fix(deploy): unify backend URL policy
This commit is contained in:
@@ -2,19 +2,10 @@
|
|||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
backend_base_url=${BACKEND_BASE_URL-/api}
|
backend_base_url=${BACKEND_BASE_URL-/api}
|
||||||
case "$backend_base_url" in
|
if ! /usr/local/bin/validate-backend-url "$backend_base_url"; then
|
||||||
""|/|/api|/api/) ;;
|
echo "Invalid BACKEND_BASE_URL: use empty/root, /api, or a valid http(s) base without credentials, query, or fragment" >&2
|
||||||
http://*|https://*)
|
exit 2
|
||||||
if printf '%s' "$backend_base_url" | grep -Eq '[[:space:]]|^https?://[^/]*@'; then
|
fi
|
||||||
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
|
|
||||||
runtime_config=$(jq -cn --arg backend_base_url "$backend_base_url" \
|
runtime_config=$(jq -cn --arg backend_base_url "$backend_base_url" \
|
||||||
'{backendBaseUrl: $backend_base_url}')
|
'{backendBaseUrl: $backend_base_url}')
|
||||||
printf 'window.__THOTHII_CONFIG__ = %s;\n' "$runtime_config" \
|
printf 'window.__THOTHII_CONFIG__ = %s;\n' "$runtime_config" \
|
||||||
|
|||||||
@@ -12,8 +12,13 @@ RUN apk add --no-cache jq
|
|||||||
COPY --from=build /src/frontend/dist /usr/share/nginx/html
|
COPY --from=build /src/frontend/dist /usr/share/nginx/html
|
||||||
COPY docker/nginx.conf.template /etc/nginx/conf.d/default.conf
|
COPY docker/nginx.conf.template /etc/nginx/conf.d/default.conf
|
||||||
COPY docker/frontend-entrypoint.sh /usr/local/bin/frontend-entrypoint
|
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
|
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
|
&& chown -R 101:101 /usr/share/nginx/html
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
Executable
+21
@@ -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"
|
||||||
Executable
+30
@@ -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
|
||||||
@@ -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 }
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { backendBaseUrl, joinBackendPath, resolveBackendUrl } from "./runtime-config";
|
import { backendBaseUrl, joinBackendPath, resolveBackendUrl } from "./runtime-config";
|
||||||
|
import cases from "./backend-url-cases.json";
|
||||||
|
|
||||||
describe("resolveBackendUrl", () => {
|
describe("resolveBackendUrl", () => {
|
||||||
it("uses the runtime-injected backend URL", () => {
|
it("uses the runtime-injected backend URL", () => {
|
||||||
@@ -28,6 +29,12 @@ describe("resolveBackendUrl", () => {
|
|||||||
expect(resolveBackendUrl({ backendBaseUrl })).toBe(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", () => {
|
describe("joinBackendPath", () => {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import policy from "./backend-url-policy.json";
|
||||||
|
|
||||||
export interface RuntimeConfig {
|
export interface RuntimeConfig {
|
||||||
backendBaseUrl?: string;
|
backendBaseUrl?: string;
|
||||||
}
|
}
|
||||||
@@ -10,17 +12,21 @@ declare global {
|
|||||||
|
|
||||||
export function resolveBackendUrl(config: RuntimeConfig | undefined): string {
|
export function resolveBackendUrl(config: RuntimeConfig | undefined): string {
|
||||||
const value = config?.backendBaseUrl ?? import.meta.env.VITE_BACKEND_URL ?? "";
|
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 {
|
try {
|
||||||
const url = new URL(value);
|
if (!new RegExp(policy.absolutePattern).test(value)) throw new Error("syntax");
|
||||||
if ((url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password) {
|
const authority = value.replace(/^https?:\/\//, "").split("/", 1)[0];
|
||||||
return value;
|
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 {
|
} catch {
|
||||||
// Fall through to the single actionable runtime error below.
|
// Fall through to the single actionable runtime error below.
|
||||||
}
|
}
|
||||||
throw new Error(
|
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",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Executable
+23
@@ -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"
|
||||||
@@ -22,6 +22,7 @@ docker run --rm --platform "$platform" -e BACKEND_BASE_URL=/api \
|
|||||||
"$frontend_image" frontend-config-smoke
|
"$frontend_image" frontend-config-smoke
|
||||||
docker run --rm --platform "$platform" -e BACKEND_BASE_URL= \
|
docker run --rm --platform "$platform" -e BACKEND_BASE_URL= \
|
||||||
"$frontend_image" frontend-config-smoke
|
"$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 \
|
if docker run --rm --platform "$platform" -e BACKEND_BASE_URL=/backend \
|
||||||
"$frontend_image" frontend-config-smoke >/dev/null 2>&1; then
|
"$frontend_image" frontend-config-smoke >/dev/null 2>&1; then
|
||||||
|
|||||||
Reference in New Issue
Block a user