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:
[](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