diff --git a/docker/frontend-entrypoint.sh b/docker/frontend-entrypoint.sh index e7c8fea6..15554e4a 100644 --- a/docker/frontend-entrypoint.sh +++ b/docker/frontend-entrypoint.sh @@ -2,19 +2,10 @@ set -eu backend_base_url=${BACKEND_BASE_URL-/api} -case "$backend_base_url" in - ""|/|/api|/api/) ;; - http://*|https://*) - if printf '%s' "$backend_base_url" | grep -Eq '[[:space:]]|^https?://[^/]*@'; then - echo "Invalid BACKEND_BASE_URL: credentials and whitespace are not allowed" >&2 - exit 2 - fi - ;; - *) - echo "Invalid BACKEND_BASE_URL: use empty/root, /api, or an absolute http(s) URL" >&2 - exit 2 - ;; -esac +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 + 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" \ diff --git a/docker/frontend.Dockerfile b/docker/frontend.Dockerfile index 75065583..c232846a 100644 --- a/docker/frontend.Dockerfile +++ b/docker/frontend.Dockerfile @@ -12,8 +12,13 @@ RUN apk add --no-cache jq COPY --from=build /src/frontend/dist /usr/share/nginx/html COPY docker/nginx.conf.template /etc/nginx/conf.d/default.conf COPY docker/frontend-entrypoint.sh /usr/local/bin/frontend-entrypoint +COPY docker/validate-backend-url.sh /usr/local/bin/validate-backend-url COPY docker/smoke/frontend-smoke.sh /usr/local/bin/frontend-config-smoke -RUN chmod 0555 /usr/local/bin/frontend-entrypoint /usr/local/bin/frontend-config-smoke \ +COPY docker/smoke/frontend-policy-smoke.sh /usr/local/bin/frontend-policy-smoke +COPY frontend/src/api/backend-url-policy.json /etc/thothii/backend-url-policy.json +COPY frontend/src/api/backend-url-cases.json /etc/thothii/backend-url-cases.json +RUN chmod 0555 /usr/local/bin/frontend-entrypoint /usr/local/bin/validate-backend-url \ + /usr/local/bin/frontend-config-smoke /usr/local/bin/frontend-policy-smoke \ && chown -R 101:101 /usr/share/nginx/html EXPOSE 8080 diff --git a/docker/smoke/frontend-policy-smoke.sh b/docker/smoke/frontend-policy-smoke.sh new file mode 100755 index 00000000..309009d4 --- /dev/null +++ b/docker/smoke/frontend-policy-smoke.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -eu + +corpus=/etc/thothii/backend-url-cases.json + +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 + exit 1 + fi +done + +echo "frontend entrypoint canonical URL corpus: ok" diff --git a/docker/validate-backend-url.sh b/docker/validate-backend-url.sh new file mode 100755 index 00000000..374e581b --- /dev/null +++ b/docker/validate-backend-url.sh @@ -0,0 +1,30 @@ +#!/bin/sh +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 diff --git a/frontend/src/api/backend-url-cases.json b/frontend/src/api/backend-url-cases.json new file mode 100644 index 00000000..5391a1ed --- /dev/null +++ b/frontend/src/api/backend-url-cases.json @@ -0,0 +1,26 @@ +[ + { "value": "", "valid": true }, + { "value": "/", "valid": true }, + { "value": "/api", "valid": true }, + { "value": "/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": "/backend", "valid": false }, + { "value": "api", "valid": false }, + { "value": "//evil.test", "valid": false }, + { "value": "http:///missing-authority", "valid": false }, + { "value": "https:///triple-slash", "valid": false }, + { "value": "http://", "valid": false }, + { "value": "http://example.test:abc", "valid": false }, + { "value": "http://example.test:65536", "valid": false }, + { "value": "http://example.test:999999999999999999999", "valid": false }, + { "value": "http://example.test:", "valid": false }, + { "value": "https://user:pass@example.test", "valid": false }, + { "value": "https://example.test/path with space", "valid": false }, + { "value": "ftp://example.test", "valid": false }, + { "value": "https://example.test/api?tenant=x", "valid": false }, + { "value": "https://example.test/api#fragment", "valid": false } +] diff --git a/frontend/src/api/backend-url-policy.json b/frontend/src/api/backend-url-policy.json new file mode 100644 index 00000000..b97c5da2 --- /dev/null +++ b/frontend/src/api/backend-url-policy.json @@ -0,0 +1,8 @@ +{ + "relativeBases": ["", "/", "/api", "/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, + "fragmentAllowed": false, + "credentialsAllowed": false +} diff --git a/frontend/src/api/runtime-config.test.ts b/frontend/src/api/runtime-config.test.ts index 27761769..0753694d 100644 --- a/frontend/src/api/runtime-config.test.ts +++ b/frontend/src/api/runtime-config.test.ts @@ -1,6 +1,7 @@ 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", () => { @@ -28,6 +29,12 @@ describe("resolveBackendUrl", () => { 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 d8d38cf2..9cfab5f5 100644 --- a/frontend/src/api/runtime-config.ts +++ b/frontend/src/api/runtime-config.ts @@ -1,3 +1,5 @@ +import policy from "./backend-url-policy.json"; + export interface RuntimeConfig { backendBaseUrl?: string; } @@ -10,17 +12,21 @@ declare global { export function resolveBackendUrl(config: RuntimeConfig | undefined): string { const value = config?.backendBaseUrl ?? import.meta.env.VITE_BACKEND_URL ?? ""; - if (value === "" || value === "/" || value === "/api" || value === "/api/") return value; + if (policy.relativeBases.includes(value)) return value; try { - const url = new URL(value); - if ((url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password) { - return value; - } + 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 an absolute http(s) URL without credentials", + "Invalid BACKEND_BASE_URL: use empty/root, /api, or a valid http(s) base without credentials, query, or fragment", ); } diff --git a/scripts/test-backend-url-policy.sh b/scripts/test-backend-url-policy.sh new file mode 100755 index 00000000..c82dd570 --- /dev/null +++ b/scripts/test-backend-url-policy.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -eu + +cd "$(dirname "$0")/.." + +policy=frontend/src/api/backend-url-policy.json +corpus=frontend/src/api/backend-url-cases.json + +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_URL_POLICY_FILE="$policy" ./docker/validate-backend-url.sh "$value"; then + actual=true + else + actual=false + fi + if [ "$actual" != "$valid" ]; then + echo "shell policy mismatch for BACKEND_BASE_URL=$value: expected $valid" >&2 + exit 1 + fi +done + +echo "shell canonical URL corpus: ok" diff --git a/scripts/verify-container-images.sh b/scripts/verify-container-images.sh index d3d8660c..15513859 100755 --- a/scripts/verify-container-images.sh +++ b/scripts/verify-container-images.sh @@ -22,6 +22,7 @@ docker run --rm --platform "$platform" -e BACKEND_BASE_URL=/api \ "$frontend_image" frontend-config-smoke docker run --rm --platform "$platform" -e BACKEND_BASE_URL= \ "$frontend_image" frontend-config-smoke +docker run --rm --platform "$platform" --entrypoint frontend-policy-smoke "$frontend_image" if docker run --rm --platform "$platform" -e BACKEND_BASE_URL=/backend \ "$frontend_image" frontend-config-smoke >/dev/null 2>&1; then