fix(deploy): unify backend URL policy

This commit is contained in:
2026-07-12 00:54:39 +02:00
parent a3a266fd81
commit ebdd3aa2c5
10 changed files with 138 additions and 20 deletions
+26
View File
@@ -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 }
]
+8
View File
@@ -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
}
+7
View File
@@ -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", () => {
+12 -6
View File
@@ -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",
);
}