From 8ffcaf3db9bedf145e14cb81fc43e382ae9cded2 Mon Sep 17 00:00:00 2001 From: mptyl Date: Tue, 4 Aug 2026 15:48:15 +0200 Subject: [PATCH] deploy: route frontend and core through one origin --- docker/frontend-entrypoint.sh | 22 ++++++++----- docker/frontend.Dockerfile | 14 +++------ docker/nginx.conf.template | 12 +++----- docker/smoke/frontend-policy-smoke.sh | 39 ++++++++++++++++-------- docker/validate-backend-url.sh | 26 ++-------------- frontend/src/api/backend-url-cases.json | 20 ++++++------ frontend/src/api/backend-url-policy.json | 2 +- frontend/src/api/runtime-config.test.ts | 30 +++++------------- frontend/src/api/runtime-config.ts | 37 +++------------------- scripts/test-backend-url-policy.sh | 34 +++++++++++++++++++++ 10 files changed, 109 insertions(+), 127 deletions(-) diff --git a/docker/frontend-entrypoint.sh b/docker/frontend-entrypoint.sh index 15554e4a..7392aaac 100644 --- a/docker/frontend-entrypoint.sh +++ b/docker/frontend-entrypoint.sh @@ -1,15 +1,23 @@ #!/bin/sh set -eu -backend_base_url=${BACKEND_BASE_URL-/api} -if ! /usr/local/bin/validate-backend-url "$backend_base_url"; then - echo "Invalid BACKEND_BASE_URL: use empty/root, /api, or a valid http(s) base without credentials, query, or fragment" >&2 +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 +export THT_FRONTEND_API_UPSTREAM + +if ! envsubst '${THT_FRONTEND_API_UPSTREAM}' \ + < /etc/nginx/templates/default.conf.template \ + > /etc/nginx/conf.d/default.conf; then + echo "Unable to render nginx API upstream configuration" >&2 exit 2 fi -runtime_config=$(jq -cn --arg backend_base_url "$backend_base_url" \ - '{backendBaseUrl: $backend_base_url}') -printf 'window.__THOTHII_CONFIG__ = %s;\n' "$runtime_config" \ - > /usr/share/nginx/html/config.js if [ "$#" -gt 0 ]; then exec "$@" diff --git a/docker/frontend.Dockerfile b/docker/frontend.Dockerfile index f3a09b71..c629111a 100644 --- a/docker/frontend.Dockerfile +++ b/docker/frontend.Dockerfile @@ -1,21 +1,17 @@ # syntax=docker/dockerfile:1.7 # thothii-frontend: build Vite (React) + nginx-unprivileged (porta 8080). -# Build args: -# VITE_BASE prefisso asset ("/" standalone, "/datamart-builder/assets/" embedded) -# VITE_BACKEND_URL base API ("http://localhost:8787" standalone, "/datamart-builder/api" embedded) FROM node:22-bookworm AS build WORKDIR /src COPY frontend/package*.json ./ RUN npm ci COPY frontend/ ./ -ARG VITE_BASE=/ -ARG VITE_BACKEND_URL=http://localhost:8787 -ENV VITE_BASE=$VITE_BASE VITE_BACKEND_URL=$VITE_BACKEND_URL +ENV VITE_BASE=/ VITE_BACKEND_URL=/api RUN npm run build -# typecheck opzionale (non bloccante nella build dell'immagine) -RUN npx tsc -b 2>/dev/null || true FROM nginxinc/nginx-unprivileged:1.27-alpine AS runtime COPY --from=build /src/dist /usr/share/nginx/html -COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +COPY docker/nginx.conf.template /etc/nginx/templates/default.conf.template +COPY --chmod=755 docker/frontend-entrypoint.sh /usr/local/bin/frontend-entrypoint +ENTRYPOINT ["/usr/local/bin/frontend-entrypoint"] +CMD ["nginx", "-g", "daemon off;"] EXPOSE 8080 diff --git a/docker/nginx.conf.template b/docker/nginx.conf.template index 83226fe6..7668dbab 100644 --- a/docker/nginx.conf.template +++ b/docker/nginx.conf.template @@ -3,20 +3,16 @@ server { server_name _; root /usr/share/nginx/html; - location = /config.js { - add_header Cache-Control "no-store"; - try_files $uri =404; - } - location = /health { - proxy_pass http://core:8787/health; + proxy_pass ${THT_FRONTEND_API_UPSTREAM}/health; proxy_http_version 1.1; proxy_set_header Host $host; proxy_cache off; } location /api/ { - proxy_pass http://core:8787/; + # The trailing slash replaces the matched /api/ prefix before the private hop. + proxy_pass ${THT_FRONTEND_API_UPSTREAM}/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -27,7 +23,7 @@ server { proxy_set_header X-Authenticated-User $http_x_authenticated_user; proxy_buffering off; proxy_cache off; - proxy_read_timeout 1h; + proxy_read_timeout 3600s; } location / { diff --git a/docker/smoke/frontend-policy-smoke.sh b/docker/smoke/frontend-policy-smoke.sh index 309009d4..4ed27449 100755 --- a/docker/smoke/frontend-policy-smoke.sh +++ b/docker/smoke/frontend-policy-smoke.sh @@ -1,21 +1,34 @@ #!/bin/sh set -eu -corpus=/etc/thothii/backend-url-cases.json +cd "$(dirname "$0")/../.." -jq -c '.[]' "$corpus" | while IFS= read -r case_json; do - value=$(printf '%s' "$case_json" | jq -r '.value') - valid=$(printf '%s' "$case_json" | jq -r '.valid') - if BACKEND_BASE_URL="$value" /usr/local/bin/frontend-entrypoint true \ - >/dev/null 2>&1; then - actual=true - else - actual=false - fi - if [ "$actual" != "$valid" ]; then - echo "entrypoint policy mismatch for BACKEND_BASE_URL=$value: expected $valid" >&2 +nginx_config=docker/nginx.conf.template +for setting in \ + 'proxy_pass ${THT_FRONTEND_API_UPSTREAM}/;' \ + 'proxy_http_version 1.1;' \ + 'proxy_buffering off;' \ + 'proxy_read_timeout 3600s;'; do + if ! grep -Fq "$setting" "$nginx_config"; then + echo "missing required nginx API/SSE setting: $setting" >&2 exit 1 fi done -echo "frontend entrypoint canonical URL corpus: ok" +if ! grep -Fqx 'THT_FRONTEND_API_UPSTREAM=${THT_FRONTEND_API_UPSTREAM:-http://core:8787}' \ + docker/frontend-entrypoint.sh; then + echo "frontend entrypoint is missing the private core default" >&2 + exit 1 +fi + +if ! grep -Fq "envsubst '\${THT_FRONTEND_API_UPSTREAM}'" docker/frontend-entrypoint.sh; then + echo "frontend entrypoint does not render the private upstream" >&2 + exit 1 +fi + +if rg -n 'BACKEND_BASE_URL|VITE_BACKEND_URL' docker/frontend-entrypoint.sh docker/nginx.conf.template; then + echo "frontend runtime routing still accepts a browser-facing backend URL" >&2 + exit 1 +fi + +echo "frontend same-origin proxy policy: ok" diff --git a/docker/validate-backend-url.sh b/docker/validate-backend-url.sh index 374e581b..ed139578 100755 --- a/docker/validate-backend-url.sh +++ b/docker/validate-backend-url.sh @@ -4,27 +4,5 @@ set -eu value=${1-} policy_file=${BACKEND_URL_POLICY_FILE:-/etc/thothii/backend-url-policy.json} -if jq -e --arg value "$value" '.relativeBases | index($value) != null' \ - "$policy_file" >/dev/null; then - exit 0 -fi - -if ! jq -e --arg value "$value" \ - '.absolutePattern as $pattern | $value | test($pattern)' \ - "$policy_file" >/dev/null; then - exit 2 -fi - -authority=${value#*://} -authority=${authority%%/*} -port="" -case "$authority" in - *]:*) port=${authority##*:} ;; - *]) ;; - *:*) port=${authority##*:} ;; -esac - -if [ -n "$port" ]; then - max_port=$(jq -r '.maxPort' "$policy_file") - if [ "${#port}" -gt 5 ] || [ "$port" -gt "$max_port" ]; then exit 2; fi -fi +jq -e --arg value "$value" '.relativeBases | index($value) != null' \ + "$policy_file" >/dev/null diff --git a/frontend/src/api/backend-url-cases.json b/frontend/src/api/backend-url-cases.json index 6648ede9..6daccf5a 100644 --- a/frontend/src/api/backend-url-cases.json +++ b/frontend/src/api/backend-url-cases.json @@ -1,15 +1,15 @@ [ - { "value": "", "valid": true }, - { "value": "/", "valid": true }, { "value": "/api", "valid": true }, - { "value": "/api/", "valid": true }, - { "value": "/datamart-builder/api", "valid": true }, - { "value": "/datamart-builder/api/", "valid": true }, - { "value": "http://localhost:8787", "valid": true }, - { "value": "https://api.example.test/v1", "valid": true }, - { "value": "https://api.example.test/base/path/", "valid": true }, - { "value": "http://127.0.0.1:1/api", "valid": true }, - { "value": "http://[::1]:8787/api", "valid": true }, + { "value": "", "valid": false }, + { "value": "/", "valid": false }, + { "value": "/api/", "valid": false }, + { "value": "/datamart-builder/api", "valid": false }, + { "value": "/datamart-builder/api/", "valid": false }, + { "value": "http://localhost:8787", "valid": false }, + { "value": "https://api.example.test/v1", "valid": false }, + { "value": "https://api.example.test/base/path/", "valid": false }, + { "value": "http://127.0.0.1:1/api", "valid": false }, + { "value": "http://[::1]:8787/api", "valid": false }, { "value": "/backend", "valid": false }, { "value": "api", "valid": false }, { "value": "//evil.test", "valid": false }, diff --git a/frontend/src/api/backend-url-policy.json b/frontend/src/api/backend-url-policy.json index 05087223..470194fa 100644 --- a/frontend/src/api/backend-url-policy.json +++ b/frontend/src/api/backend-url-policy.json @@ -1,5 +1,5 @@ { - "relativeBases": ["", "/", "/api", "/api/", "/datamart-builder/api", "/datamart-builder/api/"], + "relativeBases": ["/api"], "absolutePattern": "^https?://(?:\\[[0-9A-Fa-f:.]+\\]|[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?)(?::[0-9]+)?(?:/[^\\s?#]*)?/?$", "maxPort": 65535, "queryAllowed": false, diff --git a/frontend/src/api/runtime-config.test.ts b/frontend/src/api/runtime-config.test.ts index 0753694d..5589dddb 100644 --- a/frontend/src/api/runtime-config.test.ts +++ b/frontend/src/api/runtime-config.test.ts @@ -1,40 +1,26 @@ import { describe, expect, it } from "vitest"; import { backendBaseUrl, joinBackendPath, resolveBackendUrl } from "./runtime-config"; -import cases from "./backend-url-cases.json"; describe("resolveBackendUrl", () => { - it("uses the runtime-injected backend URL", () => { - expect(resolveBackendUrl({ backendBaseUrl: "/api" })).toBe("/api"); + it("uses same-origin /api by default", () => { + expect(resolveBackendUrl()).toBe("/api"); }); - it("falls back to the Vite backend URL", () => { - expect(resolveBackendUrl(undefined)).toBe(import.meta.env.VITE_BACKEND_URL ?? ""); + it("does not allow a browser-facing backend override", () => { + expect(() => resolveBackendUrl("https://api.example.test")).toThrow(/same-origin/i); }); - it("preserves the client default when Vite has no configured backend", () => { - expect(backendBaseUrl).toBe(import.meta.env.VITE_BACKEND_URL ?? "http://localhost:8787"); + it("keeps the exported browser base on /api", () => { + expect(backendBaseUrl).toBe("/api"); }); it.each(["/backend", "api", "//evil.test", "ftp://example.test", "https://user:pass@example.test"])( - "rejects unsupported backend URL %j", + "rejects any browser-facing backend URL %j", (backendBaseUrl) => { - expect(() => resolveBackendUrl({ backendBaseUrl })).toThrow(/BACKEND_BASE_URL/); + expect(() => resolveBackendUrl(backendBaseUrl)).toThrow(/same-origin/i); }, ); - - it.each(["", "/", "/api", "/api/", "http://localhost:8787", "https://api.example.test/v1"])( - "accepts supported backend URL %j", - (backendBaseUrl) => { - expect(resolveBackendUrl({ backendBaseUrl })).toBe(backendBaseUrl); - }, - ); - - it.each(cases)("applies the canonical policy to $value", ({ value, valid }) => { - const resolve = () => resolveBackendUrl({ backendBaseUrl: value }); - if (valid) expect(resolve()).toBe(value); - else expect(resolve).toThrow(/BACKEND_BASE_URL/); - }); }); describe("joinBackendPath", () => { diff --git a/frontend/src/api/runtime-config.ts b/frontend/src/api/runtime-config.ts index 9cfab5f5..4533bad3 100644 --- a/frontend/src/api/runtime-config.ts +++ b/frontend/src/api/runtime-config.ts @@ -1,33 +1,6 @@ -import policy from "./backend-url-policy.json"; - -export interface RuntimeConfig { - backendBaseUrl?: string; -} - -declare global { - interface Window { - __THOTHII_CONFIG__?: RuntimeConfig; - } -} - -export function resolveBackendUrl(config: RuntimeConfig | undefined): string { - const value = config?.backendBaseUrl ?? import.meta.env.VITE_BACKEND_URL ?? ""; - if (policy.relativeBases.includes(value)) return value; - try { - if (!new RegExp(policy.absolutePattern).test(value)) throw new Error("syntax"); - const authority = value.replace(/^https?:\/\//, "").split("/", 1)[0]; - const suffix = authority.startsWith("[") - ? authority.slice(authority.indexOf("]") + 1) - : authority.slice(authority.lastIndexOf(":")); - const port = suffix.startsWith(":") ? suffix.slice(1) : ""; - if (port && (port.length > 5 || Number(port) > policy.maxPort)) throw new Error("port"); - return value; - } catch { - // Fall through to the single actionable runtime error below. - } - throw new Error( - "Invalid BACKEND_BASE_URL: use empty/root, /api, or a valid http(s) base without credentials, query, or fragment", - ); +export function resolveBackendUrl(value?: string): string { + if (value === undefined || value === "/api") return "/api"; + throw new Error("Invalid backend URL: the browser must use the same-origin /api route"); } export function joinBackendPath(base: string, path: string): string { @@ -36,6 +9,4 @@ export function joinBackendPath(base: string, path: string): string { return `${normalizedBase}/${normalizedPath}`; } -export const backendBaseUrl = - resolveBackendUrl(typeof window === "undefined" ? undefined : window.__THOTHII_CONFIG__) || - "http://localhost:8787"; +export const backendBaseUrl = resolveBackendUrl(); diff --git a/scripts/test-backend-url-policy.sh b/scripts/test-backend-url-policy.sh index c82dd570..c2b747ad 100755 --- a/scripts/test-backend-url-policy.sh +++ b/scripts/test-backend-url-policy.sh @@ -6,6 +6,40 @@ cd "$(dirname "$0")/.." policy=frontend/src/api/backend-url-policy.json corpus=frontend/src/api/backend-url-cases.json +assert_policy() { + value=$1 + expected=$2 + if BACKEND_URL_POLICY_FILE="$policy" ./docker/validate-backend-url.sh "$value"; then + actual=true + else + actual=false + fi + if [ "$actual" != "$expected" ]; then + echo "browser URL policy mismatch for $value: expected $expected" >&2 + exit 1 + fi +} + +assert_policy /api true +assert_policy /datamart-builder/api false +assert_policy http://localhost:8787 false +assert_policy https://api.example.test/v1 false + +if rg -n '^ARG VITE_(BASE|BACKEND_URL)' docker/frontend.Dockerfile; then + echo "frontend image must not expose deployment-specific Vite build arguments" >&2 + exit 1 +fi + +if ! rg -Fx 'ENV VITE_BASE=/ VITE_BACKEND_URL=/api' docker/frontend.Dockerfile >/dev/null; then + echo "frontend image must build the fixed / assets and /api browser contract" >&2 + exit 1 +fi + +if rg -n 'datamart-builder' frontend/src/api/runtime-config.ts frontend/src/api/backend-url-policy.json docker/nginx.conf.template docker/frontend-entrypoint.sh docker/frontend.Dockerfile; then + echo "active frontend routing still assumes a portal prefix" >&2 + exit 1 +fi + jq -c '.[]' "$corpus" | while IFS= read -r case_json; do value=$(printf '%s' "$case_json" | jq -r '.value') valid=$(printf '%s' "$case_json" | jq -r '.valid')