diff --git a/docker/frontend-entrypoint.sh b/docker/frontend-entrypoint.sh index 7392aaac..ff529749 100644 --- a/docker/frontend-entrypoint.sh +++ b/docker/frontend-entrypoint.sh @@ -3,13 +3,10 @@ set -eu THT_FRONTEND_API_UPSTREAM=${THT_FRONTEND_API_UPSTREAM:-http://core:8787} THT_FRONTEND_API_UPSTREAM=${THT_FRONTEND_API_UPSTREAM%/} -case "$THT_FRONTEND_API_UPSTREAM" in - http://*|https://*) ;; - *) - echo "Invalid THT_FRONTEND_API_UPSTREAM: use an internal http(s) upstream" >&2 - exit 2 - ;; -esac +if ! /usr/local/bin/validate-frontend-api-upstream "$THT_FRONTEND_API_UPSTREAM"; then + echo "Invalid THT_FRONTEND_API_UPSTREAM: expected internal http://core:8787" >&2 + exit 2 +fi export THT_FRONTEND_API_UPSTREAM if ! envsubst '${THT_FRONTEND_API_UPSTREAM}' \ diff --git a/docker/frontend.Dockerfile b/docker/frontend.Dockerfile index c629111a..80305b6f 100644 --- a/docker/frontend.Dockerfile +++ b/docker/frontend.Dockerfile @@ -12,6 +12,7 @@ FROM nginxinc/nginx-unprivileged:1.27-alpine AS runtime COPY --from=build /src/dist /usr/share/nginx/html COPY docker/nginx.conf.template /etc/nginx/templates/default.conf.template COPY --chmod=755 docker/frontend-entrypoint.sh /usr/local/bin/frontend-entrypoint +COPY --chmod=755 docker/validate-frontend-api-upstream.sh /usr/local/bin/validate-frontend-api-upstream ENTRYPOINT ["/usr/local/bin/frontend-entrypoint"] CMD ["nginx", "-g", "daemon off;"] EXPOSE 8080 diff --git a/docker/smoke/frontend-policy-smoke.sh b/docker/smoke/frontend-policy-smoke.sh index 4ed27449..d4d7c6b6 100755 --- a/docker/smoke/frontend-policy-smoke.sh +++ b/docker/smoke/frontend-policy-smoke.sh @@ -31,4 +31,27 @@ if rg -n 'BACKEND_BASE_URL|VITE_BACKEND_URL' docker/frontend-entrypoint.sh docke exit 1 fi +upstream_validator=docker/validate-frontend-api-upstream.sh +for upstream in http://core:8787 http://core:8787/; do + if ! "$upstream_validator" "$upstream"; then + echo "frontend upstream validator rejected $upstream" >&2 + exit 1 + fi +done + +for upstream in \ + https://core:8787 \ + http://core:8080 \ + http://core:8787/api \ + http://user:pass@core:8787 \ + 'http://core:8787?next=evil' \ + 'http://core:8787#fragment' \ + 'http://core:8787 injected' \ + 'http://core:8787;proxy_pass http://evil'; do + if "$upstream_validator" "$upstream" >/dev/null 2>&1; then + echo "frontend upstream validator accepted unsafe upstream: $upstream" >&2 + exit 1 + fi +done + echo "frontend same-origin proxy policy: ok" diff --git a/docker/validate-frontend-api-upstream.sh b/docker/validate-frontend-api-upstream.sh new file mode 100755 index 00000000..65e4de27 --- /dev/null +++ b/docker/validate-frontend-api-upstream.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu + +case "${1-}" in + http://core:8787|http://core:8787/) exit 0 ;; + *) exit 2 ;; +esac diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 01a047cc..6b98f78a 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,6 +4,7 @@ import path from "path"; export default defineConfig(() => { const embedBase = process.env.VITE_BASE; // "/datamart-builder/assets/" in embedded; undefined = standalone + const apiUpstream = process.env.THT_FRONTEND_API_UPSTREAM ?? "http://localhost:8787"; return { plugins: [react()], // base: prefisso pubblico degli asset. Default "/" (standalone). @@ -11,6 +12,15 @@ export default defineConfig(() => { // /datamart-builder/assets/ → frontend-root mappa 1:1 (niente /assets/assets/). base: embedBase ?? "/", build: { manifest: true, outDir: "dist", assetsDir: embedBase ? "" : "assets" }, + server: { + proxy: { + "/api": { + target: apiUpstream, + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api(?=\/|$)/, ""), + }, + }, + }, resolve: { alias: { "@": path.resolve(__dirname, "./src") } }, }; }); diff --git a/scripts/run-stack.sh b/scripts/run-stack.sh index ced0ae1d..4befb659 100755 --- a/scripts/run-stack.sh +++ b/scripts/run-stack.sh @@ -55,10 +55,10 @@ trap cleanup EXIT INT TERM ) & pids+=($!) -# Frontend: punta al backend reale. +# Frontend: il browser usa sempre /api; Vite lo inoltra al backend locale. ( cd "$FRONTEND" - VITE_BACKEND_URL="http://localhost:$BACKEND_PORT" \ + THT_FRONTEND_API_UPSTREAM="http://localhost:$BACKEND_PORT" \ npm run dev -- --port "$FRONTEND_PORT" ) & pids+=($!) diff --git a/scripts/test-local-dev-routing.sh b/scripts/test-local-dev-routing.sh new file mode 100755 index 00000000..717853f9 --- /dev/null +++ b/scripts/test-local-dev-routing.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +TMPDIR_TEST="$(mktemp -d)" +PIDS=() + +cleanup() { + for pid in "${PIDS[@]}"; do kill "$pid" 2>/dev/null || true; done + rm -rf "$TMPDIR_TEST" +} +trap cleanup EXIT INT TERM + +free_port() { + node -e 'const server = require("node:net").createServer(); server.listen(0, "127.0.0.1", () => { console.log(server.address().port); server.close(); });' +} + +backend_port_file="$TMPDIR_TEST/backend-port" +node -e ' +const Fastify = require(process.argv[1]); +const fs = require("node:fs"); +const app = Fastify(); +app.get("/health", async (request) => ({ backend: "fastify", path: request.raw.url })); +app.listen({ port: 0, host: "127.0.0.1" }).then((address) => { + fs.writeFileSync(process.argv[2], String(new URL(address).port)); +}); +' "$ROOT/backend/node_modules/fastify" "$backend_port_file" >"$TMPDIR_TEST/backend.log" 2>&1 & +PIDS+=("$!") + +for _ in {1..50}; do + [ -s "$backend_port_file" ] && break + sleep 0.1 +done +[ -s "$backend_port_file" ] || { cat "$TMPDIR_TEST/backend.log" >&2; exit 1; } +backend_port="$(<"$backend_port_file")" +frontend_port="$(free_port)" + +THT_FRONTEND_API_UPSTREAM="http://127.0.0.1:$backend_port" \ + npm --prefix "$ROOT/frontend" run dev -- --host 127.0.0.1 --port "$frontend_port" \ + >"$TMPDIR_TEST/vite.log" 2>&1 & +PIDS+=("$!") + +response="" +for _ in {1..50}; do + if response="$(curl -fsS "http://127.0.0.1:$frontend_port/api/health" 2>/dev/null)"; then + break + fi + sleep 0.1 +done + +if [ "$response" != '{"backend":"fastify","path":"/health"}' ]; then + cat "$TMPDIR_TEST/vite.log" >&2 + printf 'expected Vite /api/health to reach Fastify /health, got: %s\n' "$response" >&2 + exit 1 +fi + +if ! rg -Fq 'THT_FRONTEND_API_UPSTREAM="http://localhost:$BACKEND_PORT"' "$ROOT/scripts/run-stack.sh"; then + echo "run-stack.sh must configure the Vite internal upstream from BACKEND_PORT" >&2 + exit 1 +fi + +if rg -q 'VITE_BACKEND_URL' "$ROOT/scripts/run-stack.sh"; then + echo "run-stack.sh must keep the browser base on /api" >&2 + exit 1 +fi + +echo "local browser /api routes to Fastify through the Vite proxy: ok"