deploy: route frontend and core through one origin
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user