Skip to content
Open
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
26 changes: 23 additions & 3 deletions infra/cors-worker/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand Down Expand Up @@ -523,15 +541,17 @@ 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");
}
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.
Expand All @@ -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",
});
}

Expand Down
24 changes: 15 additions & 9 deletions infra/cors-worker/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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.
Expand Down Expand Up @@ -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"]) {
Expand Down
4 changes: 2 additions & 2 deletions infra/cors-worker/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand All @@ -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),
Expand Down
15 changes: 14 additions & 1 deletion infra/cors-worker/worker.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(""), []);
Expand Down
Loading