diff --git a/servers/gateway/funnel.js b/servers/gateway/funnel.js index 2b790466..80e7eed5 100644 --- a/servers/gateway/funnel.js +++ b/servers/gateway/funnel.js @@ -29,6 +29,19 @@ export const PUBLIC_FUNNEL_PREFIXES = [ "/manifest.json", ]; +/** + * GATEWAY_FUNNEL_PUBLIC_PREFIXES: comma-separated extra entries appended to + * PUBLIC_FUNNEL_PREFIXES, for deployments that funnel additional public + * surfaces (e.g. share surfaces under /s). Same semantics as the static list: + * trailing "/" = subtree prefix, no trailing slash = exact match. The same + * three conditions at the top of this file apply to every entry added here. + * Read at middleware creation, not per request. + */ +function envFunnelPrefixes() { + return (process.env.GATEWAY_FUNNEL_PUBLIC_PREFIXES || "") + .split(",").map((s) => s.trim()).filter((s) => s.startsWith("/")); +} + /** * Express middleware: reject funneled requests to any non-public path. * @@ -42,11 +55,12 @@ export const PUBLIC_FUNNEL_PREFIXES = [ * @returns {(req, res, next) => void} */ export function rejectFunneledMiddleware() { + const allowed = [...PUBLIC_FUNNEL_PREFIXES, ...envFunnelPrefixes()]; return (req, res, next) => { if (!req.headers["tailscale-funnel-request"]) return next(); if (process.env.CROW_DASHBOARD_PUBLIC === "true") return next(); if ( - PUBLIC_FUNNEL_PREFIXES.some((p) => { + allowed.some((p) => { if (p.endsWith("/")) return req.path.startsWith(p); return req.path === p; }) diff --git a/tests/auth-network.test.js b/tests/auth-network.test.js index abf22807..3064ec82 100644 --- a/tests/auth-network.test.js +++ b/tests/auth-network.test.js @@ -206,3 +206,30 @@ test("funnel: public prefixes pass, lookalike paths are rejected", () => { // non-funnel requests always pass this middleware assert.equal(runFunnelMw("/dashboard", { funnel: false }).nexted, true); }); + +test("funnel: GATEWAY_FUNNEL_PUBLIC_PREFIXES extends the allowlist with the same semantics", () => { + process.env.GATEWAY_FUNNEL_PUBLIC_PREFIXES = "/s/,/s, /s-assets/ ,not-a-path"; + try { + // subtree entry "/s/" and exact entry "/s" + assert.equal(runFunnelMw("/s").nexted, true); + assert.equal(runFunnelMw("/s/family").nexted, true); + assert.equal(runFunnelMw("/s-assets/icons/icon-192.png").nexted, true); + // lookalikes and bare-subtree misses still rejected + assert.equal(runFunnelMw("/sX").statusCode, 403); + assert.equal(runFunnelMw("/s-assets").statusCode, 403); + // entries not starting with "/" are dropped, never matched + assert.equal(runFunnelMw("/not-a-path").statusCode, 403); + // dashboard stays private regardless of env + assert.equal(runFunnelMw("/dashboard").statusCode, 403); + // env is read at middleware creation: static list unaffected + assert.equal(runFunnelMw("/blog").nexted, true); + } finally { + delete process.env.GATEWAY_FUNNEL_PUBLIC_PREFIXES; + } +}); + +test("funnel: env unset leaves the static allowlist behavior unchanged", () => { + delete process.env.GATEWAY_FUNNEL_PUBLIC_PREFIXES; + assert.equal(runFunnelMw("/s/family").statusCode, 403); + assert.equal(runFunnelMw("/blog").nexted, true); +});