diff --git a/infra/cors-worker/core.js b/infra/cors-worker/core.js index 2062916..d531223 100644 --- a/infra/cors-worker/core.js +++ b/infra/cors-worker/core.js @@ -78,6 +78,24 @@ const IMMUTABLE_TYPE_PREFIXES = [ "application/javascript", ]; +/** + * Read the `PUBLIC` setting. Only "true" (any case) opens the proxy; anything + * else, a typo or stray whitespace included, fails closed. + */ +export function parsePublic(raw) { + return String(raw || "").toLowerCase() === "true"; +} + +/** + * How a proxy with this config behaves: "public" (open, no token), "locked" + * (token required) or "unconfigured" (setup page only, never proxies). + * `PUBLIC` wins over tokens. + */ +export function proxyMode({ tokens, isPublic }) { + if (isPublic) return "public"; + return tokens.length > 0 ? "locked" : "unconfigured"; +} + /** Split the `PROXY_TOKENS` secret ("a,b,c") into a trimmed, non-empty list. */ export function parseTokens(raw) { if (!raw) return []; @@ -523,7 +541,9 @@ export async function handleRequest(request, options = {}) { const url = new URL(request.url); const segments = pathSegments(url); - if (!isPublic && tokens.length === 0) { + const mode = proxyMode({ tokens, isPublic }); + + if (mode === "unconfigured") { // Unconfigured. Never proxy; show the human how to lock it down. if (request.method !== "GET" && request.method !== "HEAD") { return plain(405, "Method not allowed"); @@ -531,7 +551,7 @@ export async function handleRequest(request, options = {}) { return setupPage(request, setupSecretStep); } - if (!isPublic) { + if (mode === "locked") { const presented = presentedToken(url, segments); if (!isAuthorized(presented, tokens)) { // 404, not 401: a locked proxy should look dead to scanners. @@ -548,7 +568,7 @@ export async function handleRequest(request, options = {}) { return json({ name: PROXY_NAME, version: VERSION, - authRequired: !isPublic && tokens.length > 0, + authRequired: mode === "locked", }); } diff --git a/infra/cors-worker/node.js b/infra/cors-worker/node.js index 29f60f5..93fe12d 100644 --- a/infra/cors-worker/node.js +++ b/infra/cors-worker/node.js @@ -22,7 +22,14 @@ import http from "node:http"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { pathToFileURL } from "node:url"; -import { PROXY_NAME, handleRequest, parseOriginPatterns, parseTokens } from "./core.js"; +import { + PROXY_NAME, + handleRequest, + parseOriginPatterns, + parsePublic, + parseTokens, + proxyMode, +} from "./core.js"; import { createGuardedFetch } from "./guarded-fetch.js"; /** The setup page's "set the secret" step for a self-hosted proxy. */ @@ -41,7 +48,7 @@ function envSecretStep(tokenHtml) { export function createHandler(env = {}, { fetch = createGuardedFetch(), cache = null } = {}) { const options = { tokens: parseTokens(env.PROXY_TOKENS), - isPublic: String(env.PUBLIC || "").toLowerCase() === "true", + isPublic: parsePublic(env.PUBLIC), allowedOrigins: parseOriginPatterns(env.ALLOWED_ORIGINS), cache, // Nothing to keep alive outside a worker; just never leave a rejection unhandled. @@ -118,13 +125,12 @@ function main() { const host = process.env.HOST || "0.0.0.0"; const server = createServer(createHandler(process.env)); server.listen(port, host, () => { - // Same precedence as core: PUBLIC wins over PROXY_TOKENS. - const mode = String(process.env.PUBLIC || "").toLowerCase() === "true" - ? "public" - : parseTokens(process.env.PROXY_TOKENS).length - ? "token-locked" - : "unconfigured (serving the setup page)"; - console.log(`${PROXY_NAME} listening on http://${host}:${port} — ${mode}`); + const mode = proxyMode({ + tokens: parseTokens(process.env.PROXY_TOKENS), + isPublic: parsePublic(process.env.PUBLIC), + }); + const label = mode === "unconfigured" ? "unconfigured (serving the setup page)" : mode; + console.log(`${PROXY_NAME} listening on http://${host}:${port} — ${label}`); }); // Exit promptly on `docker stop` (PID 1 gets no default SIGTERM handling). for (const signal of ["SIGTERM", "SIGINT"]) { diff --git a/infra/cors-worker/worker.js b/infra/cors-worker/worker.js index 8c15668..1463386 100644 --- a/infra/cors-worker/worker.js +++ b/infra/cors-worker/worker.js @@ -10,7 +10,7 @@ * The shared public instance deploys with `npx wrangler deploy --env public`. */ -import { handleRequest, parseOriginPatterns, parseTokens } from "./core.js"; +import { handleRequest, parseOriginPatterns, parsePublic, parseTokens } from "./core.js"; import { toDataPoint } from "./stats-schema.js"; /** @@ -37,7 +37,7 @@ export default { fetch(request, env, ctx) { return handleRequest(request, { tokens: parseTokens(env.PROXY_TOKENS), - isPublic: String(env.PUBLIC || "").toLowerCase() === "true", + isPublic: parsePublic(env.PUBLIC), allowedOrigins: parseOriginPatterns(env.ALLOWED_ORIGINS), cache: globalThis.caches?.default, waitUntil: (promise) => ctx.waitUntil(promise), diff --git a/infra/cors-worker/worker.test.mjs b/infra/cors-worker/worker.test.mjs index 92fabf9..f79c453 100644 --- a/infra/cors-worker/worker.test.mjs +++ b/infra/cors-worker/worker.test.mjs @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import worker from "./worker.js"; -import { handleRequest, parseOriginPatterns, parseTokens } from "./core.js"; +import { handleRequest, parseOriginPatterns, parsePublic, parseTokens, proxyMode } from "./core.js"; import { BLOB_FIELDS, CACHE_OUTCOMES, @@ -41,6 +41,19 @@ test("the Cloudflare setup page gives the wrangler command", async () => { assert.match(await response.text(), /npx wrangler secret put PROXY_TOKENS/); }); +test("parsePublic accepts only 'true', case-insensitively", () => { + for (const raw of ["true", "TRUE", "True"]) assert.equal(parsePublic(raw), true, raw); + // Anything else fails closed (ADR 0002), stray whitespace included. + for (const raw of [undefined, "", "false", "1", "yes", "truthy", " true "]) assert.equal(parsePublic(raw), false, String(raw)); +}); + +test("proxyMode: PUBLIC wins over tokens; neither means unconfigured", () => { + assert.equal(proxyMode({ tokens: [], isPublic: true }), "public"); + assert.equal(proxyMode({ tokens: ["a"], isPublic: true }), "public"); + assert.equal(proxyMode({ tokens: ["a"], isPublic: false }), "locked"); + assert.equal(proxyMode({ tokens: [], isPublic: false }), "unconfigured"); +}); + test("parseTokens trims and drops empty entries", () => { assert.deepEqual(parseTokens(" a , ,b, "), ["a", "b"]); assert.deepEqual(parseTokens(""), []);