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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion docs/ENVIRONMENT_VARIABLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
6 changes: 4 additions & 2 deletions docs/TELEMETRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions docs/adr/0006-public-proxy-origin-allowlist.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
2 changes: 1 addition & 1 deletion infra/cors-stats/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
42 changes: 36 additions & 6 deletions infra/cors-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -71,10 +71,40 @@ https://proxy.example/?token=<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=<absolute-url>` fetches `<absolute-url>` 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

Expand All @@ -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

Expand Down
77 changes: 73 additions & 4 deletions infra/cors-worker/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = [
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -408,6 +461,8 @@ You can rotate later by adding a second comma-separated token to the secret.</p>
* `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
Expand All @@ -418,6 +473,7 @@ You can rotate later by adding a second comma-separated token to the secret.</p>
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 ?? (() => {});
Expand Down Expand Up @@ -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" });
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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", "*");
Expand Down
5 changes: 3 additions & 2 deletions infra/cors-worker/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
* 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.
*
* Deploy: `npx wrangler deploy --env=""` from this directory (see README.md).
* 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";

/**
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading