Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion servers/gateway/funnel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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;
})
Expand Down
27 changes: 27 additions & 0 deletions tests/auth-network.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading