From 3e1a38d299842a77f2f6ea409b5fa023bdaeeeed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Nov 2026 17:17:39 +0000 Subject: [PATCH] Let a server ask whether one address is on a list, with a secret apart from form keys POST /subscribe/status is for the web-app's own server. It authenticates with STATUS_KEY, refuses form keys (they must not reveal who subscribes), and answers only { subscribed } for one address and one configured list. Co-authored-by: Nilsson --- .dev.vars.example | 2 + README.md | 8 +- SECURITY.md | 2 +- cloudflare.config.ts | 4 + config/example.json | 8 +- config/sourceful.json | 8 +- docs/setup.md | 7 +- docs/signup.md | 55 +++++++++- src/app.ts | 6 +- src/config.ts | 6 ++ src/contacts.ts | 13 +++ src/env.ts | 2 + src/signup.ts | 11 ++ src/status.ts | 108 ++++++++++++++++++++ test/status.test.ts | 227 ++++++++++++++++++++++++++++++++++++++++++ 15 files changed, 454 insertions(+), 13 deletions(-) create mode 100644 src/status.ts create mode 100644 test/status.test.ts diff --git a/.dev.vars.example b/.dev.vars.example index ad63fe6..43f5694 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -5,3 +5,5 @@ AWS_SECRET_ACCESS_KEY=local SNS_TOPIC_ARN=arn:aws:sns:eu-central-1:000000000000:utskick-events # {"
":""} to try a server form locally (docs/signup.md) FORM_KEYS={} +# Bearer token for POST /subscribe/status. Distinct from every form key. Fake local value only. +STATUS_KEY=local-status-key-not-for-production-use diff --git a/README.md b/README.md index d8598cb..a24ba7d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ You keep the list, the templates and the numbers. Amazon SES only delivers the m - **Numbers that matter:** open, click, click-to-open, unsubscribe, bounce and complaint rates per send; sending health over 30 days against the limits Amazon SES and Gmail hold senders to; and each list's growth by month. - **Bounces and spam complaints** arrive from SES through SNS; each message's signature is checked, and the address is suppressed. - **Imports** a Mailchimp audience export: subscribers with their opt-in time, unsubscribed and cleaned addresses as suppressions. -- **Sign-ups** from your own sites: a browser form, a server route with a key, or a Cloudflare Worker over a service binding ([docs/signup.md](docs/signup.md)). +- **Sign-ups** from your own sites: a browser form, a server route with a key, or a Cloudflare Worker over a service binding. A server can ask whether the signed-in person is on a list, with a separate secret ([docs/signup.md](docs/signup.md)). - **A dashboard** for the team with sending health, lists and their growth, sends, rates, clicks per link and the audit log, and no personal data ([docs/dashboard.md](docs/dashboard.md)). - **An MCP server** for everyone at the company, through their own assistant (Claude, ChatGPT, Grok, Cursor, Codex …), with OAuth 2.1. An assistant reads the numbers, writes drafts, sends tests to the person using it, and makes lists for one mailing, such as an event's guests. It can only *ask* to send to a list: an admin gets the request by mail and approves each send ([docs/mcp.md](docs/mcp.md)). - **A web archive** page per issue, without names or tracking. @@ -23,7 +23,7 @@ You keep the list, the templates and the numbers. Amazon SES only delivers the m issue HTML ──► CLI ──/admin──► Worker ──SendEmail──► Amazon SES ──► inbox ▲ │ │ readers: /u /c /o /a ─────────┤ ├── D1 (lists, sends, │ - sites: /subscribe, RPC ─────┤ │ events, OAuth) └─ SNS ──► /hooks/ses + sites: /subscribe, /subscribe/status, RPC ─────┤ │ events, OAuth) └─ SNS ──► /hooks/ses assistants: /mcp + OAuth ─────┤ └── R2 (images) admins: /dashboard, /approve ┘ cron: sends what a person approved ``` @@ -81,13 +81,13 @@ More: [docs/signup.md](docs/signup.md) (forms), [docs/dashboard.md](docs/dashboa ## Security model -- **Admin API.** `/admin` takes a bearer token, compared in constant time; changing calls are written to the audit log. The token, the AWS key, the SNS topic ARN and the form keys are Worker secrets, never files in the repository. Consider putting `/admin` behind a Cloudflare Access service token as well. +- **Admin API.** `/admin` takes a bearer token, compared in constant time; changing calls are written to the audit log. The token, the AWS key, the SNS topic ARN, the form keys and `STATUS_KEY` are Worker secrets, never files in the repository. Consider putting `/admin` behind a Cloudflare Access service token as well. - **AWS key.** An IAM user allowed `ses:SendEmail` on one identity and one configuration set, and nothing else. - **SNS webhook.** Nothing in a message is used before its signature checks out: SignatureVersion 1 or 2, the signing certificate fetched only from `sns..amazonaws.com` with the `SimpleNotificationService-…pem` path, and only the configured topic accepted. That includes a subscription confirmation's `SubscribeURL`. - **Reader tokens.** Each delivery has its own 128-bit random token, used in its unsubscribe, click and open URLs. A token identifies one mail, not a person, and the API never returns one. Like any newsletter footer link, a forwarded mail lets its new reader unsubscribe the original one. - **Unsubscribe.** A GET only shows a button, because mail scanners open links. A POST, the one RFC 8058 clients send, unsubscribes. In a test mail the POST is logged and takes nobody off a list. - **Bounces.** A hard bounce or a spam complaint suppresses the address on every list. A full mailbox does so when a second mail bounces for it within 30 days; other soft bounces (a busy server, one slow to trust a new sender) do not. Only the mail's own recipient is suppressed, never other addresses a bounce report names. -- **Sign-ups.** Browser forms are allowed by exact origin (CORS), server routes by a per-form key, sites on Cloudflare over a service binding. Each list's consent id must match the config, a honeypot field catches form bots, and requests are rate-limited per client (per /64 for IPv6). The answer is the same for a new, known or suppressed address. Without double opt-in, a sign-up never lifts a suppression and never changes an existing contact. A site over a service binding has the same rate limit. +- **Sign-ups.** Browser forms are allowed by exact origin (CORS), server routes by a per-form key, sites on Cloudflare over a service binding. Each list's consent id must match the config, a honeypot field catches form bots, and requests are rate-limited per client (per /64 for IPv6). The answer is the same for a new, known or suppressed address. Without double opt-in, a sign-up never lifts a suppression and never changes an existing contact. A site over a service binding has the same rate limit. Whether a signed-in person is on a list is `POST /subscribe/status`, with its own secret (`STATUS_KEY`); a form key is refused, the call takes one address, and the answer is only `{ subscribed: true }` or `{ subscribed: false }`. - **Assistants (MCP).** OAuth 2.1 with PKCE S256 only, tokens bound to the `/mcp` resource, rotating refresh tokens, revocation per client and per person, and least-privilege scopes (`stats.read`, `subscribers.read`, `drafts.write`, `test.send`, `lists.write`). Sending to a list is never a scope: an admin approves each send on a page that shows the list, the subject, the count, the text part, every link and anything hidden; the approval works once, within 24 hours by default. With `approval.notifyFrom` set, the admins get the approval link by mail and the assistant never holds it. An assistant can add addresses to a list it made, and no tool returns one. Every tool call is audited. Tokens, codes and secrets are stored only as hashes. - **Members and admins.** Sign-in for the dashboard, the consent page and approvals goes through Cloudflare Access; Utskick checks the Access JWT itself. `members` (addresses, or `@domain` for a whole company) may connect an assistant and read the dashboard; only `admins` approve sends. Every call an assistant makes checks that its person is still a member. The development stand-in works only when both the configured and the requested host are local. - **Pages.** Every reader and admin page is server-rendered without scripts. The dashboard and approval pages carry a strict Content-Security-Policy and cannot be framed; the web archive is served in a CSP sandbox. diff --git a/SECURITY.md b/SECURITY.md index 03b3da1..3865ac0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -11,7 +11,7 @@ We answer within five working days, tell you what we will do, and credit you in ## In scope -The Worker (`src/`), the CLI (`cli/`), the database schema (`migrations/`) and the documented setup (`docs/setup.md`). Examples are: sending to someone who unsubscribed or never signed up, reading or changing subscribers without the admin token or an OAuth grant, sending to a list without a human approval, forging SES/SNS events, reading personal data from public pages, and anything that runs code in a reader's or an admin's browser. +The Worker (`src/`), the CLI (`cli/`), the database schema (`migrations/`) and the documented setup (`docs/setup.md`). Examples are: sending to someone who unsubscribed or never signed up, reading or changing subscribers without the admin token or an OAuth grant, learning whether an address is on a list without `STATUS_KEY` (including with a form key), sending to a list without a human approval, forging SES/SNS events, reading personal data from public pages, and anything that runs code in a reader's or an admin's browser. The security model is described in [README.md](README.md#security-model), [docs/mcp.md](docs/mcp.md), [docs/dashboard.md](docs/dashboard.md) and [docs/signup.md](docs/signup.md). diff --git a/cloudflare.config.ts b/cloudflare.config.ts index 526d4b7..7e5b46c 100644 --- a/cloudflare.config.ts +++ b/cloudflare.config.ts @@ -31,6 +31,8 @@ export default defineConfig(async ({ mode }) => { // POST /subscribe: 10 sign-ups a minute per form and client address (src/signup.ts). // The namespace is any number no other rate limiter in the account uses. SIGNUP_LIMITER: bindings.rateLimit({ namespace: deploy.signupLimiterNamespace, simple: { limit: 10, period: 60 } }), + // POST /subscribe/status: 120 lookups a minute per calling server (src/status.ts). + STATUS_LIMITER: bindings.rateLimit({ namespace: deploy.statusLimiterNamespace, simple: { limit: 120, period: 60 } }), PUBLIC_URL: bindings.text(dev ? "http://localhost:5173" : deploy.publicUrl), AWS_REGION: bindings.text(deploy.awsRegion), // In development, every sign-in at /oauth/authorize, /approve/ and /dashboard is the first admin, @@ -43,6 +45,8 @@ export default defineConfig(async ({ mode }) => { SNS_TOPIC_ARN: bindings.secret(), // JSON {"": ""} for the forms in the config that need a key; {} when none do (docs/signup.md). FORM_KEYS: bindings.secret(), + // Bearer token for POST /subscribe/status. Distinct from every form key (docs/signup.md). + STATUS_KEY: bindings.secret(), }, }, }; diff --git a/config/example.json b/config/example.json index a0fb018..77d54db 100644 --- a/config/example.json +++ b/config/example.json @@ -9,7 +9,8 @@ "id": "00000000-0000-0000-0000-000000000000" }, "r2": "utskick-images", - "signupLimiterNamespace": "1001" + "signupLimiterNamespace": "1001", + "statusLimiterNamespace": "1002" }, "organization": "Example Co", "homepage": "https://example.com", @@ -70,5 +71,10 @@ ], "key": true } + }, + "status": { + "lists": [ + "newsletter" + ] } } diff --git a/config/sourceful.json b/config/sourceful.json index 0fb8e2f..f5e012a 100644 --- a/config/sourceful.json +++ b/config/sourceful.json @@ -9,7 +9,8 @@ "id": "e43b0294-fce6-4010-bb4c-08a2fd99a96c" }, "r2": "utskick-images", - "signupLimiterNamespace": "7001" + "signupLimiterNamespace": "7001", + "statusLimiterNamespace": "7002" }, "organization": "Sourceful Energy", "homepage": "https://sourceful.energy", @@ -86,5 +87,10 @@ ], "key": true } + }, + "status": { + "lists": [ + "app-users" + ] } } diff --git a/docs/setup.md b/docs/setup.md index 472c250..b5c0d36 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -105,20 +105,21 @@ cf d1 create --name utskick-db --jurisdiction eu cf r2 buckets create --name utskick-images --cf-r2-jurisdiction eu ``` -Copy `config/example.json` to `config/.json` and fill in its `deploy` section: `publicUrl` (`https://$DOMAIN`; the deploy attaches that hostname to the Worker), `awsRegion`, `sesConfigurationSet`, the D1 name and id, the R2 bucket, and `signupLimiterNamespace` (any number no other rate limiter in the account uses). Fill in the rest too: organization, lists (with a `consent` id for lists that take form sign-ups), `forms`, `members` (who may use the dashboard and connect an assistant: addresses, or `"@example.com"` for everyone at a domain), `admins` (members who may also approve a send to a list) and `testAllowlist` (addresses besides the signed-in person's own that an assistant's test send may reach). Then: +Copy `config/example.json` to `config/.json` and fill in its `deploy` section: `publicUrl` (`https://$DOMAIN`; the deploy attaches that hostname to the Worker), `awsRegion`, `sesConfigurationSet`, the D1 name and id, the R2 bucket, `signupLimiterNamespace` and `statusLimiterNamespace` (any numbers no other rate limiter in the account uses). Fill in the rest too: organization, lists (with a `consent` id for lists that take form sign-ups), `forms`, `status.lists` (lists `POST /subscribe/status` may query; omit the block to leave that call off), `members` (who may use the dashboard and connect an assistant: addresses, or `"@example.com"` for everyone at a domain), `admins` (members who may also approve a send to a list) and `testAllowlist` (addresses besides the signed-in person's own that an assistant's test send may reach). Then: ```bash cf d1 migrations apply openssl rand -hex 32 # the admin token: into the secrets file, and into .env as MAILER_ADMIN_TOKEN +openssl rand -hex 32 # STATUS_KEY: into the secrets file and Bitwarden; never a form key (docs/signup.md) ``` Secrets file (`.secrets.json`, git-ignored, deleted after use): ```json -{ "ADMIN_TOKEN": "…", "AWS_ACCESS_KEY_ID": "…", "AWS_SECRET_ACCESS_KEY": "…", "SNS_TOPIC_ARN": "arn:aws:sns:::utskick-events", "FORM_KEYS": "{}" } +{ "ADMIN_TOKEN": "…", "AWS_ACCESS_KEY_ID": "…", "AWS_SECRET_ACCESS_KEY": "…", "SNS_TOPIC_ARN": "arn:aws:sns:::utskick-events", "FORM_KEYS": "{}", "STATUS_KEY": "…" } ``` -`FORM_KEYS` holds the keys of website forms that post from a server; `{}` until you have one ([signup.md](signup.md)). +`FORM_KEYS` holds the keys of website forms that post from a server; `{}` until you have one ([signup.md](signup.md)). `STATUS_KEY` is a separate secret for `POST /subscribe/status` (whether the signed-in person is on a list). Generate it with `openssl rand -hex 32` and store it in Bitwarden as `STATUS_KEY`; it must not be a form key. Without `status.lists` in the config the call stays off. ```bash UTSKICK_CONFIG=config/.json cf deploy --secrets-file .secrets.json && rm .secrets.json diff --git a/docs/signup.md b/docs/signup.md index 687d16e..c370fa6 100644 --- a/docs/signup.md +++ b/docs/signup.md @@ -7,6 +7,7 @@ Utskick takes sign-ups from your own websites in three ways. All of them end in | A Cloudflare Worker or Pages project | Service binding: it calls `subscribe()` over RPC | The binding itself; the method cannot be reached from the internet | | Any server (a Next.js route, a backend) | `POST /subscribe` with the form's key | `Authorization: Bearer ` | | A page with no server of its own | `POST /subscribe` from the browser | The page's `Origin`, over CORS | +| Your own account page (server only) | `POST /subscribe/status` with `STATUS_KEY` | `Authorization: Bearer ` — never a form key | Pick the first that fits. A browser form is the weakest: browsers send a true `Origin`, which stops other websites from posting through their visitors, but a script can send any `Origin` it likes. For a browser form, the rate limit, the hidden bot field and the consent check do the real work. @@ -149,7 +150,7 @@ curl https://news.example.com/unsubscribe \ -d '{"email": "reader@example.com", "list": "newsletter"}' ``` -It answers `200 {"ok": true}` whether or not the address was on the list, and the same errors as `/subscribe`. The address is suppressed for that list, so the form cannot sign it up again; the reader joins again by writing to you. Only a form with a key can do this, never a browser form. Utskick has no call that says whether an address is on a list. +It answers `200 {"ok": true}` whether or not the address was on the list, and the same errors as `/subscribe`. The address is suppressed for that list, so the form cannot sign it up again; the reader joins again by writing to you. Only a form with a key can do this, never a browser form. Whether someone is on a list is a separate call, `POST /subscribe/status`, with its own secret. ## 3. A Cloudflare Worker or Pages project, over a service binding @@ -162,10 +163,60 @@ await env.UTSKICK.subscribe({ email, list: 'newsletter', consent: 'newsletter-20 The call never leaves Cloudflare, so there is no Origin or key on Utskick's side: the calling site checks those before it calls. The rate limit holds, though, since the site's own form is open to anyone: 10 sign-ups a minute per visitor when the site passes the visitor's address as `ip`, and 10 a minute for the whole site when it does not. It throws `rate_limited`, `invalid_list`, `invalid_email` or `invalid_consent`, and returns `{ ok: true }` otherwise. Bind it in production only, or a preview deployment signs people up for real. [examples/ftw-web.md](examples/ftw-web.md) is a Pages site that does this. +## 4. Is this person on a list? `POST /subscribe/status` + +A signed-in account page may need to know whether *that person* is on a list, for example `app-users`. That is personal data: the call exists only for a server you control, authenticated with a secret that never goes to a browser, an app binary, or a form. + +**Never use a form key here.** Form keys are for `POST /subscribe` and `POST /unsubscribe`. They are held by websites and apps, so treat them as public. A leaked form key that answered "is this address on the list?" would let anyone check who subscribes. Utskick refuses a form key on this path with `401 {"error": "form_key_not_allowed"}`, even if you accidentally set `STATUS_KEY` to the same value. + +The caller is responsible for passing only the signed-in person's own address, one per request. + +```bash +curl https://news.example.com/subscribe/status \ + -H "Authorization: Bearer $STATUS_KEY" -H 'Content-Type: application/json' \ + -d '{"email": "reader@example.com", "list": "newsletter"}' +``` + +`STATUS_KEY` is a Worker secret. Generate it with `openssl rand -hex 32` and store it in Bitwarden under that name, next to `ADMIN_TOKEN` and `FORM_KEYS`. It must be at least 32 characters and a different value from every form key. On the calling server (for Sourceful: the Vercel web-app), keep the same value under a name that cannot be confused with a form key, for example `UTSKICK_STATUS_KEY`. Do not put it in `FORM_KEYS`. + +The body is `application/json` only, at most 2 KB, one object: + +| Field | | | +|---|---|---| +| `email` | required | The address. Case and spaces at the ends do not matter. One address only: an `emails` array is ignored, and `email` as an array is refused. | +| `list` | required | A list id from `status.lists` in the config. | + +Answers: + +- `200 {"subscribed": true}` or `200 {"subscribed": false}`. Nothing else: no address, name, consent, source or suppression reason. `false` covers never signed up, left, and bounced. `Cache-Control: no-store`. +- No CORS. This is server-to-server; a browser must not call it. + +| Status | `error` | Why | +|---|---|---| +| 400 | `invalid_email`, `invalid_list`, `invalid_body` | That field is wrong, or `list` is not in `status.lists`. | +| 401 | `key_required`, `invalid_key`, `form_key_not_allowed` | No key, not `STATUS_KEY`, or a form key. | +| 405 | `method_not_allowed` | Only `POST`. A GET would put the address in the URL, which logs keep. | +| 413 | `too_large` | The body is over 2 KB. | +| 415 | `unsupported_media_type` | Not JSON. | +| 429 | `rate_limited` | Too many lookups from this client; `Retry-After` says when to try again. | +| 503 | `status_unavailable` | `STATUS_KEY` is missing or too short, or `status.lists` is empty. | + +Config: name the lists this secret may query. With no `status` block, or an empty list, the endpoint is off. + +```json +{ + "status": { "lists": ["newsletter"] } +} +``` + +Sourceful's web-app asks about `app-users` only. + +Logs: `{ event: "subscription_status", list, subscribed }` on a 200, and `{ event: "form_key_rejected", path: "/subscribe/status" }` when a form key is presented. No address, no key. + ## Abuse limits - **Size.** At most 2 KB; Utskick stops reading past that. -- **Rate.** 10 sign-ups a minute per form and client address (`CF-Connecting-IP`, or `X-Client-IP` from a form with a key; `ip` over a service binding), with the Workers Rate Limiting binding `SIGNUP_LIMITER` in `cloudflare.config.ts`. That binding counts per Cloudflare location and is meant to be cheap rather than exact, so a few more can get through. Without the binding, as in the tests, a counter in memory takes its place. Requests refused for their origin, key, size, type, body or redirect do not count; everything after those checks does, valid or not. +- **Rate.** 10 sign-ups a minute per form and client address (`CF-Connecting-IP`, or `X-Client-IP` from a form with a key; `ip` over a service binding), with the Workers Rate Limiting binding `SIGNUP_LIMITER` in `cloudflare.config.ts`. That binding counts per Cloudflare location and is meant to be cheap rather than exact, so a few more can get through. Without the binding, as in the tests, a counter in memory takes its place. Requests refused for their origin, key, size, type, body or redirect do not count; everything after those checks does, valid or not. `POST /subscribe/status` has its own limiter, `STATUS_LIMITER`: 120 lookups a minute per calling server (`CF-Connecting-IP` only; `X-Client-IP` is ignored, so a stolen `STATUS_KEY` cannot mint a new budget per guessed address). - **Bots.** The hidden `website` field: a bot that fills it gets the usual answer and nothing is stored. - **Consent.** Only the list's current consent id is accepted. - **No redirect elsewhere.** `redirect` must be on the form's own origins, compared as parsed URLs, so `https://example.com.evil.example/` and `javascript:` are refused. diff --git a/src/app.ts b/src/app.ts index 3299fa0..9d2ca7b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,5 @@ -// Every HTTP route. Public: unsubscribe, click, open, archive, images, the SES webhook, sign-up forms. /admin is for the CLI. +// Every HTTP route. Public: unsubscribe, click, open, archive, images, the SES webhook, sign-up forms. +// POST /subscribe/status is the web-app's server-to-server lookup (STATUS_KEY, never a form key). /admin is for the CLI. // Assistants: /mcp with a bearer token, OAuth under /oauth and /.well-known, and /approve/ for an admin (docs/mcp.md). // /dashboard: read-only numbers for a signed-in admin (docs/dashboard.md). import { approvalRoute } from './approvals.ts'; @@ -13,6 +14,7 @@ import { render } from './render.ts'; import { createSend, retryFailed, retryUnknown, runBatch, stats } from './send.ts'; import { isSnsUrl, regionOf, verifySns, type SnsMessage } from './sns.ts'; import { boundedText, signupRoute, unsubscribeRoute } from './signup.ts'; +import { statusRoute } from './status.ts'; import { burstLinks, burstMs, machineClick, machineOpen } from './tracking.ts'; import { withLists } from './lists.ts'; import { HttpError, iso, json, normalizeEmail, sameSecret, sha256 } from './util.ts'; @@ -43,6 +45,8 @@ export async function handle(request: Request, env: Env): Promise { if (res) return res; } if (url.pathname === '/subscribe') return await signupRoute(request, env); + // A server with STATUS_KEY, never a form key: is this one address on this list? + if (url.pathname === '/subscribe/status') return await statusRoute(request, env); // GET /unsubscribe is the archive's page for readers; a POST is a server form taking someone off. if (url.pathname === '/unsubscribe' && request.method === 'POST') return await unsubscribeRoute(request, env); const approval = url.pathname.match(/^\/approve\/([A-Za-z0-9_-]{43})$/); diff --git a/src/config.ts b/src/config.ts index 35b9432..e2c5964 100644 --- a/src/config.ts +++ b/src/config.ts @@ -41,6 +41,12 @@ export interface Config { access?: AccessConfig; // Website forms that may post to /subscribe. None when empty. forms?: Record; + // Lists POST /subscribe/status may query with STATUS_KEY. Off when absent or empty. + status?: StatusConfig; +} + +export interface StatusConfig { + lists: string[]; // the lists the STATUS_KEY may ask about, e.g. ["app-users"] } export interface AdhocConfig { diff --git a/src/contacts.ts b/src/contacts.ts index 17cc5f6..6a24969 100644 --- a/src/contacts.ts +++ b/src/contacts.ts @@ -146,3 +146,16 @@ export async function subscribe(env: Env, s: Signup, now = new Date()): Promise< ]); return { ok: true }; } + +// True when the address is on the list now. Someone who left, bounced, or was never on it is not. +// Looked up by hash, so a status check does not put the address in the query. +export async function subscribed(env: Env, email: string, list: string): Promise { + const hash = await sha256(email); + const row = await env.DB.prepare( + `SELECT EXISTS ( + SELECT 1 FROM subscriptions s JOIN contacts c ON c.id = s.contact_id + WHERE c.email_hash = ?1 AND s.list = ?2 + ) AS found` + ).bind(hash, list).first<{ found: number }>(); + return Number(row?.found) === 1; +} diff --git a/src/env.ts b/src/env.ts index 0d1bdae..b3fb2b9 100644 --- a/src/env.ts +++ b/src/env.ts @@ -13,6 +13,8 @@ export interface Env { SES_CONFIGURATION_SET: string; // publishes bounces, complaints and deliveries to SNS SNS_TOPIC_ARN: string; // the only topic /hooks/ses accepts FORM_KEYS?: string; // JSON {"": ""} for the forms with key: true (docs/signup.md) + STATUS_KEY?: string; // bearer token for POST /subscribe/status; never a form key (docs/signup.md) SIGNUP_LIMITER?: RateLimit; // Workers Rate Limiting for /subscribe; without it (tests) a counter in memory + STATUS_LIMITER?: RateLimit; // Workers Rate Limiting for /subscribe/status; without it (tests) a counter in memory DEV_IDENTITY?: string; // only under `cf dev` and in tests: the admin every sign-in is (src/identity.ts) } diff --git a/src/signup.ts b/src/signup.ts index 51f1d42..9b10964 100644 --- a/src/signup.ts +++ b/src/signup.ts @@ -103,6 +103,17 @@ async function whichForm(request: Request, env: Env, forms: Record { + try { + return (await formByKey(env, env.CONFIG.forms ?? {}, auth)) !== null; + } catch (error) { + if (error instanceof HttpError && error.status === 503) return false; + throw error; + } +} + // Compares the key with every form's, so the time taken does not say which one matched. async function formByKey(env: Env, forms: Record, auth: string): Promise { const keys = formKeys(env); diff --git a/src/status.ts b/src/status.ts new file mode 100644 index 0000000..0339106 --- /dev/null +++ b/src/status.ts @@ -0,0 +1,108 @@ +// POST /subscribe/status: a server (the web-app) asks whether one address is on one list. +// Authenticated with STATUS_KEY, never a form key. Form keys are held by sites and apps and +// must not reveal who subscribes (docs/signup.md). +import { isList } from './config.ts'; +import { subscribed } from './contacts.ts'; +import type { Env } from './env.ts'; +import { boundedText, clientKey, isFormKey } from './signup.ts'; +import { HttpError, json, normalizeEmail, sameSecret } from './util.ts'; + +const maxBody = 2048; +// Status checks per calling server a minute. Higher than /subscribe: one web-app server asks for +// many signed-in people. Workers Rate Limiting counts per Cloudflare location; cloudflare.config.ts +// sets the same numbers. X-Client-IP is ignored, so a stolen STATUS_KEY cannot mint a new budget +// per guessed address. +const limit = 120, period = 60; + +export async function statusRoute(request: Request, env: Env): Promise { + if (request.method !== 'POST') { + const res = json({ error: 'method_not_allowed' }, 405); + res.headers.set('Allow', 'POST'); + return res; + } + try { + await authorize(request, env); + const client = clientKey(request.headers.get('CF-Connecting-IP') || 'unknown'); + if (!await statusAllowed(env, `status:${client}`)) throw new HttpError(429, 'rate_limited'); + const allowed = env.CONFIG.status?.lists ?? []; + if (!allowed.length) throw new HttpError(503, 'status_unavailable'); + const fields = await readJson(request); + const email = normalizeEmail(fields.email); + if (!email) throw new HttpError(400, 'invalid_email'); + const list = fields.list; + if (typeof list !== 'string' || !allowed.includes(list) || !isList(env.CONFIG, list) || env.CONFIG.lists[list]?.adhoc) { + throw new HttpError(400, 'invalid_list'); + } + const yes = await subscribed(env, email, list); + console.log(JSON.stringify({ event: 'subscription_status', list, subscribed: yes })); + return json({ subscribed: yes }); + } catch (error) { + if (!(error instanceof HttpError)) throw error; + const res = json({ error: error.message }, error.status); + if (error.status === 429) res.headers.set('Retry-After', String(period)); + return res; + } +} + +async function authorize(request: Request, env: Env) { + const auth = request.headers.get('Authorization'); + if (auth === null) throw new HttpError(401, 'key_required'); + // Always compare both secrets so the time taken does not say which kind it was. + const form = await isFormKey(env, auth); + const key = env.STATUS_KEY ?? ''; + const usable = key.length >= 32 || (key.length > 0 && localHost(env.PUBLIC_URL)); + const ok = usable && await sameSecret(auth, `Bearer ${key}`); + if (form) { + console.error(JSON.stringify({ event: 'form_key_rejected', path: '/subscribe/status' })); + throw new HttpError(401, 'form_key_not_allowed'); + } + if (!usable) { + if (key) console.error(JSON.stringify({ event: 'status_key_too_short' })); + throw new HttpError(503, 'status_unavailable'); + } + if (!ok) throw new HttpError(401, 'invalid_key'); +} + +async function readJson(request: Request): Promise> { + const type = (request.headers.get('Content-Type') ?? '').split(';')[0].trim().toLowerCase(); + if (type !== 'application/json') throw new HttpError(415, 'unsupported_media_type'); + const text = await boundedText(request, maxBody); + let body: unknown; + try { body = JSON.parse(text); } catch { throw new HttpError(400, 'invalid_body'); } + if (!body || typeof body !== 'object' || Array.isArray(body)) throw new HttpError(400, 'invalid_body'); + return body as Record; +} + +function localHost(url: string) { + try { return /^(localhost|127\.0\.0\.1|\[::1\])$|\.test$/.test(new URL(url).hostname); } catch { return false; } +} + +interface Limiter { limit(options: { key: string }): Promise<{ success: boolean }> } + +function memoryLimiter(max: number, seconds: number): Limiter { + const windows = new Map(); + return { + async limit({ key }) { + const now = Date.now(); + let w = windows.get(key); + if (!w || w.ends <= now) { + if (windows.size > 10_000) for (const [k, v] of windows) if (v.ends <= now) windows.delete(k); + w = { count: 0, ends: now + seconds * 1000 }; + windows.set(key, w); + } + return { success: ++w.count <= max }; + }, + }; +} + +async function statusAllowed(env: Env, key: string): Promise { + return (await limiter(env).limit({ key })).success; +} + +const memory = new WeakMap(); +function limiter(env: Env): Limiter { + if (env.STATUS_LIMITER) return env.STATUS_LIMITER; + let l = memory.get(env); + if (!l) memory.set(env, l = memoryLimiter(limit, period)); + return l; +} diff --git a/test/status.test.ts b/test/status.test.ts new file mode 100644 index 0000000..822bc56 --- /dev/null +++ b/test/status.test.ts @@ -0,0 +1,227 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { handle } from '../src/app.ts'; +import type { Config } from '../src/config.ts'; +import { subscribe } from '../src/contacts.ts'; +import { rows, testConfig, testEnv } from './helpers.ts'; + +// A server form key (effectively public) and a distinct STATUS_KEY. Status must never accept the form key. +const formKey = '5f0c1d2e3a4b59687766554433221100ffeeddccbbaa99887766554433221100'; +const statusKey = '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff'; + +const config: Config = { + ...testConfig, + lists: { + ...testConfig.lists, + 'the-source': { ...testConfig.lists['the-source'], consent: 'the-source-2026-11' }, + }, + forms: { + 'example-server': { lists: ['the-source'], key: true }, + }, + status: { lists: ['the-source'] }, +}; + +function env() { + const e = testEnv(); + e.CONFIG = config; + e.FORM_KEYS = JSON.stringify({ 'example-server': formKey }); + e.STATUS_KEY = statusKey; + return e; +} + +interface Options { key?: string | null; ip?: string; method?: string; type?: string; query?: string } + +function status(body: Record | string, o: Options = {}) { + const type = o.type ?? 'application/json'; + const headers: Record = { 'Content-Type': type, 'CF-Connecting-IP': o.ip ?? '192.0.2.1' }; + if (o.key !== null) headers.Authorization = `Bearer ${o.key ?? statusKey}`; + const text = typeof body === 'string' ? body : JSON.stringify(body); + const url = `https://news.test/subscribe/status${o.query ?? ''}`; + return new Request(url, { method: o.method ?? 'POST', headers, body: o.method === 'GET' ? undefined : text }); +} + +async function refused(res: Response, code: number, error: string) { + assert.equal(res.status, code); + assert.deepEqual(await res.json(), { error }); +} + +async function onList(e: ReturnType, email: string, list = 'the-source') { + await subscribe(e, { email, list, consent: 'the-source-2026-11', source: 'example-server' }); +} + +test('STATUS_KEY answers subscribed true or false for one address, and nothing else', async () => { + const e = env(); + await onList(e, 'User@Example.com'); + + const yes = await handle(status({ email: ' User@Example.com ', list: 'the-source' }), e); + assert.equal(yes.status, 200); + assert.equal(yes.headers.get('Cache-Control'), 'no-store'); + assert.equal(yes.headers.get('Access-Control-Allow-Origin'), null); + assert.deepEqual(await yes.json(), { subscribed: true }); + + const no = await handle(status({ email: 'stranger@example.com', list: 'the-source' }), e); + assert.equal(no.status, 200); + assert.deepEqual(await no.json(), { subscribed: false }); +}); + +test('someone who left, bounced, or is only on another list is not subscribed', async () => { + const e = env(); + e.CONFIG = { ...config, status: { lists: ['the-source', 'ftw-news'] } }; + await onList(e, 'reader@example.com'); + await subscribe(e, { email: 'reader@example.com', list: 'ftw-news', consent: 'news-1', source: 'example-server' }); + const { hash } = (await rows<{ hash: string }>(e, `SELECT email_hash AS hash FROM contacts WHERE email = 'reader@example.com'`))[0]; + await e.DB.prepare(`DELETE FROM subscriptions WHERE list = 'the-source'`).run(); + await e.DB.prepare(`INSERT INTO suppressions (email_hash, scope, reason) VALUES (?, 'the-source', 'unsubscribe')`).bind(hash).run(); + + assert.deepEqual(await (await handle(status({ email: 'reader@example.com', list: 'the-source' }), e)).json(), { subscribed: false }); + assert.deepEqual(await (await handle(status({ email: 'reader@example.com', list: 'ftw-news' }), e)).json(), { subscribed: true }); + + await e.DB.prepare('DELETE FROM subscriptions').run(); + await e.DB.prepare('DELETE FROM contacts').run(); + await e.DB.prepare(`INSERT INTO suppressions (email_hash, scope, reason) VALUES (?, '*', 'bounce')`).bind(hash).run(); + assert.deepEqual(await (await handle(status({ email: 'reader@example.com', list: 'the-source' }), e)).json(), { subscribed: false }); +}); + +test('a form key is refused, even when it is also STATUS_KEY', async () => { + const e = env(); + await onList(e, 'reader@example.com'); + await refused(await handle(status({ email: 'reader@example.com', list: 'the-source' }, { key: formKey }), e), 401, 'form_key_not_allowed'); + + e.STATUS_KEY = formKey; + await refused(await handle(status({ email: 'reader@example.com', list: 'the-source' }, { key: formKey }), e), 401, 'form_key_not_allowed'); + assert.equal(await e.DB.prepare('SELECT COUNT(*) AS n FROM subscriptions').first('n'), 1, 'a refused status check does not change the list'); +}); + +test('the admin token and a missing or wrong key cannot read status', async () => { + const e = env(); + await onList(e, 'reader@example.com'); + await refused(await handle(status({ email: 'reader@example.com', list: 'the-source' }, { key: null }), e), 401, 'key_required'); + await refused(await handle(status({ email: 'reader@example.com', list: 'the-source' }, { key: statusKey.slice(0, -1) + '0' }), e), 401, 'invalid_key'); + await refused(await handle(status({ email: 'reader@example.com', list: 'the-source' }, { key: 'admin-secret' }), e), 401, 'invalid_key'); +}); + +test('STATUS_KEY cannot be used as a form key on /subscribe or /unsubscribe', async () => { + const e = env(); + const headers = { Authorization: `Bearer ${statusKey}`, 'Content-Type': 'application/json', 'CF-Connecting-IP': '192.0.2.1' }; + const signup = { email: 'reader@example.com', list: 'the-source', consent: 'the-source-2026-11' }; + await refused(await handle(new Request('https://news.test/subscribe', { method: 'POST', headers, body: JSON.stringify(signup) }), e), 401, 'invalid_key'); + await refused(await handle(new Request('https://news.test/unsubscribe', { method: 'POST', headers, body: JSON.stringify({ email: 'reader@example.com', list: 'the-source' }) }), e), 401, 'invalid_key'); + assert.deepEqual(await rows(e, 'SELECT 1 FROM contacts'), []); +}); + +test('only one address is read; an emails array is not a bulk lookup', async () => { + const e = env(); + await onList(e, 'one@example.com'); + await onList(e, 'two@example.com'); + await refused(await handle(status({ emails: ['one@example.com', 'two@example.com'], list: 'the-source' }), e), 400, 'invalid_email'); + await refused(await handle(status({ email: ['one@example.com', 'two@example.com'], list: 'the-source' }), e), 400, 'invalid_email'); + const res = await handle(status({ email: 'one@example.com', emails: ['two@example.com'], list: 'the-source' }), e); + assert.deepEqual(await res.json(), { subscribed: true }, 'only email is read; emails is ignored'); +}); + +test('a list the status key may not query, or no list at all, is refused', async () => { + const e = env(); + await refused(await handle(status({ email: 'reader@example.com', list: 'ftw-news' }), e), 400, 'invalid_list'); + await refused(await handle(status({ email: 'reader@example.com', list: 'nope' }), e), 400, 'invalid_list'); + await refused(await handle(status({ email: 'reader@example.com' }), e), 400, 'invalid_list'); + await refused(await handle(status({ email: 'not an address', list: 'the-source' }), e), 400, 'invalid_email'); +}); + +test('GET is refused and never reads an address from the query string', async () => { + const e = env(); + await onList(e, 'reader@example.com'); + const res = await handle(status({}, { method: 'GET', query: '?email=reader@example.com&list=the-source' }), e); + await refused(res, 405, 'method_not_allowed'); + assert.equal(res.headers.get('Allow'), 'POST'); +}); + +test('a missing or short STATUS_KEY closes the endpoint', async () => { + const e = env(); + delete e.STATUS_KEY; + await refused(await handle(status({ email: 'reader@example.com', list: 'the-source' }), e), 503, 'status_unavailable'); + e.STATUS_KEY = 'short'; + e.PUBLIC_URL = 'https://news.example.com'; + await refused(await handle(status({ email: 'reader@example.com', list: 'the-source' }), e), 503, 'status_unavailable'); +}); + +test('without status.lists the endpoint is off even with STATUS_KEY', async () => { + const e = env(); + const { status: _, ...rest } = config; + e.CONFIG = rest; + await refused(await handle(status({ email: 'reader@example.com', list: 'the-source' }), e), 503, 'status_unavailable'); +}); + +test('logs name the list and the boolean, never the address or the key', async () => { + const e = env(); + await onList(e, 'secret.user@example.com'); + const lines: string[] = []; + const log = console.log, err = console.error; + console.log = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); }; + console.error = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); }; + try { + await handle(status({ email: 'secret.user@example.com', list: 'the-source' }), e); + await handle(status({ email: 'secret.user@example.com', list: 'the-source' }, { key: formKey }), e); + await handle(status({ email: 'secret.user@example.com', list: 'the-source' }, { key: 'nope' }), e); + } finally { + console.log = log; + console.error = err; + } + assert.ok(lines.some(l => l.includes('subscription_status') && l.includes('the-source'))); + assert.ok(lines.some(l => l.includes('form_key_rejected'))); + const joined = lines.join('\n'); + assert.ok(!joined.includes('secret.user@example.com'), 'no address in the log'); + assert.ok(!joined.includes(statusKey.slice(0, 16)), 'no part of STATUS_KEY in the log'); + assert.ok(!joined.includes(formKey.slice(0, 16)), 'no part of the form key in the log'); +}); + +test('a refused key does not consume the status rate limit', async () => { + const e = env(); + const keys: string[] = []; + e.STATUS_LIMITER = { limit: async ({ key }: { key: string }) => { keys.push(key); return { success: true }; } } as RateLimit; + await refused(await handle(status({ email: 'reader@example.com', list: 'the-source' }, { key: 'nope' }), e), 401, 'invalid_key'); + await refused(await handle(status({ email: 'reader@example.com', list: 'the-source' }, { key: formKey }), e), 401, 'form_key_not_allowed'); + assert.deepEqual(keys, []); +}); + +test('too many status checks from one client get 429 with Retry-After', async () => { + const e = env(); + const keys: string[] = []; + e.STATUS_LIMITER = { limit: async ({ key }: { key: string }) => { keys.push(key); return { success: false }; } } as RateLimit; + const res = await handle(status({ email: 'reader@example.com', list: 'the-source' }, { ip: '2001:db8::1' }), e); + await refused(res, 429, 'rate_limited'); + assert.equal(res.headers.get('Retry-After'), '60'); + assert.deepEqual(keys, ['status:2001:db8:0:0::/64'], 'an IPv6 client counts per /64; X-Client-IP is not taken'); +}); + +test('X-Client-IP does not split the status limit: the calling server is the client', async () => { + const e = env(); + const keys: string[] = []; + e.STATUS_LIMITER = { limit: async ({ key }: { key: string }) => { keys.push(key); return { success: true }; } } as RateLimit; + const req = status({ email: 'reader@example.com', list: 'the-source' }, { ip: '192.0.2.9' }); + req.headers.set('X-Client-IP', '198.51.100.1'); + assert.equal((await handle(req, e)).status, 200); + assert.deepEqual(keys, ['status:192.0.2.9']); +}); + +test('a bad body is refused before a lookup', async () => { + const e = env(); + await refused(await handle(status('{"email":', {}), e), 400, 'invalid_body'); + await refused(await handle(status({ email: 'reader@example.com', list: 'the-source' }, { type: 'application/x-www-form-urlencoded' }), e), 415, 'unsupported_media_type'); + await refused(await handle(status({ email: 'x'.repeat(3000), list: 'the-source' }), e), 413, 'too_large'); +}); + +test('Sourceful: the web-app may ask about app-users with STATUS_KEY, never with the app form key, and never about The Source', async () => { + const e = env(); + e.CONFIG = JSON.parse(readFileSync(new URL('../config/sourceful.json', import.meta.url), 'utf8')); + const app = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + e.FORM_KEYS = JSON.stringify({ 'sourceful-app': app }); + e.STATUS_KEY = statusKey; + await subscribe(e, { email: 'user@example.com', list: 'app-users', consent: 'app-account-2026-11', source: 'sourceful-app' }); + + await refused(await handle(status({ email: 'user@example.com', list: 'app-users' }, { key: app }), e), 401, 'form_key_not_allowed'); + await refused(await handle(status({ email: 'user@example.com', list: 'the-source' }), e), 400, 'invalid_list'); + const res = await handle(status({ email: 'user@example.com', list: 'app-users' }), e); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { subscribed: true }); +});