diff --git a/CHANGELOG.md b/CHANGELOG.md index 050e317..230c4fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 2026-10-15 +- The shared CORS proxy now only serves allowlisted calling sites — savr.link, Netlify preview/branch deploys (`*--savrlist.netlify.app`) and localhost — via a new optional `ALLOWED_ORIGINS` setting. It still fetches articles from any public site; other websites calling it get a 404, recorded in the stats. Self-hosted Savr frontends on their own domain need their own proxy. Unset (the default for self-hosted proxies) changes nothing (ADR 0006; rate limiting tracked in #117) + - Telemetry now counts browser-extension saves separately: `capture-extension` (page scraped by the extension) and `capture-extension-extracted` (its YouTube transcript extractor), instead of folding them into the bookmarklet events. An extension too old to report its version still counts as a bookmarklet save - The `capture-raw` event is renamed `capture-bookmarklet-extracted` ("raw" was an internal term); GoatCounter keeps the old history under `capture-raw` and counts restart under the new name diff --git a/README.md b/README.md index a7641f2..1158b74 100644 --- a/README.md +++ b/README.md @@ -335,7 +335,7 @@ While Savr provides a default proxy, you can configure your own CORS proxy serve - **Privacy**: Your requests aren't going through a third-party service **Set up your own proxy (one click)** -The shared default proxy has a free-tier request cap that everyone shares. You can deploy your own copy in a couple of minutes, locked with a secret token so only your devices can use it: +The shared default proxy has a free-tier request cap that everyone shares, so it only serves the hosted app (savr.link, its preview deploys, and localhost for development). It still fetches articles from any site; it just refuses other calling sites. If you self-host the Savr app on your own domain, you need your own proxy. You can deploy your own copy in a couple of minutes, locked with a secret token so only your devices can use it: [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/jonocodes/savr/tree/main/infra/cors-worker) diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 0901905..293c5b9 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -34,7 +34,7 @@ This document describes the environment variables used by Savr. - **Description**: URL prefix for the CORS proxy used when fetching external articles. This is the build-time default; a user can override it in Preferences, where the value is stored in localStorage (not a cookie) and any token in it is masked in diagnostics and failure reports. Set to empty string to fetch directly (useful for local dev against localhost content servers). - **Usage**: `VITE_CORS_PROXY="" npm run dev` -Deploy a token-locked proxy of your own from `infra/cors-worker/` (there is a Deploy to Cloudflare button in the README). +Deploy a token-locked proxy of your own from `infra/cors-worker/` (there is a Deploy to Cloudflare button in the README). A self-hosted build served from its own domain **must** do this: the default public proxy only answers savr.link, its Netlify previews, and localhost (`ALLOWED_ORIGINS`, see `infra/cors-worker/README.md`). ### `VITE_FAILURE_REPORT_REPO` diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index 94cccd3..cd1caca 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -61,8 +61,10 @@ usage telemetry stays as described above; the proxy statistics are a separate dataset with 90-day retention, and self-hosters run their own proxy (or none), so they are unaffected. -Because the proxy is open to any public host, these counts include any non-Savr -callers; treat them as a rough operational signal, not an audience measure. +The proxy only serves allowlisted calling sites, but refused callers are +counted too (as `404`/`bypass` with no host), and non-browser clients can forge +their origin; treat the counts as a rough operational signal, not an audience +measure. The opt-out below covers the app's GoatCounter telemetry only — the proxy can't see that preference. To keep your requests out of the proxy statistics, point diff --git a/docs/adr/0006-public-proxy-origin-allowlist.md b/docs/adr/0006-public-proxy-origin-allowlist.md new file mode 100644 index 0000000..eb94dd0 --- /dev/null +++ b/docs/adr/0006-public-proxy-origin-allowlist.md @@ -0,0 +1,29 @@ +# 0006. The public proxy only serves allowlisted calling sites + +*Status: Accepted · 2026-10-15* + +## Context + +The shared public proxy is open (ADR 0002) and its CORS response was `Access-Control-Allow-Origin: *`, so any website could spend its free-tier quota (100k requests/day, shared by every Savr user) by having its visitors' browsers call it. The proxy must keep fetching **arbitrary** public article hosts, so restricting *targets* is not an option; only *callers* can be restricted. + +Browsers set the `Origin` header on every cross-origin `fetch()` and page script cannot change it. Every proxied request the app makes (article pages via `buildProxiedUrl`, images via `proxyImageSource`) is a `fetch()`, so legitimate traffic always carries it. The browser extension does not call the proxy at all (it fetches pages and images itself); its fallback goes through the app on savr.link. + +## Decision + +An optional `ALLOWED_ORIGINS` setting (comma-separated origins) restricts callers. When set: + +- a request whose `Origin` is missing, `null`, or unlisted gets the same **404** as a bad token, checked before the edge-cache lookup and before any upstream fetch; +- an allowed response echoes the caller's origin in `Access-Control-Allow-Origin` (not `*`) with `Vary: Origin`; +- `OPTIONS` preflight and `/health` stay open (neither fetches anything); +- it composes with tokens — both must pass. + +Patterns are origins (`scheme://host[:port]`). `*` in the host matches `[a-z0-9-]+` and never crosses a `.`; `:*` matches any port; no port means the default port only. + +The public instance sets it in `[env.public.vars]` to production, Netlify preview/branch deploys and local dev: `https://savr.link,https://*--savrlist.netlify.app,http://localhost:*,http://127.0.0.1:*`. When unset (the default, and every self-hosted proxy), behaviour is unchanged. + +## Consequences + +- Other websites can no longer use the public proxy from browsers. Self-hosted Savr frontends on their own domain can't either; they deploy their own proxy (or set `VITE_CORS_PROXY`), which already works. +- This is **not** protection against scripts: a non-browser client can forge `Origin`. Per-IP rate limiting is tracked separately (#117). +- Refusals are recorded as stats events (status `404`, cache `bypass`, no host, with the caller origin), so the dashboard shows who is being turned away. +- Adding a new first-party origin (a new domain, a native wrapper with its own scheme) is a config change plus redeploy of the public instance. diff --git a/docs/adr/README.md b/docs/adr/README.md index fe1f59f..159b004 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -13,3 +13,4 @@ One file per decision that is expensive to reverse or likely to be re-litigated. | [0003](0003-portable-proxy-core.md) | One portable proxy core, thin platform entries | | [0004](0004-proxy-token-is-a-secret.md) | The proxy token is a secret the app must not leak | | [0005](0005-proxy-usage-statistics.md) | Proxy usage statistics live in Analytics Engine behind a separate stats worker | +| [0006](0006-public-proxy-origin-allowlist.md) | The public proxy only serves allowlisted calling sites | diff --git a/infra/cors-stats/README.md b/infra/cors-stats/README.md index 59fa1f0..7d3de0a 100644 --- a/infra/cors-stats/README.md +++ b/infra/cors-stats/README.md @@ -26,7 +26,7 @@ and the IP itself is discarded. | ---- | ----- | -------------- | | 1 | country (ISO alpha-2) | `US`, `DE`, `T1` (Tor), `XX` (unknown) | | 2 | MIME class | `html`, `image`, `css`, `js`, `font`, `video`, `json`, `none`, … | -| 3 | status | `200`, `404`, `502` | +| 3 | status | `200`, `404`, `502` (a `404` with cache `bypass` and no host is a caller refused by `ALLOWED_ORIGINS`) | | 4 | cache | `hit` (edge cache), `miss` (origin fetch), `bypass` (HEAD / invalid) | | 5 | host | **failures only** (`status >= 400`), otherwise empty | | 6 | caller origin | `savr.link`, a self-hoster's host, `chrome-extension`, `local` (localhost / LAN / IP), `""` (none) | diff --git a/infra/cors-worker/README.md b/infra/cors-worker/README.md index 88324c5..883e349 100644 --- a/infra/cors-worker/README.md +++ b/infra/cors-worker/README.md @@ -11,10 +11,10 @@ core (`core.js`), so a Deno Deploy entry point can reuse it later. ## Files - `core.js` — portable - `handleRequest(request, { tokens, isPublic, cache, waitUntil, onRequest })`: - auth, routing, setup page, caching, SSRF guard, and the neutral statistics + `handleRequest(request, { tokens, isPublic, allowedOrigins, cache, waitUntil, onRequest })`: + auth, origin allowlist, routing, setup page, caching, SSRF guard, and the neutral statistics event port. No Cloudflare globals. -- `worker.js` — Cloudflare entry: reads `PROXY_TOKENS`/`PUBLIC` from the +- `worker.js` — Cloudflare entry: reads `PROXY_TOKENS`/`PUBLIC`/`ALLOWED_ORIGINS` from the environment, supplies `caches.default`, and writes statistics events to Analytics Engine (a failed write is logged, never fails the request). - `stats-schema.js` — the statistics schema (field order, vocabularies, @@ -71,10 +71,40 @@ https://proxy.example/?token=&url=… ← query parameter `{ name: "savr-cors-proxy", version, authRequired }`. On the public instance `/health` needs no token and reports `authRequired: false`. +## Origin allowlist + +`ALLOWED_ORIGINS` (a plain var, not a secret) restricts which **calling sites** +may use the proxy. It never restricts which article hosts it fetches. The +shared public instance sets it in `[env.public.vars]`: + +```toml +ALLOWED_ORIGINS = "https://savr.link,https://*--savrlist.netlify.app,http://localhost:*,http://127.0.0.1:*" +``` + +- Entries are origins, `scheme://host[:port]`. `*` in the host matches one or + more of `[a-z0-9-]` and never crosses a `.`, so `https://*--savrlist.netlify.app` + covers `deploy-preview-116--savrlist.netlify.app` and branch deploys, but + not `evil.x--savrlist.netlify.app`. `:*` matches any port; no port means the + default port only. Malformed entries are ignored. +- A missing, `null` or unlisted `Origin` gets the same **404** as a bad token, + checked before the edge cache and before any upstream fetch. Refusals are + recorded in the stats (status `404`, cache `bypass`, no host). +- Allowed responses echo the caller's origin in `Access-Control-Allow-Origin` + with `Vary: Origin`. `OPTIONS` and `/health` stay open. +- It composes with `PROXY_TOKENS`: both must pass. +- Unset (the default) means any caller, as before. + +Change it by editing `wrangler.toml` and redeploying (`npx wrangler deploy +--env public`). This stops other websites from spending the quota through +their visitors' browsers; it does **not** stop scripts, which can forge +`Origin`. See [ADR 0006](../../docs/adr/0006-public-proxy-origin-allowlist.md) +and #117 (rate limiting). + ## What it does `GET|HEAD /?url=` fetches `` and streams it back -with `Access-Control-Allow-Origin: *`. `OPTIONS` answers preflight. +with `Access-Control-Allow-Origin: *` (or the caller's origin, behind an +allowlist). `OPTIONS` answers preflight. ## Hardening beyond the original @@ -91,8 +121,8 @@ with `Access-Control-Allow-Origin: *`. `OPTIONS` answers preflight. full `?url=` (which can carry API keys). The old per-request logging was also needlessly expensive. -There is intentionally **no host allowlist** — it must work for arbitrary -public sites. +There is intentionally **no target-host allowlist** — it must work for +arbitrary public sites. (The origin allowlist above restricts callers only.) ## Usage statistics diff --git a/infra/cors-worker/core.js b/infra/cors-worker/core.js index d2b518f..568a2fb 100644 --- a/infra/cors-worker/core.js +++ b/infra/cors-worker/core.js @@ -24,6 +24,12 @@ * token, so it looks dead to scanners. Auth is checked before the edge * cache, so a cache hit is never served without a token. The cache key is * the target URL only, so rotating a token doesn't throw away the cache. + * - origin allowlist: an optional `ALLOWED_ORIGINS` list restricts which + * *calling sites* may use the proxy (never which targets it fetches). A + * missing or unlisted Origin gets the same dead-looking 404 as a bad token, + * checked before the edge cache. It stops other websites spending the + * quota through their visitors' browsers; it does not stop non-browser + * clients, which can forge the header. * - stats: an optional `onRequest` port emits one neutral event per recorded * request (never preflights, health checks, or setup pages) — status, MIME * class, cache outcome, and on failures the host and fetch-error kind. The @@ -47,6 +53,16 @@ const CORS_HEADERS = { "Access-Control-Max-Age": "0", }; +/** + * CORS headers for a proxied response. Behind an origin allowlist the caller's + * own origin is echoed instead of `*`, and `Vary: Origin` keeps browser caches + * from serving one site's response to another. + */ +function corsHeadersFor(allowOrigin) { + if (!allowOrigin) return CORS_HEADERS; + return { ...CORS_HEADERS, "Access-Control-Allow-Origin": allowOrigin, Vary: "Origin" }; +} + // Content that never changes for a given URL can be cached aggressively; this // is what stops a re-render from re-fetching the same image. const IMMUTABLE_TYPE_PREFIXES = [ @@ -68,6 +84,43 @@ export function parseTokens(raw) { .filter(Boolean); } +/** + * Compile the `ALLOWED_ORIGINS` setting ("a,b,c") into matchers. Each entry is + * an origin, `scheme://host[:port]`, where: + * - `*` in the host matches one or more of `[a-z0-9-]` and never a `.`, so + * `https://*--savrlist.netlify.app` covers Netlify preview deploys but not + * `https://evil.com.x--savrlist.netlify.app`; + * - a `:*` port matches any port (or none), e.g. `http://localhost:*`; + * - no port means the scheme's default port only. + * Malformed entries are dropped rather than failing every request. + */ +export function parseOriginPatterns(raw) { + if (!raw) return []; + const patterns = []; + for (const entry of String(raw).split(",")) { + const match = entry + .trim() + .toLowerCase() + .match(/^(https?):\/\/([a-z0-9*.-]+)(?::(\d+|\*))?\/?$/); + if (!match) continue; + const [, scheme, host, port] = match; + const hostPattern = host + .split("*") + .map((part) => part.replace(/[.-]/g, "\\$&")) + .join("[a-z0-9-]+"); + const portPattern = port === "*" ? "(?::\\d+)?" : port ? `:${port}` : ""; + patterns.push(new RegExp(`^${scheme}://${hostPattern}${portPattern}$`)); + } + return patterns; +} + +/** True when the Origin header matches one of the allowlist patterns. */ +function isOriginAllowed(origin, patterns) { + if (!origin || origin === "null") return false; + const normalized = origin.toLowerCase(); + return patterns.some((pattern) => pattern.test(normalized)); +} + function isPrivateIPv4(host) { const match = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (!match) return false; @@ -247,9 +300,9 @@ async function safeFetch(target, method, headers) { throw new Error("too many redirects"); } -function withCors(response) { +function withCors(response, corsHeaders = CORS_HEADERS) { const headers = new Headers(response.headers); - for (const [name, value] of Object.entries(CORS_HEADERS)) headers.set(name, value); + for (const [name, value] of Object.entries(corsHeaders)) headers.set(name, value); return new Response(response.body, { status: response.status, statusText: response.statusText, @@ -408,6 +461,8 @@ You can rotate later by adding a second comma-separated token to the secret.

* `options`: * - tokens: string[] — configured secret tokens (empty = not locked) * - isPublic: boolean — the shared instance: open, no token required + * - allowedOrigins: RegExp[] — from `parseOriginPatterns`; when non-empty, + * only callers whose Origin matches may proxy (empty = any caller) * - cache: optional { match(Request), put(Request, Response) } edge cache * - waitUntil: optional (promise) => void, to keep a cache write alive * - onRequest: optional (event) => void, one neutral statistics event per @@ -418,6 +473,7 @@ You can rotate later by adding a second comma-separated token to the secret.

export async function handleRequest(request, options = {}) { const tokens = options.tokens ?? []; const isPublic = options.isPublic === true; + const allowedOrigins = options.allowedOrigins ?? []; const cache = options.cache ?? null; const waitUntil = options.waitUntil ?? (() => {}); const onRequest = options.onRequest ?? (() => {}); @@ -482,6 +538,19 @@ export async function handleRequest(request, options = {}) { }); } + // After health (a probe fetches nothing) and before the cache lookup, like + // the token. Refusals are recorded so the dashboard shows who was turned away. + let allowOrigin = ""; + if (allowedOrigins.length > 0) { + const origin = request.headers.get("origin"); + if (!isOriginAllowed(origin, allowedOrigins)) { + record(404, { cache: "bypass" }); + return notFound(); + } + allowOrigin = origin; + } + const corsHeaders = corsHeadersFor(allowOrigin); + const targetUrl = url.searchParams.get("url"); if (!targetUrl) { record(400, { cache: "bypass" }); @@ -507,7 +576,7 @@ export async function handleRequest(request, options = {}) { const hit = await cache.match(cacheKey); if (hit) { recordResponse(hit, "hit", target.hostname); - return withCors(hit); + return withCors(hit, corsHeaders); } } @@ -549,7 +618,7 @@ export async function handleRequest(request, options = {}) { const errorKind = await upstreamErrorKind(upstream); const responseHeaders = new Headers(upstream.headers); - for (const [name, value] of Object.entries(CORS_HEADERS)) { + for (const [name, value] of Object.entries(corsHeaders)) { responseHeaders.set(name, value); } responseHeaders.set("Access-Control-Expose-Headers", "*"); diff --git a/infra/cors-worker/worker.js b/infra/cors-worker/worker.js index 8a3669a..f55401b 100644 --- a/infra/cors-worker/worker.js +++ b/infra/cors-worker/worker.js @@ -2,7 +2,7 @@ * Savr CORS proxy — Cloudflare Worker entry point. * * Thin wrapper around the portable core in `core.js`; all behaviour (token - * auth, setup page, health, caching, SSRF guard) lives there so it can be + * auth, origin allowlist, setup page, health, caching, SSRF guard) lives there so it can be * reused by the Deno entry point. This file adds the one Cloudflare-specific * piece: writing the core's statistics events to Analytics Engine. * @@ -10,7 +10,7 @@ * The shared public instance deploys with `npx wrangler deploy --env public`. */ -import { handleRequest, parseTokens } from "./core.js"; +import { handleRequest, parseOriginPatterns, parseTokens } from "./core.js"; import { toDataPoint } from "./stats-schema.js"; /** @@ -38,6 +38,7 @@ export default { return handleRequest(request, { tokens: parseTokens(env.PROXY_TOKENS), isPublic: String(env.PUBLIC || "").toLowerCase() === "true", + allowedOrigins: parseOriginPatterns(env.ALLOWED_ORIGINS), cache: globalThis.caches?.default, waitUntil: (promise) => ctx.waitUntil(promise), onRequest: (event) => recordStat(env, request, event), diff --git a/infra/cors-worker/worker.test.mjs b/infra/cors-worker/worker.test.mjs index 929d276..543de8f 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, parseTokens } from "./core.js"; +import { handleRequest, parseOriginPatterns, parseTokens } from "./core.js"; import { BLOB_FIELDS, CACHE_OUTCOMES, @@ -686,3 +686,160 @@ test("deploys without the stats binding still proxy", async () => { }); assert.equal(response.status, 200, "a self-hosted deploy must not require Analytics Engine"); }); + +// --------------------------------------------------------------------------- +// Origin allowlist +// --------------------------------------------------------------------------- + +const PUBLIC_ALLOWED_ORIGINS = + "https://savr.link,https://*--savrlist.netlify.app,http://localhost:*,http://127.0.0.1:*"; + +function fromOrigin(origin, target = "https://example.com/a") { + return proxyRequest(target, origin ? { headers: { origin } } : {}); +} + +function countingFetch() { + const calls = []; + stubGlobals({ + fetchImpl: async (url) => { + calls.push(String(url)); + return new Response("ok", { + status: 200, + headers: { "content-type": "text/html" }, + }); + }, + }); + return calls; +} + +test("origin allowlist admits the listed sites, previews and local dev", async () => { + countingFetch(); + const env = { PUBLIC: "true", ALLOWED_ORIGINS: PUBLIC_ALLOWED_ORIGINS }; + for (const origin of [ + "https://savr.link", + "https://deploy-preview-116--savrlist.netlify.app", + "https://feature-x--savrlist.netlify.app", + "http://localhost:3000", + "http://localhost", + "http://127.0.0.1:8000", + ]) { + const response = await callWorker(fromOrigin(origin), { env }); + assert.equal(response.status, 200, `${origin} should be allowed`); + assert.equal(response.headers.get("access-control-allow-origin"), origin); + assert.equal(response.headers.get("vary"), "Origin"); + } +}); + +test("origin allowlist refuses other callers with 404 and never fetches", async () => { + const calls = countingFetch(); + const env = { PUBLIC: "true", ALLOWED_ORIGINS: PUBLIC_ALLOWED_ORIGINS }; + for (const origin of [ + undefined, // curl, server-side scripts, hotlinks + "null", // sandboxed iframes, file:// + "https://evil.example", + "http://savr.link", // wrong scheme + "https://savr.link.evil.example", + "https://evilsavr.link", + "https://savr.link:8443", // non-default port not listed + "https://savrlist.netlify.app", // the bare site isn't a preview + "https://evil.x--savrlist.netlify.app", // `*` never spans a dot + "https://x--savrlist.netlify.app.evil.example", + "https://localhost:3000", // wrong scheme + "chrome-extension://abcdefghijklmnop", + ]) { + const response = await callWorker(fromOrigin(origin), { env }); + assert.equal(response.status, 404, `${origin} should be refused`); + } + assert.deepEqual(calls, [], "refused callers must not reach fetch"); +}); + +test("origin allowlist restricts callers, never targets", async () => { + const calls = countingFetch(); + const env = { PUBLIC: "true", ALLOWED_ORIGINS: "https://savr.link" }; + for (const target of [ + "https://some-blog.example/post", + "https://news.example.org/2026/article?x=1", + "http://plain-http.example/", + ]) { + const response = await callWorker(fromOrigin("https://savr.link", target), { env }); + assert.equal(response.status, 200, `${target} should be fetchable`); + } + assert.equal(calls.length, 3); +}); + +test("origin allowlist is checked before the edge cache", async () => { + const cache = stubGlobals(); + cache.store.set( + "https://example.com/hit.jpg", + new Response("cached", { status: 200, headers: { "content-type": "image/jpeg" } }) + ); + const env = { PUBLIC: "true", ALLOWED_ORIGINS: "https://savr.link" }; + + const denied = await callWorker(fromOrigin("https://evil.example", "https://example.com/hit.jpg"), { env, cache }); + assert.equal(denied.status, 404); + + const allowed = await callWorker(fromOrigin("https://savr.link", "https://example.com/hit.jpg"), { env, cache }); + assert.equal(allowed.status, 200); + assert.equal(await allowed.text(), "cached"); + assert.equal(allowed.headers.get("access-control-allow-origin"), "https://savr.link"); +}); + +test("origin allowlist leaves preflight and health open", async () => { + stubGlobals(); + const env = { PUBLIC: "true", ALLOWED_ORIGINS: "https://savr.link" }; + const preflight = await callWorker( + urlRequest(`${PROXY}?url=https%3A%2F%2Fexample.com%2F`, { method: "OPTIONS" }), + { env } + ); + assert.equal(preflight.status, 200); + const health = await callWorker(urlRequest(`${PROXY}health`), { env }); + assert.equal(health.status, 200); +}); + +test("origin allowlist composes with a token", async () => { + countingFetch(); + const env = { PROXY_TOKENS: TOKEN, ALLOWED_ORIGINS: "https://savr.link" }; + const ok = await callWorker( + pathTokenRequest("https://example.com/a", TOKEN, { headers: { origin: "https://savr.link" } }), + { env } + ); + assert.equal(ok.status, 200); + const noToken = await callWorker(fromOrigin("https://savr.link"), { env }); + assert.equal(noToken.status, 404, "the allowlist does not replace the token"); + const wrongOrigin = await callWorker( + pathTokenRequest("https://example.com/a", TOKEN, { headers: { origin: "https://evil.example" } }), + { env } + ); + assert.equal(wrongOrigin.status, 404, "the token does not bypass the allowlist"); +}); + +test("without ALLOWED_ORIGINS any caller may proxy, with a wildcard CORS header", async () => { + countingFetch(); + for (const origin of [undefined, "https://my-self-hosted-savr.example"]) { + const response = await callWorker(fromOrigin(origin), { env: { PUBLIC: "true" } }); + assert.equal(response.status, 200); + assert.equal(response.headers.get("access-control-allow-origin"), "*"); + } +}); + +test("records refused origins so the dashboard shows who was turned away", async () => { + countingFetch(); + const { points, env } = makeStatsEnv(); + env.ALLOWED_ORIGINS = "https://savr.link"; + await callWorker(fromOrigin("https://evil.example"), { env }); + assert.equal(points.length, 1); + const [point] = points; + assert.equal(point.blobs[BLOB_FIELDS.indexOf("status")], "404"); + assert.equal(point.blobs[BLOB_FIELDS.indexOf("cache")], "bypass"); + assert.equal(point.blobs[BLOB_FIELDS.indexOf("origin")], "evil.example"); +}); + +test("parseOriginPatterns drops malformed entries and tolerates spacing and case", () => { + assert.deepEqual(parseOriginPatterns(""), []); + assert.deepEqual(parseOriginPatterns(undefined), []); + assert.equal( + parseOriginPatterns(" https://Savr.link/ , savr.link, ftp://x.example, https://a.example/path ").length, + 1, + "only the well-formed origin survives" + ); +}); diff --git a/infra/cors-worker/wrangler.toml b/infra/cors-worker/wrangler.toml index 71218d0..620e100 100644 --- a/infra/cors-worker/wrangler.toml +++ b/infra/cors-worker/wrangler.toml @@ -21,6 +21,11 @@ name = "lively-cors-proxy-b569" [env.public.vars] PUBLIC = "true" +# Only these calling sites may use the shared instance: production, Netlify +# preview/branch deploys, and local dev. Self-hosted Savr frontends should run +# their own proxy. Restricts callers only — any public article host can still +# be fetched. Syntax: README.md "Origin allowlist". +ALLOWED_ORIGINS = "https://savr.link,https://*--savrlist.netlify.app,http://localhost:*,http://127.0.0.1:*" # Usage statistics, shared public instance only: one Analytics Engine point per # recorded request, read and displayed by the separate stats worker