Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Private security reports at /security/report; the deploy sets up Turnstile and bounce and complaint events (#159)

* Private security reports at /security/report, and bounce and complaint events wired up by the deploy

- /security/report: a private report form, signed in or anonymously after Turnstile (same keys
  and behaviour as open sign-up: skipped on this machine, refused for anonymous visitors when a
  deployed instance has no keys). Summary, capped details, optional affected URL or area, and a
  reply-to address (optional when signed in). RL_AUTH per IP, plus daily limits per IP, per
  account and for everyone not signed in. Stored in security_reports (migration 0029).
- Site administration → Security reports: list, open, mark triaged/resolved with a note; and
  GET/PATCH /api/v3/diffinery/admin/security-reports[/{id}]. 404 for anyone but a site admin.
  Audited as security_report.create/triage/resolve/reopen (never the content).
- Each report queues a security_report email to every active site administrator: summary and
  a link only.
- security.txt always serves, with Contact: <PUBLIC_URL>/security/report first and any
  SECURITY_CONTACT entries after it. The disclosure policy points to the form.
- bootstrap.sh subscribes diffinery-mail-events to Email Sending's message.bounced and
  message.complained events (Queues event subscriptions API), creating it only when missing;
  without the permission it warns, naming it, and the deploy goes on.
- deliver(): a send Email Sending refuses for good (E_RECIPIENT_SUPPRESSED, or a REST-style
  permanent_bounces entry) suppresses the address as hard-bounce, source "send", audited.
  Temporary failures change nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo

* The deploy sets up Turnstile, so people without an account can send security reports

security.txt points researchers at /security/report, but anonymous reports need Turnstile
keys, and an invite-only instance without them took reports only from people signed in.

- scripts/turnstile.sh (run by bootstrap.sh): finds the account's Turnstile widget named
  "diffinery <DIFFINERY_DOMAIN>" (GET /accounts/{id}/challenges/widgets?per_page=1000) and
  creates it when missing (POST /accounts/{id}/challenges/widgets, {name, domains: [domain],
  mode: "managed"}). Bootstrap passes its site key to render-config.mjs in TURNSTILE_SITE_KEY,
  as RUNNER_IMAGE is passed, and it is deployed as the TURNSTILE_SITE_KEY var. A key set in
  wrangler.jsonc is the operator's: the script then does nothing and render-config keeps it.
- TURNSTILE_SECRET: ensure-secrets.sh sets it when missing, reading the widget's secret
  (GET /accounts/{id}/challenges/widgets/{sitekey}) and piping it to `wrangler secret put`;
  never printed. Secrets can't be read back, so the marker is the TURNSTILE_SITE_KEY deployed
  with it (GET /accounts/{id}/workers/scripts/diffinery/settings): when the Worker has a secret
  but a different deployed site key (the widget was recreated), turnstile.sh replaces the
  secret before the deploy, and if it can't, deploys no site key so the new key never goes
  live with the old secret; the next deploy tries again.
- Without Account → Turnstile → Edit ("Turnstile Sites Write"), each step warns with
  ::warning::, naming the permission, and the deploy goes on; anonymous reports stay refused.
- Keys never open sign-up (SIGNUP=open only); a unit test pins that.
- parseJsonc moves to scripts/jsonc.mjs so turnstile.sh can read wrangler.jsonc.
- Unit tests run turnstile.sh, ensure-secrets.sh and render-config.mjs against a fake
  Cloudflare API and wrangler: widget missing, exists, secret stale, denied, read-only,
  operator's own keys.
- Docs: deploy (token permissions, bootstrap steps, ensure-secrets, messages), configuration
  (new Turnstile section, SIGNUP, security contact, secrets), disclosure, site administration,
  README.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo

* Sync: repeat the Diffinery Deploy's warnings on GitHub

A deploy that goes on past a missing token permission (mail events subscription, Turnstile)
only warns, and its log is on Diffinery. The sync now prints those warnings, so they can be
read from the GitHub run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo

---------

Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent 9945254commit 7a65ff4e49d6b4db44300ec4aaa73113d4093981Browse files
43 files changed+1756−136
Files (43)
modified.github/workflows/deploy.yml+1−1View file
@@ -73,7 +73,7 @@
7373 # also shows that a deploy doesn't stop a running job.
7474 run: scripts/wait-container-rollout.sh
7575
76- - name: Generate Worker secrets (first deploy only)
76+ - name: Worker secrets (generated on the first deploy; TURNSTILE_SECRET when missing)
7777 run: scripts/ensure-secrets.sh
7878
7979 - name: Finish Access setup on first deploy
modifiedREADME.md+8−6View file
@@ -74,15 +74,17 @@
7474 The workflow needs two repository secrets:
7575
7676 - `CLOUDFLARE_API_TOKEN`: account permissions Workers Scripts, D1, R2,
77- Queues, Workflows, Containers and Access: Apps and Policies (all Edit), and
78- zone permission Workers Routes Edit on `diffinery.com`.
77+ Queues, Workflows, Containers, Access: Apps and Policies and Turnstile (all
78+ Edit), and zone permission Workers Routes Edit on `diffinery.com`.
7979 - `CLOUDFLARE_ACCOUNT_ID`
8080
8181 On each deploy, `scripts/bootstrap.sh` creates anything that is missing: the D1
82-database, R2 bucket, queues and one Access application that covers only
83-`/auth/access` (email-based account recovery). It then applies D1 migrations. `wrangler deploy`
84-builds and pushes the runner image. On the first deploy, `ensure-secrets.sh`
85-generates `MASTER_KEY` and the OIDC signing key inside the job, so neither
82+database, R2 bucket, queues, the Turnstile widget for anonymous security reports
83+(`scripts/turnstile.sh`; needs Account → Turnstile → Edit, else it warns) and one
84+Access application that covers only `/auth/access` (email-based account recovery).
85+It then applies D1 migrations. `wrangler deploy` builds and pushes the runner image.
86+`ensure-secrets.sh` generates `MASTER_KEY` and the OIDC signing key inside the job
87+on the first deploy, and sets `TURNSTILE_SECRET` whenever it is missing; no secret
8688 value is ever printed.
8789
8890 ### Storage
modifiedapps/diffinery/docs/accounts/notifications.md+2−1View file
@@ -46,7 +46,7 @@
4646
4747 | Group | Emails | Can be turned off | Default |
4848 | --- | --- | --- | --- |
49-| Security | New sign-in from a browser or device not seen before (and every email-recovery sign-in), passkey added or removed, access token created or about to expire, agent added, model API key added, push blocked or push protection bypassed, account disabled or enabled | No | On |
49+| Security | New sign-in from a browser or device not seen before (and every email-recovery sign-in), passkey added or removed, access token created or about to expire, agent added, model API key added, push blocked or push protection bypassed, account disabled or enabled, and for site administrators a new security report | No | On |
5050 | Account | Invitations, welcome, sign-in links, sign-up codes, test email, agent review's daily limit reached | No | On |
5151 | Participating | Issues and pull requests you opened, commented on, reviewed, were assigned, were asked to review, were @mentioned in or subscribed to: new issues and pull requests, comments, reviews, pushes, merges, closes, reopens, assignments and review requests | Yes | On |
5252 | Watching | Everything else in repositories you watch with **All activity** | Yes | On |
@@ -254,6 +254,7 @@
254254 | `push_blocked` | `[acme/website] Your push was blocked: possible secrets` | Security | `security_alert` | Push protection rejected your `git push`, or a commit made in the web editor, through the REST API or over MCP (subject `Your commit was blocked`). For an agent, its owner is told. |
255255 | `push_bypassed` | `[acme/website] Push protection was bypassed` | Security | `security_alert` | A repository admin pushed past push protection, or committed a flagged file through the commits API with `allow_secrets`; sent to that admin and the repository's admins |
256256 | `account_status` | `[Diffinery] Your account was disabled` or `Your account was enabled` | Security | `account` | A site administrator disabled your account, or enabled it again |
257+| `security_report` | `[Diffinery] New security report` | Security | `admin` | Someone sent a private report through `/security/report` (sent to every active site administrator). It holds only the report's summary and a link to it in [Site administration](/docs/admin/site-administration#security-reports). |
257258 | `invite` | `@diffy invited you to join the acme organization on Diffinery` | Account | `invitation` | Someone invited your email address (organization, repository or instance), or your username (organization or repository; the button is **View invitation** and opens **Settings → Invitations**) |
258259 | `welcome` | `Welcome to Diffinery, @diffy` | Account | `account` | You created your account from an invite or through open sign-up |
259260 | `signup_code` | `[Diffinery] Your sign-up code` | Account | `signup` | With [open sign-up](/docs/accounts/passkeys-and-sign-in#open-sign-up), someone asked for a code for an address without an account. The code is in the message, not the subject. |
modifiedapps/diffinery/docs/admin/site-administration.md+34−8View file
@@ -1,13 +1,13 @@
11 ---
22 title: Site administration
3-description: What site administrators do: manage people and admins, handle reports, suspend and delete accounts, issue sign-in links, stop Actions, oversee imports, read the audit log and check email.
3+description: What site administrators do: manage people and admins, handle abuse and security reports, suspend and delete accounts, stop Actions, oversee imports, read the audit log and check email.
44 ---
55
6-Site administrators run a Diffinery instance. They decide who gets in, help people who lost their passkeys, and check the audit log and email delivery. This page covers who the site administrators are and each page in the **Site administration** group of **Settings**.
6+Site administrators run a Diffinery instance. They decide who gets in, help people who lost their passkeys, handle private security reports, and check the audit log and email delivery. This page covers who the site administrators are and each page in the **Site administration** group of **Settings**.
77
88 ## Site administrators
99
10-Site administrators have admin on every repository, can act as an owner of every organization (including its **Settings** tab, where the organization's name, picture and base permission are changed, and the **Add people** form on its **People** tab), and see an extra **Site administration** group in the **Settings** sidebar:
10+Site administrators have admin on every repository, can act as an owner of every organization (including its **Settings** tab, where the organization's name, picture and base permission are changed, and the **Add people** form on its **People** tab), and see an extra **Site administration** group in the **Settings** sidebar with these pages:
1111
1212 | Page | Path | What it is for |
1313 | --- | --- | --- |
@@ -15,6 +15,7 @@
1515 | **Reports** | `/settings/admin/reports` | What people reported as abusive, to look at and resolve |
1616 | **Email** | `/settings/admin/email` | Email delivery status, the suppression list, template previews and the log of every email |
1717 | **Kill switches** | `/settings/admin/kill-switches` | Stop Actions and agent sandboxes for a repository, an account or the whole instance |
18+| **Security reports** | `/settings/admin/security-reports` | Private vulnerability reports from the report form, to read, triage and resolve |
1819 | **Audit log** | `/settings/audit` | The most recent security-relevant events |
1920
2021 Their **Settings → Profile** page shows **Site admin** as yes, and they have an **admin** badge in the users list.
@@ -310,7 +311,7 @@
310311 | --- | --- | --- |
311312 | `GET /api/v3/diffinery/admin/email` | `sending` and `queue` (whether they are configured), `from` (the sender address: `MAIL_FROM`, or `notifications@<your hostname>` when it is empty), `last_24h` counts (`sent`, `failed`, `skipped`) and the `last` email logged | site administrator, any token scope |
312313 | `POST /api/v3/diffinery/admin/emails/samples` | Send every template to yourself now, or only some with `{"only": ["signin", "invite-org"]}`. Returns `to`, `sent`, `total` and each result. | site administrator, with an `admin` token for all repositories or a browser session |
313-| `GET /api/v3/diffinery/admin/email/suppressions` | The suppression list, newest first: `email`, `reason`, `detail`, `source` (`cloudflare`, `webhook` or `admin`), `created_by`, `created_at` and `owner_can_lift` | site administrator, any token scope |
314+| `GET /api/v3/diffinery/admin/email/suppressions` | The suppression list, newest first: `email`, `reason`, `detail`, `source` (`cloudflare`, `webhook`, `send` or `admin`), `created_by`, `created_at` and `owner_can_lift` | site administrator, any token scope |
314315 | `PUT /api/v3/diffinery/admin/email/suppressions/{email}` | Suppress an address by hand, with an optional `{"note": "..."}`. Audited as `email.suppression.add`. | site administrator, with an `admin` token or a browser session |
315316 | `DELETE /api/v3/diffinery/admin/email/suppressions/{email}` | Remove an address from the list, whatever put it there; `204`, or `404` when it isn't there. Audited as `email.suppression.remove`. | site administrator, with an `admin` token or a browser session |
316317 | `GET /api/v3/diffinery/emails?all=1` | Everyone's recent emails (without `all=1`, only yours). Also takes `kind`, `since` and `per_page` (up to 500). | site administrator for `all=1` |
@@ -323,7 +324,7 @@
323324
324325 | Reason | How |
325326 | --- | --- |
326-| `hard-bounce` | Email to it bounced permanently (the mailbox or domain doesn't exist, or the receiving server refuses it for good). Soft bounces and delays change nothing. |
327+| `hard-bounce` | Email to it bounced permanently (the mailbox or domain doesn't exist, or the receiving server refuses it for good), or Email Sending refused to send to it for good (`E_RECIPIENT_SUPPRESSED`, source `send`). Soft bounces, delays and temporary send errors (rate and daily limits, delivery errors) change nothing. |
327328 | `complaint` | Its owner marked an email from Diffinery as spam. |
328329 | `manual` | A site administrator added it, under **Suppress an address** or with the API. |
329330
@@ -331,19 +332,23 @@
331332
332333 **Lifting a suppression.** A site administrator can remove any address (**Remove**, or `DELETE /api/v3/diffinery/admin/email/suppressions/{email}`). People can lift a bounce or complaint on their own address themselves: **Settings → Email** shows that the address is suppressed and why, and **Send a verification email** sends one email to it, past the list. Following its link while signed in to the same account turns email back on (`email.suppression.lift` in the audit log). The link works for 24 hours, and at most 3 can be asked for in an hour. A `manual` suppression can only be removed by an administrator. See [Notifications](/docs/accounts/notifications#when-diffinery-stops-emailing-you).
333334
334-Cloudflare Email Sending keeps a suppression list of its own, for bounces and complaints it sees. An address on Cloudflare's list fails with `E_RECIPIENT_SUPPRESSED` in **Recent emails**, even after you remove it from Diffinery's; remove it in the Cloudflare dashboard too (**Email Service → Email Sending → Suppressions**).
335+Cloudflare Email Sending keeps a suppression list of its own, for bounces and complaints it sees. When it refuses to send to an address on that list (`E_RECIPIENT_SUPPRESSED`, shown as `failed` in **Recent emails**), Diffinery adds the address to its own list as a `hard-bounce` with source `send`, so later emails are skipped. If you remove such an address from Diffinery's list, remove it in the Cloudflare dashboard too (**Email Service → Email Sending → Suppressions**), or the next email to it puts it back.
335336
336337 #### Where bounces and complaints come from
337338
338-Cloudflare Email Sending publishes delivery events (`message.delivered`, `message.bounced`, `message.complained` and others) through [event subscriptions](https://developers.cloudflare.com/email-service/platform/event-subscriptions/) to a queue. Diffinery consumes the `diffinery-mail-events` queue: the deploy creates it and the Worker reads it, but **you must subscribe it to your sending domain's events once**, since Cloudflare has no setting for that in `wrangler.jsonc`:
339+Cloudflare Email Sending publishes delivery events (`message.delivered`, `message.bounced`, `message.complained` and others) through [event subscriptions](https://developers.cloudflare.com/email-service/platform/event-subscriptions/) to a queue. Diffinery consumes the `diffinery-mail-events` queue, and **the deploy sets it all up**: `scripts/bootstrap.sh` creates the queue and, when it is missing, an event subscription named `diffinery-mail-events <domain>` with source **Email Sending**, your sending domain (`MAIL_DOMAIN`, or `DIFFINERY_DOMAIN`), and the **bounced** and **complained** events. A subscription you made yourself for that domain and queue is kept; any of those two events it lacks are added. Other events are ignored.
339340
341+The deploy's API token needs **Account → Queues → Edit** for this (the [API reference](https://developers.cloudflare.com/api/resources/queues/subresources/subscriptions/methods/create/) calls it "Queues Write"), and **Zone → Zone → Read** on the zone of your sending domain to look up its zone ID; the domain must be onboarded to Email Sending. Without them the deploy goes on and prints a warning such as `Could not subscribe diffinery-mail-events to Email Sending's bounce and complaint events for example.com. The token needs Account → Queues → Edit ...`, followed by Cloudflare's error. Then either add the permission to the token and deploy again, or subscribe the queue by hand once:
342+
340343 1. In the Cloudflare dashboard, open **Storage & databases → Queues** and select `diffinery-mail-events`.
341344 2. On the **Subscriptions** tab, click **Subscribe to events**.
342345 3. Name the subscription (for example `diffinery-bounces`), pick **Email Sending** as the source and your sending domain (the domain of `MAIL_FROM`), and select at least the **bounced** and **complained** events. Others are ignored.
343346 4. Click **Subscribe**.
344347
345-Only Cloudflare can publish to the queue, so its messages need no signature. Until the subscription exists, nothing reaches the list automatically; administrators can still add and remove addresses by hand.
348+With Wrangler, the same is `wrangler queues subscription create diffinery-mail-events --source email.sending --zone-id <zone id> --domain <sending domain> --events message.bounced,message.complained`.
346349
350+Only Cloudflare can publish to the queue, so its messages need no signature. Until the subscription exists, bounces and complaints reach the list only when Email Sending refuses a send (see above); administrators can still add and remove addresses by hand.
351+
347352 **The signed endpoint.** Anything else that can relay bounces and complaints, such as another provider's webhook through a small relay, can POST them to `https://<your host>/-/email/events`. It is off (`404`) until you set the `MAIL_EVENTS_SECRET` Worker secret, and it refuses (`401`) any request without a good signature:
348353
349354 - Header `X-Diffinery-Signature: t=<unix seconds>,v1=<signature>`, where the signature is the hex HMAC-SHA256, keyed with `MAIL_EVENTS_SECRET`, of `<t>.<the raw request body>`. Requests more than 5 minutes old (or ahead) are refused, so a captured request can't be replayed later.
@@ -361,6 +366,27 @@
361366
362367 Set the secret with `wrangler secret put MAIL_EVENTS_SECRET -c wrangler.deploy.jsonc` (see [Configuration](/docs/self-hosting/configuration#worker-secrets)).
363368
369+## Security reports
370+
371+`/.well-known/security.txt` names the instance's private report form, `/security/report`, as its first contact (see [Configuration](/docs/self-hosting/configuration#security-contact)). Anyone can send a report there: signed in, or without an account after Cloudflare Turnstile, which the deploy sets up when its API token has **Account → Turnstile → Edit** (see [Turnstile](/docs/self-hosting/configuration#turnstile); without it, only people who are signed in can report). A report has a one-line summary (up to 200 characters), details (plain text, up to 20,000 characters), optionally the affected URL or area, and a reply-to address: required without an account, optional when signed in, when the account's own address is used. Limits: 5 reports a day from one IP address for people who aren't signed in, 50 a day from all of them together, 10 a day per account, and the sign-in rate limit of 20 requests a minute per IP address.
372+
373+Only site administrators can see reports. To anyone else the pages and the API answer `404`, as if they didn't exist.
374+
375+Each new report emails every active site administrator (`security_report`, an email that always sends). The email has the summary and a link to the report, never the details: read those in Diffinery.
376+
377+**Settings → Security reports** (`/settings/admin/security-reports`) lists reports newest first, with when they arrived, the summary, who sent it (a username, or "not signed in") and the state: **New**, **Triaged** or **Resolved**. The **Show** menu filters by state. Click a summary to open the report: its state, when it arrived, the reporter, the reply-to address, the affected URL or area, its reference (the id the reporter was shown) and the details as plain text. Under **Triage**, pick a state, write a note for the other administrators and click **Save**; you see "Report updated." Each change is in the audit log (`security_report.triage`, `security_report.resolve`, or `security_report.reopen` when set back to **New**), with the note. Reply to the reporter from your own mailbox: Diffinery doesn't email them.
378+
379+| Method and path | Description | Needs |
380+| --- | --- | --- |
381+| `GET /api/v3/diffinery/admin/security-reports` | Reports, newest first: `id`, `state`, `summary`, `details`, `area`, `reply_to`, `reporter` (a login, or `null`), `note`, `updated_by`, `updated_at`, `created_at` and `html_url`. Takes `state` (`new`, `triaged` or `resolved`) and `per_page` (default 100, up to 500). | site administrator, a `read` token not limited to repositories, or a browser session |
382+| `GET /api/v3/diffinery/admin/security-reports/{id}` | One report, as in the list | site administrator, a `read` token not limited to repositories, or a browser session |
383+| `PATCH /api/v3/diffinery/admin/security-reports/{id}` | `{"state": "triaged", "note": "..."}`: sets the state (`triaged`, `resolved`, or `new` to reopen) and the note (up to 2,000 characters; leaving it out clears it). Answers with the report. Audited like **Save**, with `via: api`. | site administrator, with an `admin` token not limited to repositories, or a browser session |
384+
385+```sh
386+diffinery api GET '/diffinery/admin/security-reports?state=new'
387+echo '{"state": "resolved", "note": "Fixed in 2026.10.15; reporter credited."}' | diffinery api PATCH /diffinery/admin/security-reports/<id> --input -
388+```
389+
364390 ## Workflow scheduler
365391
366392 Scheduled workflows are started by Diffinery's own timer, every 5 minutes, which also catches up checks that came late (see [Scheduled workflows](/docs/actions/events#schedule)). When a scheduled run doesn't start, read the scheduler's state to tell a timer that stopped from a run that failed to start:
modifiedapps/diffinery/docs/reference/rest-api.md+1−1View file
@@ -404,7 +404,7 @@
404404
405405 | What | Limit | Counted by |
406406 | --- | --- | --- |
407-| Sign-in, passkey registration, invitation and sign-in links, open sign-up (`POST /signup`, `POST /signup/verify`) | 20 a minute | IP address |
407+| Sign-in, passkey registration, invitation and sign-in links, open sign-up (`POST /signup`, `POST /signup/verify`), security reports (`POST /security/report`) | 20 a minute | IP address |
408408 | Anything else without signing in (web pages, API, git) | 300 a minute | IP address |
409409 | Anything with a session or token (web, API, MCP, git) | 2,000 a minute | Account |
410410 | Creating repositories, organizations, forks, issues, pull requests, releases, comments, reviews and reports, and MCP tools that change something | 60 a minute | Account |
modifiedapps/diffinery/docs/security/audit-log.md+3−1View file
@@ -123,9 +123,11 @@
123123 | `compute.pause` | A site administrator pauses hosted compute on the instance (**Pause hosted compute**, or `PUT /api/v3/admin/hosted-compute-pause`) | none | `reason`, `via`, `stopped` |
124124 | `org.block`, `org.unblock` | An organization owner [blocks](/docs/accounts/blocking-and-reporting) or unblocks a person for the organization (its **Settings** tab, or `PUT`/`DELETE /api/v3/orgs/{org}/blocks/{username}`) | the organization's login | `user`, `via` |
125125 | `compute.unpause` | A site administrator resumes it (**Resume hosted compute**, or `DELETE /api/v3/admin/hosted-compute-pause`) | none | `via` |
126-| `email.suppression.add` | An address goes on the email suppression list: a permanent bounce or a spam complaint arrives (no actor, shown as `system`), or a site administrator adds it (**Suppress an address** on **Settings → Email**, or `PUT /api/v3/diffinery/admin/email/suppressions/{email}`). See [the suppression list](/docs/admin/site-administration#bounces-complaints-and-the-suppression-list). | the email address | `reason` (`hard-bounce`, `complaint` or `manual`), `source` (`cloudflare`, `webhook` or `admin`), `detail` (the provider's words or the administrator's note) |
126+| `email.suppression.add` | An address goes on the email suppression list: a permanent bounce or a spam complaint arrives, or Email Sending refuses to send to it for good (no actor, shown as `system`), or a site administrator adds it (**Suppress an address** on **Settings → Email**, or `PUT /api/v3/diffinery/admin/email/suppressions/{email}`). See [the suppression list](/docs/admin/site-administration#bounces-complaints-and-the-suppression-list). | the email address | `reason` (`hard-bounce`, `complaint` or `manual`), `source` (`cloudflare`, `webhook`, `send` or `admin`), `detail` (the provider's words or the administrator's note) |
127127 | `email.suppression.remove` | A site administrator removes an address from the list (**Remove**, or `DELETE /api/v3/diffinery/admin/email/suppressions/{email}`) | the email address | `reason` and `source` of the entry removed |
128128 | `email.suppression.lift` | Someone lifts a bounce or complaint on their own address by following the verification link from **Settings → Email** | the email address | `reason` of the entry lifted |
129+| `security_report.create` | Someone sends a private [security report](/docs/admin/site-administration#security-reports) through `/security/report`. The actor is the reporter when signed in, otherwise `system`. Nothing of the report's content is recorded. | the report's id | `signed_in` |
130+| `security_report.triage`, `security_report.resolve`, `security_report.reopen` | A site administrator marks a security report triaged or resolved, or new again (**Save** on the report's page, or `PATCH /api/v3/diffinery/admin/security-reports/{id}`) | the report's id | `from` and `to` (the states), `note`, `via` (`web` or `api`) |
129131
130132 ### Moderation
131133
modifiedapps/diffinery/docs/security/disclosure.md+8−5View file
@@ -7,14 +7,16 @@
77
88 ## How to report
99
10-1. Find the contact address in this instance's [`/.well-known/security.txt`](/.well-known/security.txt). It lists one or more `Contact:` lines, an email address or a web form. If the file answers 404, the operator of this instance has not set a security contact; reach them another way and ask for one, without putting the details of the problem in a public place.
11-2. Send a report that lets us reproduce the problem:
10+1. Use the private report form at [`/security/report`](/security/report), the first `Contact:` in this instance's [`/.well-known/security.txt`](/.well-known/security.txt). Only the instance's site administrators can read what you send. You can send it signed in, or without an account after a quick check that you're a person ([Cloudflare Turnstile](https://www.cloudflare.com/application-services/products/turnstile/)); then give an email address we can reply to. Diffinery's deploy sets Turnstile up for the form. If an instance still has none, the form asks you to sign in, and `security.txt` may list another contact (an email address or another form) after the first.
11+2. Write a report that lets us reproduce the problem. The form has a one-line summary, the details (up to 20,000 characters of plain text) and, optionally, the affected URL or area:
1212 - what is affected (a URL, an API endpoint, a git or MCP operation, a CLI command);
1313 - the steps, requests or a short script that show it, and what happened compared to what you expected;
1414 - what an attacker could do with it, as far as you know;
1515 - whether you have told anyone else, and when you would like to publish.
16-3. Write in English if you can. Plain text is fine; attach screenshots or logs if they help.
16+3. Write in English if you can. The form takes plain text only: describe screenshots or logs, and offer to send them once we reply.
1717
18+The site administrators get an email with your summary only, and read the rest in Diffinery. Your reference number is shown when you send the report; quote it if you write again. You can send up to 5 reports a day without signing in from one network, and 10 a day from one account.
19+
1820 Please don't report in a public issue, a pull request, a discussion or social media. If the report includes a secret you came across (a token, a key, someone's data), say so and don't keep a copy.
1921
2022 ## In scope
@@ -41,7 +43,7 @@
4143 - don't use what you found for anything other than reporting it;
4244 - give us a reasonable time to fix it before you publish anything, and agree the date with us.
4345
44-If you aren't sure whether something is allowed, ask first through the security contact.
46+If you aren't sure whether something is allowed, ask first through the [report form](/security/report).
4547
4648 This safe harbour covers Diffinery's own claims only. It can't speak for Cloudflare, other services, or the operator of an instance you don't own.
4749
@@ -65,4 +67,5 @@
6567 ## See also
6668
6769 - [Security overview](/docs/security/overview), for how Diffinery protects accounts, code and secrets.
68-- [Configuration](/docs/self-hosting/configuration#security-contact), for operators: setting the contact that `security.txt` serves.
70+- [Configuration](/docs/self-hosting/configuration#security-contact), for operators: what `security.txt` serves, and adding contacts to it. The deploy sets up Turnstile for the form, so people without an account can report; its Cloudflare API token needs **Account → Turnstile → Edit** for that (see [Turnstile](/docs/self-hosting/configuration#turnstile)).
71+- [Site administration](/docs/admin/site-administration#security-reports), for operators: reading and triaging reports.
modifiedapps/diffinery/docs/security/overview.md+1−1View file
@@ -294,4 +294,4 @@
294294
295295 ## Reporting a vulnerability
296296
297-Found a security problem? Report it privately to the contact in [`/.well-known/security.txt`](/.well-known/security.txt). [Reporting a vulnerability](/docs/security/disclosure) explains what is in scope, the safe harbour for good-faith research and the response times we aim for.
297+Found a security problem? Report it privately with the [report form](/security/report), the first contact in [`/.well-known/security.txt`](/.well-known/security.txt). [Reporting a vulnerability](/docs/security/disclosure) explains what is in scope, the safe harbour for good-faith research and the response times we aim for.
modifiedapps/diffinery/docs/self-hosting/architecture.md+4−3View file
@@ -147,9 +147,9 @@
147147
148148 The same queue carries webhook retries. When a delivery fails with a network error or timeout, `408`, `429` or a `5xx` status, the consumer puts a retry message for that one webhook back on the queue with a delay: 1 minute, then 5 minutes, 30 minutes, 1 hour and 3 hours, so a delivery is tried at most 6 times, the last one about 4 hours and 36 minutes after the first. A retry sends the same body with the same `X-GitHub-Delivery` ID, to the webhook's current URL and signed with its current secret; it is dropped if the webhook was deleted in the meantime. Other statuses, including `3xx` and other `4xx` answers, are not retried. Webhook and stage retry messages are not added to the R2 event archive.
149149
150-**`diffinery-mail`** carries email jobs. It is separate so that sign-in links and invitation links never land in the event archive. The consumer applies the recipient's notification settings, renders the HTML and text versions, and sends through Email Sending. Temporary failures are retried with backoff; after 6 attempts the email is logged as failed. Before sending, it checks the suppression list: nothing goes to an address that bounced for good or reported spam.
150+**`diffinery-mail`** carries email jobs. It is separate so that sign-in links and invitation links never land in the event archive. The consumer applies the recipient's notification settings, renders the HTML and text versions, and sends through Email Sending. Temporary failures are retried with backoff; after 6 attempts the email is logged as failed. Before sending, it checks the suppression list: nothing goes to an address that bounced for good or reported spam. When Email Sending refuses a recipient for good (`E_RECIPIENT_SUPPRESSED`: it is on Cloudflare's own suppression list), the address goes on Diffinery's list as a hard bounce, so later emails are skipped without a call.
151151
152-**`diffinery-mail-events`** receives Email Sending's delivery events. The consumer adds the recipient of a permanent bounce or a spam complaint to the suppression list and ignores the rest.
152+**`diffinery-mail-events`** receives Email Sending's delivery events; the deploy subscribes it to the sending domain's bounced and complained events. The consumer adds the recipient of a permanent bounce or a spam complaint to the suppression list and ignores the rest.
153153
154154 ## Workflows
155155
@@ -211,7 +211,7 @@
211211
212212 ## Rate limits
213213
214-Every request passes the rate limiting bindings before it is handled (`src/lib/rate-limit.ts`). Sign-in and invitation requests count per IP address in `RL_AUTH`, other anonymous requests per IP address in `RL_ANON`, and requests with a session or token per account in `RL_ACCOUNT`. Requests that create repositories, organizations, forks, issues, pull requests, releases, comments or reviews also count in `RL_CREATE`, as do MCP tool calls that change something. Job, sandbox, runner and import credentials, health checks and avatars aren't counted. Cloudflare keeps each count in the location that serves the request, so the limits stop bursts rather than meter use exactly. Past a limit the request answers `429` with `retry-after: 60`. See [Bindings](/docs/self-hosting/configuration#bindings).
214+Every request passes the rate limiting bindings before it is handled (`src/lib/rate-limit.ts`). Sign-in, invitation and security report requests count per IP address in `RL_AUTH`, other anonymous requests per IP address in `RL_ANON`, and requests with a session or token per account in `RL_ACCOUNT`. Requests that create repositories, organizations, forks, issues, pull requests, releases, comments or reviews also count in `RL_CREATE`, as do MCP tool calls that change something. Job, sandbox, runner and import credentials, health checks and avatars aren't counted. Cloudflare keeps each count in the location that serves the request, so the limits stop bursts rather than meter use exactly. Past a limit the request answers `429` with `retry-after: 60`. See [Bindings](/docs/self-hosting/configuration#bindings).
215215
216216 ## Request flow: a git push
217217
@@ -272,6 +272,7 @@
272272 | `tokens` | Personal and agent tokens: SHA-256 hash, prefix, scopes, repository restriction, expiry, last use, revocation, expiry reminder, and the import a GitHub import's push token belongs to. |
273273 | `invites` | Invitations to the site, an organization or a repository, by token hash, with expiry and acceptance. |
274274 | `access_requests` | Requests from people waiting for an invite. |
275+| `security_reports` | Private vulnerability reports from `/security/report`: summary, details, affected area, reply-to address, the reporter when signed in, a hash of the sender's IP address (for the daily limits), and the state (`new`, `triaged`, `resolved`) and note a site administrator set. |
275276 | `passkeys` | WebAuthn credentials: public key, counter, transports, device type, backup state, name. |
276277 | `sessions` | Browser sessions by hash of the cookie value, with the sign-in method and passkey, user agent and expiry. |
277278 | `webauthn_challenges` | In-flight passkey ceremonies. |
modifiedapps/diffinery/docs/self-hosting/configuration.md+26−16View file
@@ -10,8 +10,8 @@
1010 | File or place | What it holds | Edit it? |
1111 | --- | --- | --- |
1212 | `apps/diffinery/wrangler.jsonc` | Worker name, route, variables, bindings, container settings, cron, and the `dev` environment. | Yes. This is the source of truth. |
13-| `apps/diffinery/wrangler.deploy.jsonc` | `wrangler.jsonc` without the `dev` environment, plus the real D1 database ID, `ACCESS_TEAM_DOMAIN`, `ACCESS_AUD`, and the route, `PUBLIC_URL` and `MAIL_FROM` for your hostname. Written by `scripts/render-config.mjs` on every deploy and ignored by git. | No. It is regenerated on each deploy. |
14-| Worker secrets | `MASTER_KEY` and `OIDC_PRIVATE_KEY`, created by `scripts/ensure-secrets.sh`, and optionally `GITHUB_OAUTH_CLIENT_SECRET` and `TURNSTILE_SECRET`, which you set yourself. | Only to restore them, or to add the optional one. Never change `MASTER_KEY`. |
13+| `apps/diffinery/wrangler.deploy.jsonc` | `wrangler.jsonc` without the `dev` environment, plus the real D1 database ID, `ACCESS_TEAM_DOMAIN`, `ACCESS_AUD`, `TURNSTILE_SITE_KEY`, and the route, `PUBLIC_URL` and `MAIL_FROM` for your hostname. Written by `scripts/render-config.mjs` on every deploy and ignored by git. | No. It is regenerated on each deploy. |
14+| Worker secrets | `MASTER_KEY` and `OIDC_PRIVATE_KEY`, created by `scripts/ensure-secrets.sh`; `TURNSTILE_SECRET`, set by the deploy from its Turnstile widget; and optionally `GITHUB_OAUTH_CLIENT_SECRET`, which you set yourself. | Only to restore them, or to add the optional one. Never change `MASTER_KEY`. |
1515 | Repository secrets and variables | Cloudflare credentials and options for the Deploy workflow, and a token for the live test and sync workflows. | Yes, in your forge's repository settings. |
1616 | `apps/diffinery/.dev.vars` | Local secrets for `wrangler dev`, such as `MASTER_KEY`. Ignored by git. | Yes, for local development only. |
1717
@@ -29,7 +29,7 @@
2929 | `ARTIFACT_QUOTA_BYTES` | Not set | No | Workflow artifact storage per repository, in bytes: artifacts that haven't expired plus uploads in progress. Default `10737418240` (10 GB). Anything that isn't a positive whole number counts as not set. Add it under `vars` in `wrangler.jsonc`; the deploy keeps it. When a repository reaches it, uploads fail with `413 the repository's artifact storage quota (N bytes) is used up; delete artifacts or wait for them to expire`. See [Caches and artifacts](/docs/reference/limits#caches-and-artifacts). |
3030 | `DEV_AUTH` | Not set in production; `dev@diffinery.local` in the `dev` environment | Never in production | Local development only. See [DEV_AUTH](#dev_auth). |
3131 | `SIGNUP` | Not set | No | `open` lets anyone create an account at `/signup`. See [SIGNUP](#signup). Any other value, or none, keeps the instance invite-only. |
32-| `TURNSTILE_SITE_KEY` | Not set | With `SIGNUP=open` | The site key of the Cloudflare Turnstile widget on the sign-up page. See [SIGNUP](#signup). |
32+| `TURNSTILE_SITE_KEY` | Set by the deploy | With `SIGNUP=open`; for anonymous security reports | The site key of the Cloudflare Turnstile widget on the sign-up page and the [security report form](#security-contact). The deploy creates the widget and sets this; see [Turnstile](#turnstile). Setting it doesn't open sign-up. |
3333 | `IMPORT_MAX_REPO_KB` | Not set | No | The largest GitHub repository a one-repository import accepts, in KB as GitHub reports a repository's `size`. Default `10485760` (10 GB). An empty value, `0`, a negative number or text that isn't a number counts as not set. A larger source is refused before the import starts with `422 the repository is about N GB, over the M GB import limit; ask an administrator to raise IMPORT_MAX_REPO_KB or split the repository`. See [GitHub imports](#github-imports). |
3434 | `QUOTA_HOSTED_MINUTES`, `QUOTA_HOSTED_JOBS`, `QUOTA_SANDBOXES`, `QUOTA_REPOSITORIES`, `QUOTA_STORAGE_BYTES` | Not set | No | The default per-account quotas: hosted minutes per month (default `3000`), hosted Actions jobs at once (`5`), agent sandboxes open at once (`5`), repositories (`500`) and storage in bytes (`10737418240`, 10 GB). `-1` is unlimited; text that isn't a whole number counts as not set. Site administrators change them for one account with `PATCH /api/v3/admin/accounts/{login}/quotas`, and accounts run by a site administrator have none. See [Limits](/docs/reference/limits#account-quotas). |
3535 | `IMPORT_GHE_HOST` | Not set | No | The host name of a GitHub Enterprise Server (like `ghe.example.com`) that imports may download release assets from. Without it, imports download release assets only from github.com and its asset hosts, and assets of an Enterprise import are skipped with a warning. |
@@ -41,7 +41,7 @@
4141 | `TERMINAL_POLL_MS` | Not set | Never in production | The end-to-end tests only: how often open web terminals are checked for their limits and their agent's access, in milliseconds, from `2000` to `60000` (the default). It counts only on a Worker on this machine; a deployed Worker ignores it. |
4242 | `EGRESS` | Not set | No | `web` limits hosted Actions jobs and agent sandboxes (including tasks, reviews and terminals) to HTTP and HTTPS (ports 80 and 443) and DNS. Any other value, or none, leaves them open to the whole internet. Accounts run by a site administrator stay open. See [EGRESS](#egress). |
4343 | `RUNNER_IMAGE` | Set by the deploy | No | The runner image's name and tag (for example `diffinery-runner:96dbbe31790f`), as jobs report it in `runner.image`. `scripts/render-config.mjs` sets it when the deploy uses a prebuilt runner image, and `GET /api/v3/meta` returns it as `runner_image` (`null` when not set). Don't set it by hand. |
44-| `SECURITY_CONTACT` | Not set | No | Where people report a vulnerability in your instance, served in `/.well-known/security.txt`: an email address or an `https://` URL (a web form), several separated by commas. Unset, `security.txt` answers 404. See [Security contact](#security-contact). |
44+| `SECURITY_CONTACT` | Not set | No | More places people can report a vulnerability in your instance, listed in `/.well-known/security.txt` after the instance's own report form (`/security/report`): an email address or an `https://` URL, several separated by commas. See [Security contact](#security-contact). |
4545 | `TERMINALS` | Not set | No | `off` switches [terminals](/docs/agents/terminal) off: nobody can open one, and open ones end at their next check, within a minute. Anything else, or not set, leaves them on. |
4646
4747 When either `ACCESS_TEAM_DOMAIN` or `ACCESS_AUD` is empty, email sign-in at `/auth/access` is off: the page answers "Account recovery by email is not set up on this instance. Ask an administrator for a sign-in link.", and the sign-in page doesn't offer **Recover your account by email**. Passkeys, invitations and admin sign-in links keep working, but the first administrator can't sign in, because email sign-in is how an account in `BOOTSTRAP_ADMINS` is created.
@@ -69,14 +69,10 @@
6969
7070 With `SIGNUP=open`, the sign-in page links to **Create an account** (`/signup`). Someone enters their email address, gets a 6-digit code by email, enters it, picks a username and creates a passkey; their account is active at once (see [Open sign-up](/docs/accounts/passkeys-and-sign-in#open-sign-up)). Unset, or any other value, the instance is invite-only as before: `/signup` answers `404` and the sign-in page says so.
7171
72-Open sign-up needs a Cloudflare Turnstile widget, so scripts can't use it to send email:
72+Open sign-up needs Cloudflare Turnstile, so scripts can't use it to send email. The deploy sets Turnstile up (see [Turnstile](#turnstile)), so to open sign-up add `"SIGNUP": "open"` under `vars` in `wrangler.jsonc`; the deploy keeps it. The Turnstile keys alone never open sign-up: only `SIGNUP=open` does.
7373
74-1. In the Cloudflare dashboard, open **Turnstile**, add a widget for your hostname (the one in `PUBLIC_URL`) and copy its site key and secret key.
75-2. Add `"SIGNUP": "open"` and `"TURNSTILE_SITE_KEY": "<site key>"` under `vars` in `wrangler.jsonc`; the deploy keeps them.
76-3. Store the secret: `printf '%s' "$TURNSTILE_SECRET" | pnpm exec wrangler secret put TURNSTILE_SECRET -c wrangler.deploy.jsonc`.
74+Each code request is checked with Turnstile's `siteverify` API, with the visitor's IP address. Without both keys, `/signup` answers `503` with "Sign-up needs Turnstile configured", except when `PUBLIC_URL` is on the local machine (`localhost`, `127.x.x.x` or `[::1]`): local development and the end-to-end tests skip the check. Only the sign-up page's and the security report form's content security policy allows scripts and frames from `https://challenges.cloudflare.com`.
7775
78-Each code request is checked with Turnstile's `siteverify` API, with the visitor's IP address. Without both keys, `/signup` answers `503` with "Sign-up needs Turnstile configured", except when `PUBLIC_URL` is on the local machine (`localhost`, `127.x.x.x` or `[::1]`): local development and the end-to-end tests skip the check. Only the sign-up page's content security policy allows scripts and frames from `https://challenges.cloudflare.com`.
79-
8076 The limits on codes (5 a day per address, 20 a day per IP address, 10 minutes and 5 tries each) are in [Limits](/docs/reference/limits#sign-in-and-sessions). Sign-up emails always send: they need the `EMAIL` binding and the `MAIL` queue like every other email.
8177
8278 ### EGRESS
@@ -125,18 +121,32 @@
125121
126122 ### Security contact
127123
128-`/.well-known/security.txt` ([RFC 9116](https://www.rfc-editor.org/rfc/rfc9116)) tells security researchers where to report a problem. Diffinery serves it only when `SECURITY_CONTACT` is set, with:
124+`/.well-known/security.txt` ([RFC 9116](https://www.rfc-editor.org/rfc/rfc9116)) tells security researchers where to report a problem. Diffinery always serves it, with:
129125
130126 | Field | Value |
131127 | --- | --- |
132-| `Contact` | One line per entry of `SECURITY_CONTACT`. An email address becomes a `mailto:` URI; `https://`, `mailto:` and `tel:` URIs are kept as they are; anything else is left out. |
128+| `Contact` | First `<PUBLIC_URL>/security/report`, the instance's private report form, then one line per entry of `SECURITY_CONTACT`. An email address becomes a `mailto:` URI; `https://`, `mailto:` and `tel:` URIs are kept as they are; anything else is left out. |
133129 | `Expires` | Midnight UTC 360 days after the request, so the file never goes stale. |
134130 | `Policy` | `<PUBLIC_URL>/docs/security/disclosure`, the [disclosure policy](/docs/security/disclosure). |
135131 | `Preferred-Languages` | `en` |
136132 | `Canonical` | `<PUBLIC_URL>/.well-known/security.txt` |
137133
138-Use an address someone reads. The repository leaves it unset on purpose, so an instance never advertises an address its operator didn't choose. To set it, add `"SECURITY_CONTACT": "security@example.com"` under `vars` in `wrangler.jsonc`; the deploy keeps it.
134+The report form needs no setup: reports go to the instance's site administrators, who each get an email with the report's summary and read it under **Settings → Security reports** (see [Site administration](/docs/admin/site-administration#security-reports)). People who are signed in can always send one. People who aren't pass Cloudflare Turnstile first, with the same `TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET` as [open sign-up](#signup), which the deploy sets up (see [Turnstile](#turnstile); you don't need `SIGNUP=open` for this). Without both keys, for example when the deploy's API token lacks **Account → Turnstile → Edit**, the form asks visitors to sign in, except on the local machine, where the check is skipped as for sign-up.
139135
136+`SECURITY_CONTACT` is optional. Use an address someone reads. The repository leaves it unset on purpose, so an instance never advertises an address its operator didn't choose. To add one, add `"SECURITY_CONTACT": "security@example.com"` under `vars` in `wrangler.jsonc`; the deploy keeps it.
137+
138+### Turnstile
139+
140+The security report form, and open sign-up when it's on, check with [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) that a visitor without an account is a person. Every deploy sets it up (`scripts/turnstile.sh`, run by `scripts/bootstrap.sh`):
141+
142+1. It looks for a Turnstile widget named `diffinery <DIFFINERY_DOMAIN>` in the account, and creates one when there is none: managed mode, for your hostname only.
143+2. The widget's site key becomes the `TURNSTILE_SITE_KEY` variable of the deployed Worker.
144+3. Its secret key becomes the `TURNSTILE_SECRET` secret: `scripts/ensure-secrets.sh` sets it when it's missing, and bootstrap replaces it before the deploy when it goes with another widget (the deployed `TURNSTILE_SITE_KEY` is not this widget's, for example because the widget was deleted and created again). The key goes from the Cloudflare API straight to `wrangler secret put` and is never printed.
145+
146+The deploy's API token needs **Account → Turnstile → Edit** for this (the [API reference](https://developers.cloudflare.com/api/resources/turnstile/subresources/widgets/methods/create/) calls it "Turnstile Sites Write"). Without it the deploy warns, naming the permission, and goes on; people who aren't signed in then can't send security reports until you add it and deploy again.
147+
148+To use a widget of your own instead, add `"TURNSTILE_SITE_KEY": "<site key>"` under `vars` in `wrangler.jsonc` and store its secret key with `printf '%s' "$TURNSTILE_SECRET" | pnpm exec wrangler secret put TURNSTILE_SECRET -c wrangler.deploy.jsonc`. The deploy then creates no widget and never replaces your secret; it only fills in a missing `TURNSTILE_SECRET` when your widget is in the same Cloudflare account. If you rotate the secret key of the deploy's widget in the dashboard, run `pnpm exec wrangler secret delete TURNSTILE_SECRET -c wrangler.deploy.jsonc` and deploy again, so the deploy stores the new one.
149+
140150 ### DEV_AUTH
141151
142152 When `DEV_AUTH` is set, `ACCESS_AUD` is empty and both `PUBLIC_URL` and the request's own address are on the local machine (`localhost`, `127.x.x.x` or `[::1]`), every request without credentials (no token, no session) acts as the account with the email in `DEV_AUTH`, with full access. That account is created on first use and is a site administrator if the email is also in `BOOTSTRAP_ADMINS`. While `DEV_AUTH` is set, the email log also keeps each whole message, and `GET /api/v3/diffinery/emails` returns it, so tests can read emails without delivering them.
@@ -153,7 +163,7 @@
153163 | `OIDC_PRIVATE_KEY` | An ECDSA P-256 private key as a JWK (JSON), with a `kid` | `ensure-secrets.sh`, once | Signs the ID tokens that workflow jobs request with `permissions: id-token: write` (ES256). Its public half is served at `/.well-known/jwks`. | Jobs that request an ID token fail with `OIDC is not configured on this Diffinery instance`, `/.well-known/jwks` returns no keys, and `GET /api/v3/meta` returns `"oidc": false`. |
154164 | `GITHUB_OAUTH_CLIENT_SECRET` | The client secret of a GitHub OAuth app | You, optionally | With `GITHUB_OAUTH_CLIENT_ID`, turns on the **Link with GitHub** option on **Settings → Linked accounts**. | People link GitHub accounts with the gist code only. |
155165 | `MAIL_EVENTS_SECRET` | A random string, at least 32 characters | You, optionally | Turns on `POST /-/email/events`, the signed intake for bounces and complaints from anything other than Cloudflare's own event subscriptions. See [Bounces, complaints and the suppression list](/docs/admin/site-administration#bounces-complaints-and-the-suppression-list). | The endpoint answers 404. Bounces and complaints still arrive through the `diffinery-mail-events` queue. |
156-| `TURNSTILE_SECRET` | The secret key of a Cloudflare Turnstile widget | You, optionally | With `SIGNUP=open` and `TURNSTILE_SITE_KEY`, checks that whoever asks for a sign-up code passed the Turnstile widget. See [SIGNUP](#signup). | With `SIGNUP=open`, sign-up answers `503` "Sign-up needs Turnstile configured" (local development skips the check). Without `SIGNUP=open` it isn't used. |
166+| `TURNSTILE_SECRET` | The secret key of a Cloudflare Turnstile widget | The deploy, from its widget (see [Turnstile](#turnstile)) | With `TURNSTILE_SITE_KEY`, checks that whoever asks for a sign-up code (with `SIGNUP=open`), or sends a security report without signing in, passed the Turnstile widget. See [SIGNUP](#signup) and [Security contact](#security-contact). | With `SIGNUP=open`, sign-up answers `503` "Sign-up needs Turnstile configured", and the security report form takes reports only from people who are signed in (local development skips the check). The next deploy sets it when the token has **Account → Turnstile → Edit**. |
157167
158168 None of these secrets is rotated automatically. Changing `MASTER_KEY` makes every stored secret undecryptable and invalidates unsubscribe links already sent, along with the runtime tokens of running jobs and cache and artifact links already handed out; keep a copy somewhere safe. To rotate the OIDC key, delete `OIDC_PRIVATE_KEY` and deploy: a new key with a new `kid` (`diffinery-<date>`) is generated, and relying parties pick it up from `/.well-known/jwks`.
159169
@@ -175,8 +185,8 @@
175185 | `EVENTS` | Queue producer, with a consumer on the same Worker | `diffinery-events`, dead-letter queue `diffinery-events-dlq` | Repository events for webhooks, notifications and the audit log. Consumer: batches of up to 50, waiting up to 2 seconds, 8 retries. |
176186 | `MAIL` | Queue producer, with a consumer on the same Worker | `diffinery-mail`, dead-letter queue `diffinery-mail-dlq` | Email jobs. Consumer: batches of up to 20, waiting up to 2 seconds, 8 retries. Optional: without it emails are dropped with a warning in the logs. |
177187 | `EMAIL` | `send_email` (Email Sending) | Your onboarded domain | Sending email. Optional: without it every email is logged as `skipped`. |
178-| (none) | Queue consumer | `diffinery-mail-events` | Bounces and complaints from Email Sending's event subscriptions, which you subscribe this queue to (see [Site administration](/docs/admin/site-administration#bounces-complaints-and-the-suppression-list)). Consumer: batches of up to 50, waiting up to 5 seconds, 8 retries. |
179-| `RL_AUTH`, `RL_ANON`, `RL_ACCOUNT`, `RL_CREATE`, `RL_MODEL` | Rate limiting (`ratelimits`) | Namespaces `1001` to `1005`; 20, 300, 2,000, 60 and 600 requests per 60 seconds | [Request rate limits](/docs/reference/rest-api#rate-limits): sign-in per IP address, anonymous requests per IP address, signed-in requests per account, new content per account, and agents' model requests per paying person (`MODEL_RATE_LIMIT` in `src/lib/rate-limit.ts`). Optional: a missing binding (the `dev` environment declares none) or one that fails lets requests through. If you change a limit, change `RATE_LIMITS` (or `MODEL_RATE_LIMIT`) in `src/lib/rate-limit.ts` to match, since the 429 messages quote it. Namespace IDs are shared across your Cloudflare account, so pick others if another Worker already uses these. |
188+| (none) | Queue consumer | `diffinery-mail-events` | Bounces and complaints from Email Sending's event subscriptions; the deploy subscribes this queue to your sending domain's events (see [Site administration](/docs/admin/site-administration#where-bounces-and-complaints-come-from)). Consumer: batches of up to 50, waiting up to 5 seconds, 8 retries. |
189+| `RL_AUTH`, `RL_ANON`, `RL_ACCOUNT`, `RL_CREATE`, `RL_MODEL` | Rate limiting (`ratelimits`) | Namespaces `1001` to `1005`; 20, 300, 2,000, 60 and 600 requests per 60 seconds | [Request rate limits](/docs/reference/rest-api#rate-limits): sign-in and security reports per IP address, anonymous requests per IP address, signed-in requests per account, new content per account, and agents' model requests per paying person (`MODEL_RATE_LIMIT` in `src/lib/rate-limit.ts`). Optional: a missing binding (the `dev` environment declares none) or one that fails lets requests through. If you change a limit, change `RATE_LIMITS` (or `MODEL_RATE_LIMIT`) in `src/lib/rate-limit.ts` to match, since the 429 messages quote it. Namespace IDs are shared across your Cloudflare account, so pick others if another Worker already uses these. |
180190 | `REPO` | Durable Object namespace | Class `RepoDO` (SQLite) | One object per repository. |
181191 | `SCHEDULER` | Durable Object namespace | Class `SchedulerDO` (SQLite) | One object that starts scheduled workflow runs on its own alarm. |
182192 | `RUNNER` | Durable Object namespace with a container | Class `RunnerContainer` (SQLite) | One container per hosted workflow job. |
modifiedapps/diffinery/docs/self-hosting/deploy.md+23−9View file
@@ -26,10 +26,10 @@
2626 One workflow run does everything, and every step is safe to repeat:
2727
2828 1. Install dependencies, build the runner agent, the web UI's client bundles and the documentation (the pages under `apps/diffinery/docs`, the CLI reference and the CLI file served at `/-/cli/diffinery.mjs`), typecheck and run the unit tests.
29-2. `scripts/bootstrap.sh` creates whatever is missing in your account (D1 database, R2 bucket, queues, the Cloudflare Access application for `/auth/access`), sets the R2 bucket's lifecycle rules, checks Email Sending, writes `apps/diffinery/wrangler.deploy.jsonc` with the real IDs and your hostname's route, `PUBLIC_URL` and email sender, and applies D1 migrations.
29+2. `scripts/bootstrap.sh` creates whatever is missing in your account (D1 database, R2 bucket, queues, the Turnstile widget for the security report form, the Cloudflare Access application for `/auth/access`), sets the R2 bucket's lifecycle rules, checks Email Sending, writes `apps/diffinery/wrangler.deploy.jsonc` with the real IDs and your hostname's route, `PUBLIC_URL` and email sender, and applies D1 migrations.
3030 3. `wrangler deploy -c wrangler.deploy.jsonc` uploads the Worker, the Durable Object classes, the two Workflows (workflow runs and GitHub imports) and the cron trigger, and builds and pushes the runner image for Containers.
3131 4. `scripts/wait-container-rollout.sh` waits until the container rollouts of the new runner image reach their last step. Even then, an instance the rollout hasn't updated yet can start a container on the old image. Diffinery turns away a runner that reports any image other than `RUNNER_IMAGE` and starts the job again, up to four times. The live test waits until jobs report the deployed image (`runner_image` on `GET /api/v3/meta`) and then checks that every job ran on it. Running containers keep the old image until they finish. It gives up with a warning, not a failure, after `ROLLOUT_WAIT_MINUTES` (10 by default).
32-5. `scripts/ensure-secrets.sh` creates the Worker's two secrets, `MASTER_KEY` and `OIDC_PRIVATE_KEY`, the first time only.
32+5. `scripts/ensure-secrets.sh` creates the Worker's two secrets, `MASTER_KEY` and `OIDC_PRIVATE_KEY`, the first time only, and sets `TURNSTILE_SECRET` when it's missing.
3333 6. On the very first deploy, bootstrap runs again to finish the Access setup, and the Worker is redeployed.
3434 7. Smoke tests check `/healthz`, the sign-in pages, static assets and D1 sessions.
3535
@@ -79,12 +79,13 @@
7979 | Account | Workers Scripts | Edit | Deploying the Worker, Durable Objects and secrets |
8080 | Account | D1 | Edit | Creating the database and applying migrations |
8181 | Account | R2 (Workers R2 Storage) | Edit | Creating the bucket and setting its lifecycle rules |
82-| Account | Queues | Edit | Creating the queues and consumers |
82+| Account | Queues | Edit | Creating the queues and consumers, and subscribing `diffinery-mail-events` to Email Sending's bounce and complaint events |
8383 | Account | Workflows | Edit | The `diffinery-run` and `diffinery-import` Workflows |
8484 | Account | Containers | Edit | Building and deploying the runner image for runners, sandboxes and imports |
8585 | Account | Access: Apps and Policies | Edit | The Access application for `/auth/access` |
86+| Account | Turnstile | Edit | The Turnstile widget that lets people without an account send [security reports](/docs/security/disclosure) (the API reference calls it "Turnstile Sites Write"). Without it the deploy warns and goes on, and only people who are signed in can send reports. |
8687 | Zone (the zone that holds your hostname, `DIFFINERY_ZONE`) | Workers Routes | Edit | The `git.example.com/*` route |
87-| Zone (your zone), optional | Zone Read, DNS Read, Email Sending Read | Read | Letting `scripts/email-sending.sh` check your Email Sending setup through the API. Without them it falls back to public DNS lookups. |
88+| Zone (your zone), optional | Zone Read, DNS Read, Email Sending Read | Read | Letting `scripts/email-sending.sh` check your Email Sending setup through the API. Without them it falls back to public DNS lookups. Zone Read on the zone of your sending domain also lets bootstrap find its zone ID for the bounce and complaint subscription. |
8889
8990 If the token can't read your Zero Trust organization, bootstrap can't discover your Access team domain on its own; set the `ACCESS_TEAM_DOMAIN` variable (next step).
9091
@@ -124,11 +125,15 @@
124125
125126 1. **D1 database.** Looks for a database named `diffinery` and creates it if missing.
126127 2. **R2 bucket.** Creates `diffinery-data` if missing, then sets its lifecycle rules, which delete short-lived data (such as expired artifacts, old job logs and unfinished uploads) by age and never touch git objects, LFS objects or release assets. The rules are set on every deploy and replace any lifecycle rules you added in the dashboard. If they can't be set, or a rule would still delete git objects, bootstrap stops the deploy. See [R2](/docs/self-hosting/architecture#r2) for the rules.
127-3. **Queues.** Creates `diffinery-events`, `diffinery-events-dlq`, `diffinery-mail`, `diffinery-mail-dlq` and `diffinery-mail-events` if missing. Subscribing `diffinery-mail-events` to Email Sending's bounce and complaint events is a manual step, see [Site administration](/docs/admin/site-administration#bounces-complaints-and-the-suppression-list).
128-4. **Email Sending.** Runs `scripts/email-sending.sh` for `MAIL_DOMAIN` (default `DIFFINERY_DOMAIN`). A failure here is only a warning: the deploy continues and email is skipped until sending works.
129-5. **Cloudflare Access.** Finds your team domain (from `ACCESS_TEAM_DOMAIN`, else from your Zero Trust organization through the API). Then it creates, or updates, a self-hosted Access application named **Diffinery account recovery** that covers only `<domain>/auth/access`, with a 15-minute Access session, an HTTP-only cookie and a policy that lets anyone who proves their email through. Diffinery, not Access, decides what that person may do. It also deletes an application named **Diffinery machine endpoints** left by older versions. If it still has no team domain, it requests `https://<domain>/auth/access` and reads the team domain from the Access redirect.
130-6. **Render config and migrate D1.** Runs `scripts/render-config.mjs` with `--public-url https://<DIFFINERY_DOMAIN>`, `--zone <DIFFINERY_ZONE>` (only when set) and `--mail-from notifications@<MAIL_DOMAIN or DIFFINERY_DOMAIN>`, and then `wrangler d1 migrations apply diffinery --remote -c wrangler.deploy.jsonc`.
128+3. **Queues.** Creates `diffinery-events`, `diffinery-events-dlq`, `diffinery-mail`, `diffinery-mail-dlq` and `diffinery-mail-events` if missing.
129+4. **Email Sending event subscription.** Subscribes `diffinery-mail-events` to Email Sending's `message.bounced` and `message.complained` events for `MAIL_DOMAIN` (default `DIFFINERY_DOMAIN`), through the Queues event subscriptions API, so bounces and complaints reach the [suppression list](/docs/admin/site-administration#where-bounces-and-complaints-come-from). It creates the subscription only when there is none for that domain and queue, and adds either event to one that lacks it. A failure here is only a warning that names the permission the token needs (Account → Queues → Edit, and Zone Read to find the zone); the deploy continues.
130+5. **Email Sending.** Runs `scripts/email-sending.sh` for `MAIL_DOMAIN` (default `DIFFINERY_DOMAIN`). A failure here is only a warning: the deploy continues and email is skipped until sending works.
131+6. **Turnstile.** Runs `scripts/turnstile.sh`, which looks for a Turnstile widget named `diffinery <DIFFINERY_DOMAIN>` in your account and creates it when there is none (managed mode, for your hostname only). Its site key becomes the Worker's `TURNSTILE_SITE_KEY` variable, so people without an account can send [security reports](/docs/security/disclosure); its secret key becomes the `TURNSTILE_SECRET` secret (see [scripts/ensure-secrets.sh](#scriptsensure-secretssh)). The token needs **Account → Turnstile → Edit** ("Turnstile Sites Write" in the API reference). Without it this is only a warning that names the permission: the deploy continues, and only people who are signed in can send reports. The keys don't open sign-up: that still takes `SIGNUP=open` (see [SIGNUP](/docs/self-hosting/configuration#signup)). If `wrangler.jsonc` sets `TURNSTILE_SITE_KEY` itself, the step leaves Turnstile to you.
131132
133+ Worker secrets can't be read back, so the step tells which widget `TURNSTILE_SECRET` goes with by the `TURNSTILE_SITE_KEY` deployed with it. When they differ, for example because someone deleted the widget and the deploy created a new one, it replaces `TURNSTILE_SECRET` with the new widget's secret before the deploy. If it can't, it leaves `TURNSTILE_SITE_KEY` unset for this deploy, so anonymous reports stay refused instead of failing the check, and the next deploy tries again.
134+7. **Cloudflare Access.** Finds your team domain (from `ACCESS_TEAM_DOMAIN`, else from your Zero Trust organization through the API). Then it creates, or updates, a self-hosted Access application named **Diffinery account recovery** that covers only `<domain>/auth/access`, with a 15-minute Access session, an HTTP-only cookie and a policy that lets anyone who proves their email through. Diffinery, not Access, decides what that person may do. It also deletes an application named **Diffinery machine endpoints** left by older versions. If it still has no team domain, it requests `https://<domain>/auth/access` and reads the team domain from the Access redirect.
135+8. **Render config and migrate D1.** Runs `scripts/render-config.mjs` with the Turnstile site key in `TURNSTILE_SITE_KEY` (when the step above found one), `--public-url https://<DIFFINERY_DOMAIN>`, `--zone <DIFFINERY_ZONE>` (only when set) and `--mail-from notifications@<MAIL_DOMAIN or DIFFINERY_DOMAIN>`, and then `wrangler d1 migrations apply diffinery --remote -c wrangler.deploy.jsonc`.
136+
132137 At the end it prints `team=<team domain> aud=set` and, inside a workflow, writes `d1_id`, `access_team` and `access_aud_set` to the step outputs.
133138
134139 ### scripts/render-config.mjs
@@ -149,6 +154,8 @@
149154 | `--zone <zone>` | No | The zone for the route. Defaults to the hostname's last two labels (`git.example.com` gives `example.com`). Only used together with `--public-url`. |
150155 | `--mail-from <address>` | No | Sets `MAIL_FROM`, overriding the `notifications@<hostname>` that `--public-url` sets. |
151156
157+It also reads two environment variables: `RUNNER_IMAGE` (set by `scripts/runner-image.sh`) points every container at a prebuilt runner image and sets the `RUNNER_IMAGE` variable, and `TURNSTILE_SITE_KEY` (set by bootstrap from `scripts/turnstile.sh`) sets the `TURNSTILE_SITE_KEY` variable, unless `wrangler.jsonc` already sets one.
158+
152159 Without `--public-url`, the route, `PUBLIC_URL` and `MAIL_FROM` stay as they are in `wrangler.jsonc`. Bootstrap always passes `--public-url` and `--mail-from`, so a normal deploy takes them from `DIFFINERY_DOMAIN` and `MAIL_DOMAIN`.
153160
154161 For `DIFFINERY_DOMAIN=git.example.com`, the deployed config contains:
@@ -176,8 +183,10 @@
176183 - `MASTER_KEY`: 32 random bytes, base64 (`openssl rand -base64 32`). It is the root key for encrypting Actions secrets, signing unsubscribe links, and signing the tokens and links that workflow jobs use for caches and artifacts.
177184 - `OIDC_PRIVATE_KEY`: a new ECDSA P-256 private key as a JWK, with a key ID like `diffinery-2026-10-13`. It signs workflow OIDC tokens.
178185
179-Values are generated on the runner and piped straight into `wrangler secret put`; they are never printed. The script lists existing secrets first and stops with `unexpected output from wrangler secret list` if it can't read them, rather than risk overwriting `MASTER_KEY`, which would make every stored Actions secret unreadable.
186+It also sets `TURNSTILE_SECRET` when the deployed config has a `TURNSTILE_SITE_KEY` and the Worker has no `TURNSTILE_SECRET` yet (the first deploy with Turnstile): `scripts/turnstile.sh secret <site key>` reads that widget's secret key from the Cloudflare API. Without **Account → Turnstile → Edit** it warns and the deploy goes on.
180187
188+Values are generated on the runner, or read from the Cloudflare API, and piped straight into `wrangler secret put`; they are never printed. The script lists existing secrets first and stops with `unexpected output from wrangler secret list` if it can't read them, rather than risk overwriting `MASTER_KEY`, which would make every stored Actions secret unreadable.
189+
181190 ### Finishing Access on the first deploy
182191
183192 On a brand-new account, bootstrap may not find the team domain before the Worker exists. When its `access_team` output is empty, the workflow runs `scripts/bootstrap.sh` once more (which can now read the team domain from the live `/auth/access` redirect) and redeploys with `wrangler deploy -c wrangler.deploy.jsonc --containers-rollout none`.
@@ -334,6 +343,11 @@
334343 | `Could not create the Access application; email recovery stays off.` | bootstrap | Check that the account has a Zero Trust organization and that the token has Access: Apps and Policies Edit. |
335344 | `Access team domain unknown; set the ACCESS_TEAM_DOMAIN repository variable (Zero Trust → Settings → Custom pages shows it).` | bootstrap | Set the variable and deploy again. |
336345 | `Email Sending check failed; email may not be delivered.` | bootstrap | Read the email check's output above it. |
346+| `Could not subscribe diffinery-mail-events to Email Sending's bounce and complaint events for git.example.com. The token needs Account → Queues → Edit ...` (or `Could not read the queue's event subscriptions ...`) | bootstrap (warning) | Add Queues Edit to the token, check that the domain is onboarded to Email Sending, and deploy again; the text at the end is Cloudflare's error. Or subscribe the queue by hand once, see [Site administration](/docs/admin/site-administration#where-bounces-and-complaints-come-from). Until then, bounces and complaints don't reach the suppression list. |
347+| `Could not find the Cloudflare zone for git.example.com (the token needs Zone → Zone → Read on it), so diffinery-mail-events isn't subscribed ...` | bootstrap (warning) | Add Zone Read for the zone of your sending domain, or subscribe the queue by hand. |
348+| `Could not read the account's Turnstile widgets ... The token needs Account → Turnstile → Edit ...` (or `Could not create the Turnstile widget "diffinery git.example.com" ...`) | bootstrap (warning) | Add Turnstile Edit to the token and deploy again; the text at the end is Cloudflare's error. Until then, only people who are signed in can send security reports. |
349+| `TURNSTILE_SECRET goes with another Turnstile widget and couldn't be replaced, ...` | bootstrap (warning) | The widget was created again and its secret key couldn't be read or stored (the warning before it says why). This deploy leaves `TURNSTILE_SITE_KEY` unset; fix the cause and deploy again. |
350+| `TURNSTILE_SECRET is not set, so only people who are signed in can send security reports.` | ensure-secrets (warning) | Its secret key couldn't be read or stored; the warning before it says why. Add Turnstile Edit to the token and deploy again. |
337351 | `Could not find the Cloudflare zone for git.example.com (the token needs Zone Read on it).` | email check | Add Zone Read for the zone, or ignore it if you don't send email yet. |
338352 | `git.example.com is not onboarded to Email Sending, so no email will be delivered.` | email check | Onboard the domain (step 5). |
339353 | `Some Email Sending DNS records for git.example.com are missing, so mail may not be delivered.` | email check | Finish onboarding in the dashboard; the lines marked `missing` name the records. |
addedapps/diffinery/migrations/0029_security_reports.sql+21−0View file
@@ -1,0 +1,21 @@
1+-- Private security reports (lib/security-reports.ts): what people send through /security/report, the
2+-- contact security.txt names first. Only site administrators can read them. A report is new, triaged
3+-- or resolved; the audit log keeps every change.
4+CREATE TABLE IF NOT EXISTS security_reports (
5+ id TEXT PRIMARY KEY,
6+ summary TEXT NOT NULL,
7+ details TEXT NOT NULL,
8+ area TEXT, -- the affected URL or area, when given
9+ reply_to TEXT, -- where to answer: the address given, or the reporter's own
10+ account_id TEXT, -- the reporter, when signed in
11+ reporter_login TEXT,
12+ ip_hash TEXT, -- SHA-256 of the sender's IP address, for the daily limits only
13+ state TEXT NOT NULL DEFAULT 'new' CHECK (state IN ('new', 'triaged', 'resolved')),
14+ note TEXT, -- the administrator's latest note
15+ updated_by TEXT, -- the administrator's login
16+ updated_at TEXT,
17+ created_at TEXT NOT NULL
18+);
19+CREATE INDEX IF NOT EXISTS security_reports_at ON security_reports(created_at);
20+CREATE INDEX IF NOT EXISTS security_reports_ip ON security_reports(ip_hash, created_at);
21+CREATE INDEX IF NOT EXISTS security_reports_account ON security_reports(account_id, created_at);
modifiedapps/diffinery/src/app.ts+2−0View file
@@ -29,6 +29,7 @@
2929 import { mailQueue } from "./mail/send.js";
3030 import { mailEvents, mailEventsQueue } from "./mail/events.js";
3131 import { securityTxtRoutes } from "./http/security-txt.js";
32+import { securityReportsApi } from "./http/security-reports.js";
3233 import { onReviewEvent } from "./agentic/reviews.js";
3334 import { onVerifyEvent } from "./agentic/tasks.js";
3435 import { recordActivity } from "./agentic/today.js";
@@ -95,6 +96,7 @@
9596 app.route("/", git);
9697 app.route("/", runner);
9798 app.route("/", mailApi);
99+app.route("/", securityReportsApi);
98100 app.route("/", mailEvents);
99101 app.route("/", registry);
100102 app.route("/", packagesApi);
modifiedapps/diffinery/src/auth/signup.ts+2−1View file
@@ -27,7 +27,8 @@
2727 }
2828
2929 /**
30- * How sign-up checks for a person: "verify" with Turnstile, "skip" in local development
30+ * How sign-up, and a security report from someone not signed in (web/security-reports.tsx), check
31+ * for a person: "verify" with Turnstile, "skip" in local development
3132 * (PUBLIC_URL on this machine) without it, and "missing" when a deployed instance has no
3233 * Turnstile keys, which refuses sign-up rather than leave it open to scripts.
3334 */
modifiedapps/diffinery/src/docs/api-meta.ts+17−1View file
@@ -102,6 +102,9 @@
102102 "GET /diffinery/admin/email/suppressions": { scope: "read" },
103103 "PUT /diffinery/admin/email/suppressions/{email}": { scope: "admin" },
104104 "DELETE /diffinery/admin/email/suppressions/{email}": { scope: "admin" },
105+ "GET /diffinery/admin/security-reports": { scope: "read" },
106+ "GET /diffinery/admin/security-reports/{id}": { scope: "read" },
107+ "PATCH /diffinery/admin/security-reports/{id}": { scope: "admin" },
105108 "PUT /user/avatar": { scope: "write" },
106109 "DELETE /user/avatar": { scope: "write" },
107110 "PUT /orgs/{org}/avatar": { scope: "write" },
@@ -1552,9 +1555,22 @@
15521555 access: "account",
15531556 },
15541557 "GET /diffinery/admin/email": { summary: "Email delivery status", description: "Whether sending is configured, whether the signed bounce and complaint endpoint is on (`event_intake`), and counts for the last 24 hours.", access: "site-admin" },
1555- "GET /diffinery/admin/email/suppressions": { summary: "List suppressed email addresses", description: "Newest first: `email`, `reason` (`hard-bounce`, `complaint` or `manual`), `detail`, `source` (`cloudflare`, `webhook` or `admin`), `created_by` and `created_at`.", access: "site-admin" },
1558+ "GET /diffinery/admin/email/suppressions": { summary: "List suppressed email addresses", description: "Newest first: `email`, `reason` (`hard-bounce`, `complaint` or `manual`), `detail`, `source` (`cloudflare`, `webhook`, `send` or `admin`), `created_by` and `created_at`.", access: "site-admin" },
15561559 "PUT /diffinery/admin/email/suppressions/{email}": { summary: "Suppress an email address", description: "Diffinery sends nothing to it until it is removed. Recorded in the audit log.", access: "site-admin", body: { note: ["string", "Why, kept with the entry."] } },
15571560 "DELETE /diffinery/admin/email/suppressions/{email}": { summary: "Remove an address from the suppression list", description: "Whatever put it there. 404 when it isn't suppressed. Recorded in the audit log.", access: "site-admin", status: 204 },
1561+ "GET /diffinery/admin/security-reports": {
1562+ summary: "List security reports",
1563+ description: "Private vulnerability reports sent through `/security/report`, newest first: `id`, `state` (`new`, `triaged` or `resolved`), `summary`, `details`, `area`, `reply_to`, `reporter` (a login, or null when not signed in), `note`, `updated_by`, `updated_at`, `created_at` and `html_url`. 404 for anyone but a site administrator.",
1564+ access: "site-admin",
1565+ query: { state: ["string", "Only reports in this state: `new`, `triaged` or `resolved`."], per_page: ["integer", "At most this many (default 100, up to 500)."] },
1566+ },
1567+ "GET /diffinery/admin/security-reports/{id}": { summary: "Get a security report", description: "One report, as in the list. 404 when there is none, or for anyone but a site administrator.", access: "site-admin" },
1568+ "PATCH /diffinery/admin/security-reports/{id}": {
1569+ summary: "Triage or resolve a security report",
1570+ description: "Sets the report's state and note (a missing or empty note clears it). Recorded in the audit log as `security_report.triage`, `security_report.resolve` or `security_report.reopen`. 404 for anyone but a site administrator.",
1571+ access: "site-admin",
1572+ body: { state: ["string", "`triaged`, `resolved`, or `new` to reopen it.", true], note: ["string", "For the other site administrators, up to 2,000 characters."] },
1573+ },
15581574 "GET /diffinery/admin/scheduler": {
15591575 summary: "Workflow scheduler state",
15601576 description: "`tick_minutes`, `last_tick_at` (when the last check finished), `covered_until` (the last minute whose schedules were started) and `next_tick_at` (null when the timer isn't set). Tells a scheduler that stopped ticking from a run that failed to start.",
modifiedapps/diffinery/src/docs/reference.ts+2−1View file
@@ -260,7 +260,8 @@
260260 ["/v2/...", "The OCI container registry (Docker Registry HTTP API v2). See [Container registry](/docs/packages/container-registry)."],
261261 ["GET /.well-known/openid-configuration", "OIDC discovery for workflow ID tokens. See [OIDC](/docs/actions/oidc)."],
262262 ["GET /.well-known/jwks", "Public keys that sign workflow ID tokens."],
263- ["GET /.well-known/security.txt", "Where to report a vulnerability (RFC 9116), from the instance's `SECURITY_CONTACT`; 404 when it isn't set. See [Reporting a vulnerability](/docs/security/disclosure)."],
263+ ["GET /.well-known/security.txt", "Where to report a vulnerability (RFC 9116): this instance's report form first, then any `SECURITY_CONTACT` entries. See [Reporting a vulnerability](/docs/security/disclosure)."],
264+ ["GET, POST /security/report", "The private security report form: signed in, or anonymously after Cloudflare Turnstile. Only site administrators can read reports. See [Reporting a vulnerability](/docs/security/disclosure)."],
264265 ["POST /-/email/events", "Signed bounce and complaint intake for the email suppression list; 404 until `MAIL_EVENTS_SECRET` is set. See [Site administration](/docs/admin/site-administration#bounces-complaints-and-the-suppression-list)."],
265266 ["GET, POST /-/unsubscribe/{token}", "One-click unsubscribe from a notification email (RFC 8058): a POST with `List-Unsubscribe=One-Click` turns that kind of email off. See [Notifications](/docs/accounts/notifications)."],
266267 ["GET /healthz", "Health check: `{\"ok\": true}`."],
modifiedapps/diffinery/src/env.ts+8−4View file
@@ -77,8 +77,9 @@
7777 /** Sender address for every email, e.g. notifications@diffinery.com. */
7878 MAIL_FROM?: string;
7979 /**
80- * Where security researchers report vulnerabilities, served in /.well-known/security.txt: an email
81- * address or an https URL, several separated by commas. Unset, security.txt answers 404.
80+ * More places security researchers can report vulnerabilities, served in /.well-known/security.txt
81+ * after the instance's own report form (/security/report): an email address or an https URL, several
82+ * separated by commas.
8283 */
8384 SECURITY_CONTACT?: string;
8485 /** Comma-separated emails or logins that become site admins on first sign-in. */
@@ -98,7 +99,10 @@
9899 * Anything else, or unset, keeps the instance invite-only.
99100 */
100101 SIGNUP?: string;
101- /** Cloudflare Turnstile widget on the sign-up page; open sign-up needs it and TURNSTILE_SECRET outside local development. */
102+ /**
103+ * Cloudflare Turnstile widget on the sign-up page and the security report form; open sign-up and
104+ * reports from people who aren't signed in need it and TURNSTILE_SECRET outside local development.
105+ */
102106 TURNSTILE_SITE_KEY?: string;
103107
104108 /** Secret: base64 32 bytes; root key for envelope encryption of secrets. */
@@ -107,7 +111,7 @@
107111 OIDC_PRIVATE_KEY?: string;
108112 /** Secret: signs bounce and complaint events posted to /-/email/events (mail/events.ts); unset, that endpoint is off. */
109113 MAIL_EVENTS_SECRET?: string;
110- /** Secret: Cloudflare Turnstile secret key, to check the sign-up page's widget (siteverify). */
114+ /** Secret: Cloudflare Turnstile secret key, to check the widget on the sign-up page and the security report form (siteverify). */
111115 TURNSTILE_SECRET?: string;
112116 /** Optional GitHub Enterprise host (e.g. github.example.com) the importer may download release assets from. */
113117 IMPORT_GHE_HOST?: string;
addedapps/diffinery/src/http/security-reports.ts+49−0View file
@@ -1,0 +1,49 @@
1+import { Hono } from "hono";
2+import type { Env } from "../env.js";
3+import { requireAccount, requireAccountScope, type AppContext, type AppVars } from "../auth/principal.js";
4+import type { Account } from "../lib/directory.js";
5+import { REPORT_STATES, SecurityReports, changeReport, reportJson, type ReportState } from "../lib/security-reports.js";
6+import { HttpError } from "../lib/util.js";
7+
8+/**
9+ * Security reports over the REST API (lib/security-reports.ts), for site administrators only.
10+ * To anyone else these endpoints don't exist (404), like the reports themselves.
11+ */
12+export const securityReportsApi = new Hono<{ Bindings: Env; Variables: AppVars }>().basePath("/api/v3");
13+
14+const siteAdmin = (c: AppContext, scope: "read" | "admin"): Account => {
15+ const a = requireAccount(c);
16+ if (!a.site_admin || a.kind !== "user") throw new HttpError(404, "not found");
17+ requireAccountScope(c, scope);
18+ return a;
19+};
20+
21+securityReportsApi.get("/diffinery/admin/security-reports", async (c) => {
22+ siteAdmin(c, "read");
23+ const q = c.req.query("state");
24+ if (q && !REPORT_STATES.includes(q as ReportState)) throw new HttpError(422, `state must be one of ${REPORT_STATES.join(", ")}`);
25+ const n = Number(c.req.query("per_page") ?? 100);
26+ const rows = await new SecurityReports(c.env.DB).list({ state: (q as ReportState) || undefined, limit: Number.isFinite(n) ? n : 100 });
27+ return c.json(rows.map((r) => reportJson(c.env, r)));
28+});
29+
30+securityReportsApi.get("/diffinery/admin/security-reports/:id", async (c) => {
31+ siteAdmin(c, "read");
32+ const r = await new SecurityReports(c.env.DB).get(c.req.param("id"));
33+ if (!r) throw new HttpError(404, "no security report with that id");
34+ return c.json(reportJson(c.env, r));
35+});
36+
37+/** Mark a report triaged or resolved (or new again), with an optional note. Audited. */
38+securityReportsApi.patch("/diffinery/admin/security-reports/:id", async (c) => {
39+ const me = siteAdmin(c, "admin");
40+ let b: { state?: unknown; note?: unknown };
41+ try {
42+ b = (await c.req.json()) as typeof b;
43+ } catch {
44+ throw new HttpError(400, "request body must be JSON");
45+ }
46+ if (!b || typeof b !== "object") throw new HttpError(422, 'send {"state": "triaged", "note": "..."}');
47+ const r = await changeReport(c.env.DB, (e) => c.get("dir").audit(e), me, c.req.param("id"), b.state, b.note, "api");
48+ return c.json(reportJson(c.env, r));
49+});
modifiedapps/diffinery/src/http/security-txt.ts+11−11View file
@@ -4,8 +4,9 @@
44
55 /**
66 * /.well-known/security.txt (RFC 9116): where to report a vulnerability in this instance. The
7- * contact comes from SECURITY_CONTACT only; without it the file is 404 rather than an address
8- * nobody reads. Expires is a year from the request, so the file never goes stale.
7+ * first contact is always the instance's own private report form, /security/report
8+ * (web/security-reports.tsx), so the file is served without an email address; any entries of
9+ * SECURITY_CONTACT follow it. Expires is a year from the request, so the file never goes stale.
910 */
1011
1112 /** How far ahead Expires is (RFC 9116 recommends less than a year). */
@@ -23,11 +24,14 @@
2324 return out;
2425 }
2526
26-/** The file's text, or null when no contact is configured. */
27-export function securityTxt(env: Pick<Env, "PUBLIC_URL" | "SECURITY_CONTACT">, nowMs = Date.now()): string | null {
28- const contacts = contactUris(env.SECURITY_CONTACT);
29- if (!contacts.length) return null;
27+/** The private report form's URL: the first Contact. */
28+export const reportUrl = (publicUrl: string): string => `${new URL(publicUrl).origin}/security/report`;
29+
30+/** The file's text. */
31+export function securityTxt(env: Pick<Env, "PUBLIC_URL" | "SECURITY_CONTACT">, nowMs = Date.now()): string {
3032 const base = new URL(env.PUBLIC_URL).origin;
33+ const form = reportUrl(env.PUBLIC_URL);
34+ const contacts = [form, ...contactUris(env.SECURITY_CONTACT).filter((c) => c !== form)];
3135 const expires = new Date(nowMs + EXPIRES_DAYS * 86400_000);
3236 expires.setUTCHours(0, 0, 0, 0);
3337 return [
@@ -42,8 +46,4 @@
4246
4347 export const securityTxtRoutes = new Hono<{ Bindings: Env; Variables: AppVars }>();
4448
45-securityTxtRoutes.get("/.well-known/security.txt", (c) => {
46- const txt = securityTxt(c.env);
47- if (!txt) return c.text("No security contact is configured for this instance.\n", 404);
48- return c.body(txt, 200, { "content-type": "text/plain; charset=utf-8", "cache-control": "public, max-age=86400" });
49-});
49+securityTxtRoutes.get("/.well-known/security.txt", (c) => c.body(securityTxt(c.env), 200, { "content-type": "text/plain; charset=utf-8", "cache-control": "public, max-age=86400" }));
modifiedapps/diffinery/src/lib/rate-limit.ts+4−3View file
@@ -8,7 +8,8 @@
88 * the limits stop floods and scripted abuse rather than meter use exactly. A binding that isn't
99 * configured (local development) or that fails lets the request through.
1010 *
11- * - RL_AUTH: sign-in, passkey registration, invitation and sign-in links, open sign-up, per IP address.
11+ * - RL_AUTH: sign-in, passkey registration, invitation and sign-in links, open sign-up and private
12+ * security reports (/security/report), per IP address.
1213 * - RL_ANON: everything else from someone who isn't signed in, per IP address.
1314 * - RL_ACCOUNT: everything from a signed-in person, agent or personal token, per account.
1415 * - RL_CREATE: creating things other people see or that cost storage (repositories,
@@ -31,7 +32,7 @@
3132
3233 /** Paths that never count: health checks, avatars, and the Actions runtime and runners (their own credentials). */
3334 const EXEMPT = /^\/(?:healthz$|api\/avatars\/|twirp\/|_runtime\/|_apis\/|runner\/|-\/model\/)/;
34-const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/)|signup(?:$|\/))/;
35+const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/)|signup(?:$|\/)|security\/report$)/;
3536 const CREATE: Array<[method: string, path: RegExp]> = [
3637 ["POST", /^\/api\/v3\/(?:user|orgs\/[^/]+)\/repos$/],
3738 ["POST", /^\/api\/v3\/(?:user\/orgs|admin\/organizations)$/],
@@ -93,7 +94,7 @@
9394 }
9495
9596 const WHAT: Record<Bucket, string> = {
96- RL_AUTH: "sign-in attempts from your network",
97+ RL_AUTH: "sign-in attempts and security reports from your network",
9798 RL_ANON: "requests from your network without signing in",
9899 RL_ACCOUNT: "requests from your account",
99100 RL_CREATE: "new repositories, issues, pull requests and comments from your account",
addedapps/diffinery/src/lib/security-reports.ts+193−0View file
@@ -1,0 +1,193 @@
1+import type { Env } from "../env.js";
2+import type { Account, Directory } from "./directory.js";
3+import { normalEmail } from "../auth/signup.js";
4+import { enqueueMail } from "../mail/send.js";
5+import type { MailJob } from "../mail/types.js";
6+import { HttpError, newId, now, sha256 } from "./util.js";
7+
8+/**
9+ * Private security reports: the form at /security/report, which security.txt names as its first
10+ * contact, so an instance takes reports without publishing an email address. Anyone can send one:
11+ * signed in, or anonymously after Cloudflare Turnstile (the same check as open sign-up). Only site
12+ * administrators can read them, in Site administration and through the API; each new report emails
13+ * every active site administrator its summary and a link, never the details.
14+ */
15+
16+export type ReportState = "new" | "triaged" | "resolved";
17+export const REPORT_STATES: readonly ReportState[] = ["new", "triaged", "resolved"];
18+
19+/** Longest summary, details, affected area and note, in characters. */
20+export const MAX_SUMMARY = 200;
21+export const MAX_DETAILS = 20_000;
22+export const MAX_AREA = 500;
23+export const MAX_NOTE = 2_000;
24+
25+/** Reports in 24 hours: from one IP address, from one account, and from everyone not signed in together. */
26+export const REPORTS_PER_IP_PER_DAY = 5;
27+export const REPORTS_PER_ACCOUNT_PER_DAY = 10;
28+export const ANONYMOUS_REPORTS_PER_DAY = 50;
29+
30+export interface SecurityReport {
31+ id: string;
32+ summary: string;
33+ details: string;
34+ area: string | null;
35+ reply_to: string | null;
36+ account_id: string | null;
37+ reporter_login: string | null;
38+ ip_hash: string | null;
39+ state: ReportState;
40+ note: string | null;
41+ updated_by: string | null;
42+ updated_at: string | null;
43+ created_at: string;
44+}
45+
46+export interface ReportInput {
47+ summary: string;
48+ details: string;
49+ area: string | null;
50+ replyTo: string | null;
51+}
52+
53+/**
54+ * Check what someone typed: a one-line summary and the details, both required and capped (refused
55+ * when too long, never cut), an optional affected URL or area, and a reply-to address, which only
56+ * someone who isn't signed in must give: we have a signed-in person's address already.
57+ */
58+export function validateReport(raw: { summary?: unknown; details?: unknown; area?: unknown; replyTo?: unknown }, signedIn: boolean): ReportInput {
59+ const str = (v: unknown) => (typeof v === "string" ? v : "");
60+ const summary = str(raw.summary).replace(/\s+/g, " ").trim();
61+ // Line endings as the browser sent them, but no trailing blank lines.
62+ const details = str(raw.details).replace(/\r\n?/g, "\n").replace(/^\s*\n|\s+$/g, "");
63+ const area = str(raw.area).replace(/\s+/g, " ").trim();
64+ const typed = str(raw.replyTo).trim();
65+ if (!summary) throw new HttpError(422, "Write a one-line summary of the problem.");
66+ if (summary.length > MAX_SUMMARY) throw new HttpError(422, `Keep the summary to ${MAX_SUMMARY} characters; the details go below it.`);
67+ if (!details) throw new HttpError(422, "Describe the problem and how to reproduce it.");
68+ if (details.length > MAX_DETAILS) throw new HttpError(422, `Keep the details to ${MAX_DETAILS.toLocaleString("en")} characters. Offer to send logs or a longer write-up once we reply.`);
69+ if (area.length > MAX_AREA) throw new HttpError(422, `Keep the affected URL or area to ${MAX_AREA} characters.`);
70+ let replyTo: string | null = null;
71+ if (typed) {
72+ replyTo = normalEmail(typed);
73+ if (!replyTo) throw new HttpError(422, "Enter a valid email address to reply to, or leave it empty.");
74+ } else if (!signedIn) throw new HttpError(422, "Enter an email address we can reply to, or sign in.");
75+ return { summary, details, area: area || null, replyTo };
76+}
77+
78+/** Security reports in D1. */
79+export class SecurityReports {
80+ constructor(private db: D1Database) {}
81+
82+ async get(id: string): Promise<SecurityReport | null> {
83+ if (!/^[0-9a-f]{32}$/.test(id)) return null;
84+ return this.db.prepare("SELECT * FROM security_reports WHERE id = ?").bind(id).first<SecurityReport>();
85+ }
86+
87+ /** Newest first, optionally only those in one state. */
88+ async list(o: { state?: ReportState; limit?: number } = {}): Promise<SecurityReport[]> {
89+ const limit = Math.min(Math.max(o.limit ?? 100, 1), 500);
90+ const q = o.state ? this.db.prepare("SELECT * FROM security_reports WHERE state = ? ORDER BY created_at DESC LIMIT ?").bind(o.state, limit) : this.db.prepare("SELECT * FROM security_reports ORDER BY created_at DESC LIMIT ?").bind(limit);
91+ return (await q.all<SecurityReport>()).results;
92+ }
93+
94+ async counts(): Promise<Record<ReportState, number>> {
95+ const rows = (await this.db.prepare("SELECT state, count(*) AS n FROM security_reports GROUP BY state").all<{ state: ReportState; n: number }>()).results;
96+ const out: Record<ReportState, number> = { new: 0, triaged: 0, resolved: 0 };
97+ for (const r of rows) out[r.state] = r.n;
98+ return out;
99+ }
100+
101+ /** How many reports were sent in the last 24 hours from this IP address, this account, and by everyone not signed in. */
102+ async sentToday(ipHash: string, accountId: string | null): Promise<{ ip: number; account: number; anonymous: number }> {
103+ const since = new Date(Date.now() - 86400_000).toISOString();
104+ const r = await this.db
105+ .prepare(
106+ "SELECT (SELECT count(*) FROM security_reports WHERE ip_hash = ? AND created_at > ?) AS ip, (SELECT count(*) FROM security_reports WHERE account_id = ? AND created_at > ?) AS account, (SELECT count(*) FROM security_reports WHERE account_id IS NULL AND created_at > ?) AS anonymous",
107+ )
108+ .bind(ipHash, since, accountId ?? "", since, since)
109+ .first<{ ip: number; account: number; anonymous: number }>();
110+ return { ip: r?.ip ?? 0, account: r?.account ?? 0, anonymous: r?.anonymous ?? 0 };
111+ }
112+
113+ async create(r: Omit<SecurityReport, "state" | "note" | "updated_by" | "updated_at">): Promise<SecurityReport> {
114+ await this.db
115+ .prepare("INSERT INTO security_reports (id, summary, details, area, reply_to, account_id, reporter_login, ip_hash, state, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'new', ?)")
116+ .bind(r.id, r.summary, r.details, r.area, r.reply_to, r.account_id, r.reporter_login, r.ip_hash, r.created_at)
117+ .run();
118+ return { ...r, state: "new", note: null, updated_by: null, updated_at: null };
119+ }
120+
121+ /** Change a report's state and note; the report as it was before, or null when there is none. */
122+ async update(id: string, state: ReportState, note: string | null, by: string): Promise<SecurityReport | null> {
123+ const before = await this.get(id);
124+ if (!before) return null;
125+ await this.db.prepare("UPDATE security_reports SET state = ?, note = ?, updated_by = ?, updated_at = ? WHERE id = ?").bind(state, note, by, now(), id).run();
126+ return before;
127+ }
128+}
129+
130+/** The IP address as stored: only a hash, which the daily limits compare. */
131+export const ipHash = (ip: string | null | undefined): Promise<string> => sha256(`diffinery-security-report:${ip || "unknown"}`);
132+
133+type Audit = (e: { actorId?: string; actorLogin?: string; action: string; target?: string; detail?: unknown }) => Promise<void>;
134+
135+/**
136+ * Store a validated report, after the daily limits, audit it (without its content) and queue the
137+ * email to every active site administrator. `reporter` is the signed-in person, or null.
138+ */
139+export async function submitReport(env: Env, audit: Audit, input: ReportInput, reporter: Account | null, ip: string | null): Promise<SecurityReport> {
140+ const reports = new SecurityReports(env.DB);
141+ const hash = await ipHash(ip);
142+ const sent = await reports.sentToday(hash, reporter?.id ?? null);
143+ if (reporter && sent.account >= REPORTS_PER_ACCOUNT_PER_DAY) throw new HttpError(429, `You have sent ${REPORTS_PER_ACCOUNT_PER_DAY} reports today. Add to an earlier one when we reply, or try again tomorrow.`);
144+ if (!reporter && sent.ip >= REPORTS_PER_IP_PER_DAY) throw new HttpError(429, "Too many reports from your network today. Sign in, or try again tomorrow.");
145+ if (!reporter && sent.anonymous >= ANONYMOUS_REPORTS_PER_DAY) throw new HttpError(429, "This instance has taken all the reports it can today from people who aren't signed in. Sign in, or try again tomorrow.");
146+ const report = await reports.create({
147+ id: newId(),
148+ summary: input.summary,
149+ details: input.details,
150+ area: input.area,
151+ reply_to: input.replyTo ?? reporter?.email ?? null,
152+ account_id: reporter?.id ?? null,
153+ reporter_login: reporter?.login ?? null,
154+ ip_hash: hash,
155+ created_at: now(),
156+ });
157+ await audit({ actorId: reporter?.id, actorLogin: reporter?.login, action: "security_report.create", target: report.id, detail: { signed_in: !!reporter } });
158+ const admins = (await env.DB.prepare("SELECT id FROM accounts WHERE kind = 'user' AND site_admin = 1 AND status = 'active'").all<{ id: string }>()).results;
159+ await enqueueMail(env, admins.map((a): MailJob => ({ kind: "security_report", data: { id: report.id, summary: report.summary }, to: { accountId: a.id }, reason: "admin", at: report.created_at })));
160+ return report;
161+}
162+
163+/** Audit actions for a change of state. */
164+const ACTION: Record<ReportState, string> = { new: "security_report.reopen", triaged: "security_report.triage", resolved: "security_report.resolve" };
165+
166+/** Mark a report triaged or resolved (or new again), with a note; audited. 404 when there is no such report. */
167+export async function changeReport(db: D1Database, audit: Audit, me: Account, id: string, state: unknown, note: unknown, via: "web" | "api"): Promise<SecurityReport> {
168+ if (typeof state !== "string" || !REPORT_STATES.includes(state as ReportState)) throw new HttpError(422, `state must be one of ${REPORT_STATES.join(", ")}`);
169+ if (note !== undefined && note !== null && typeof note !== "string") throw new HttpError(422, "note must be a string");
170+ const text = typeof note === "string" ? note.replace(/\r\n?/g, "\n").trim() : "";
171+ if (text.length > MAX_NOTE) throw new HttpError(422, `Keep the note to ${MAX_NOTE} characters.`);
172+ const reports = new SecurityReports(db);
173+ const before = await reports.update(id, state as ReportState, text || null, me.login);
174+ if (!before) throw new HttpError(404, "no security report with that id");
175+ await audit({ actorId: me.id, actorLogin: me.login, action: ACTION[state as ReportState], target: id, detail: { from: before.state, to: state, note: text || null, via } });
176+ return (await reports.get(id))!;
177+}
178+
179+/** The API's view of a report. */
180+export const reportJson = (env: Pick<Env, "PUBLIC_URL">, r: SecurityReport) => ({
181+ id: r.id,
182+ state: r.state,
183+ summary: r.summary,
184+ details: r.details,
185+ area: r.area,
186+ reply_to: r.reply_to,
187+ reporter: r.reporter_login,
188+ note: r.note,
189+ updated_by: r.updated_by,
190+ updated_at: r.updated_at,
191+ created_at: r.created_at,
192+ html_url: `${env.PUBLIC_URL}/settings/admin/security-reports/${r.id}`,
193+});
modifiedapps/diffinery/src/mail/samples.ts+1−0View file
@@ -88,6 +88,7 @@
8888 item("email-verify", "Confirm an address after a bounce", { kind: "email_verify", reason: "account", data: { email: "diffy@example.com", url: `${pub}/settings/email/verify/sample`, expiresAt: new Date(Date.now() + 86400_000).toISOString() } }),
8989 item("signup-attempt", "Sign-up with an existing address", { kind: "signup_attempt", reason: "account", data: { email: "diffy@example.com" } }),
9090 item("access-request", "Access request", { kind: "access_request", reason: "admin", data: { email: "newcomer@example.com", login: "newcomer", note: "I'm joining the platform team next week." } }),
91+ item("security-report", "Security report", { kind: "security_report", reason: "admin", data: { id: "0123456789abcdef0123456789abcdef", summary: "Stored XSS in release notes" } }),
9192 item("org-member", "Organization membership", { kind: "org_membership", reason: "account", data: { org: repo.split("/")[0]!, role: "admin", previous: "member", by: "diffy-dev" } }),
9293 item("repo-access", "Repository access", { kind: "repo_access", reason: "account", data: { repo, role: "write", previous: null } }),
9394 item("repo-created", "Repository created", { kind: "repo_created", reason: "your_activity", data: { repo, creator: me, visibility: "private", defaultBranch: "main", description: "Marketing site" } }),
modifiedapps/diffinery/src/mail/send.ts+26−5View file
@@ -4,7 +4,7 @@
44 import { MailStore, wantsEmail } from "./prefs.js";
55 import { compose, toHtml, toText, type RenderCtx } from "./templates.js";
66 import { categoryOf, isMandatory, type MailJob } from "./types.js";
7-import { REASON_TEXT, Suppressions } from "./suppression.js";
7+import { REASON_TEXT, Suppressions, sendSuppression } from "./suppression.js";
88 import { unsubUrl } from "./unsubscribe.js";
99
1010 /**
@@ -12,7 +12,7 @@
1212 * never holds up a request, and failures retry with backoff), and the queue
1313 * consumer checks the recipient's settings, renders and sends each one through
1414 * Cloudflare Email Sending. Nothing goes to an address on the suppression list
15- * (suppression.ts).
15+ * (suppression.ts), and an address Email Sending refuses for good goes on it.
1616 */
1717
1818 /** Queue emails. Never throws: email must not break the action that caused it. */
@@ -147,20 +147,41 @@
147147 await store.log({ ...base, status: "skipped", detail: "email sending is not configured (no EMAIL binding)", message_id: null });
148148 return { status: "skipped", detail: "no EMAIL binding" };
149149 }
150+ let res: { messageId?: string };
150151 try {
151- const res = await env.EMAIL.send(msg);
152- await store.log({ ...base, status: "sent", detail: null, message_id: res.messageId ?? null });
153- return { status: "sent", messageId: res.messageId };
152+ res = await env.EMAIL.send(msg);
154153 } catch (e) {
155154 const code = (e as { code?: string }).code ?? "";
156155 const detail = `${code ? `${code}: ` : ""}${(e as Error).message ?? String(e)}`;
157156 if (RETRYABLE.has(code)) throw new RetryableMailError(detail);
157+ // A permanent failure for this recipient (it's on Cloudflare's suppression list) puts it on ours.
158+ const s = sendSuppression(to.email, { error: e });
159+ if (s) await suppressFromSend(env, s);
158160 await store.log({ ...base, status: "failed", detail, message_id: null });
159161 console.error("email failed", job.kind, to.email, detail);
160162 return { status: "failed", detail };
161163 }
164+ const bounced = sendSuppression(to.email, { result: res });
165+ if (bounced) {
166+ await suppressFromSend(env, bounced);
167+ const detail = bounced.detail ?? "permanent bounce";
168+ await store.log({ ...base, status: "failed", detail, message_id: res.messageId ?? null });
169+ return { status: "failed", detail, messageId: res.messageId };
170+ }
171+ await store.log({ ...base, status: "sent", detail: null, message_id: res.messageId ?? null });
172+ return { status: "sent", messageId: res.messageId };
162173 }
163174
175+/** Suppress an address Email Sending refused for good (source `send`), audited like any other addition. Never throws. */
176+async function suppressFromSend(env: Env, s: NonNullable<ReturnType<typeof sendSuppression>>): Promise<void> {
177+ try {
178+ await new Suppressions(env.DB).add({ ...s, source: "send" });
179+ await new Directory(env).audit({ action: "email.suppression.add", target: s.email, detail: { reason: s.reason, source: "send", detail: s.detail } });
180+ } catch (e) {
181+ console.error("could not suppress", s.email, e);
182+ }
183+}
184+
164185 /** Queue consumer for `diffinery-mail`. */
165186 export async function mailQueue(batch: MessageBatch<MailJob>, env: Env): Promise<void> {
166187 for (const msg of batch.messages) {
modifiedapps/diffinery/src/mail/suppression.ts+33−2View file
@@ -10,12 +10,13 @@
1010 * address's owner asks for on Settings → Email: following its link lifts a bounce or complaint.
1111 *
1212 * Bounces and complaints come from Cloudflare Email Sending's Event Subscriptions (a queue,
13- * mail/events.ts), or from a signed POST to /-/email/events for anything else.
13+ * mail/events.ts), from a signed POST to /-/email/events for anything else, and from Email
14+ * Sending's own answer to a send that it refuses for good (sendSuppression, used by deliver()).
1415 */
1516
1617 export type SuppressionReason = "hard-bounce" | "complaint" | "manual";
1718 export const SUPPRESSION_REASONS: readonly SuppressionReason[] = ["hard-bounce", "complaint", "manual"];
18-export type SuppressionSource = "cloudflare" | "webhook" | "admin";
19+export type SuppressionSource = "cloudflare" | "webhook" | "send" | "admin";
1920
2021 export interface Suppression {
2122 email: string;
@@ -98,6 +99,36 @@
9899 return null;
99100 }
100101
102+/**
103+ * Error codes with which Email Sending's send() refuses a recipient for good: it is on Cloudflare's
104+ * own suppression list (after a bounce or complaint there), so every later send fails the same way.
105+ * Everything else (rate limits, the daily limit, delivery and internal errors, a bad message or
106+ * sender) is about the send, not the address, and changes nothing.
107+ * https://developers.cloudflare.com/email-service/api/send-emails/workers-api/
108+ */
109+export const PERMANENT_SEND_CODES: ReadonlySet<string> = new Set(["E_RECIPIENT_SUPPRESSED"]);
110+
111+/**
112+ * What Email Sending's answer to one send means for the list: a permanent failure for the
113+ * recipient suppresses it as a hard bounce; anything else (sent, queued, temporary errors) is null.
114+ * The answer is the error send() threw (`code`, `message`), or its result: the Workers binding
115+ * returns only `messageId`, while the REST API also lists `permanent_bounces`, which counts too.
116+ */
117+export function sendSuppression(to: string, answer: { error?: unknown; result?: unknown }): { email: string; reason: "hard-bounce"; detail: string | null } | null {
118+ const email = normalAddress(to);
119+ if (!email) return null;
120+ if (answer.error && typeof answer.error === "object") {
121+ const e = answer.error as { code?: unknown; message?: unknown };
122+ const code = typeof e.code === "string" ? e.code : "";
123+ if (!PERMANENT_SEND_CODES.has(code)) return null;
124+ return { email, reason: "hard-bounce", detail: clip(`Email Sending refused it: ${code}${typeof e.message === "string" && e.message ? `: ${e.message}` : ""}`) };
125+ }
126+ const r = answer.result && typeof answer.result === "object" ? (answer.result as { permanent_bounces?: unknown }) : null;
127+ const bounced = Array.isArray(r?.permanent_bounces) ? r.permanent_bounces : [];
128+ if (bounced.some((b) => typeof b === "string" && normalAddress(b) === email)) return { email, reason: "hard-bounce", detail: "Email Sending reported a permanent bounce when sending" };
129+ return null;
130+}
131+
101132 type Audit = (e: { action: string; target?: string; detail?: unknown }) => Promise<void>;
102133
103134 /** Apply provider events to the list, auditing each address suppressed. */
modifiedapps/diffinery/src/mail/templates.ts+17−0View file
@@ -744,6 +744,23 @@
744744 ],
745745 url: `${pub}/settings/admin`,
746746 };
747+ case "security_report": {
748+ // Only the summary: the details stay in Diffinery, behind a site administrator's sign-in.
749+ const url = `${pub}/settings/admin/security-reports/${job.data.id}`;
750+ return {
751+ subject: "[Diffinery] New security report",
752+ preheader: "Someone reported a security problem. Read it in Site administration.",
753+ eyebrow: { text: "Security report", tone: "red" },
754+ title: ["New security report"],
755+ blocks: [
756+ { p: ["Someone sent a private security report through ", { a: "/security/report", href: `${pub}/security/report` }, ". Its summary, as the reporter wrote it:"] },
757+ { quote: [job.data.summary] },
758+ { button: { label: "Read the report", url } },
759+ { p: ["The details are only in Site administration → Security reports. Reply to the reporter from your own mailbox, and don't forward this email beyond the people who handle security."], muted: true },
760+ ],
761+ url,
762+ };
763+ }
747764 case "org_membership": {
748765 const d = job.data;
749766 const url = `${pub}/${d.org}`;
modifiedapps/diffinery/src/mail/types.ts+3−0View file
@@ -94,6 +94,7 @@
9494 | { kind: "signup_attempt"; data: { email: string } }
9595 | { kind: "email_verify"; data: { email: string; url: string; expiresAt: string } }
9696 | { kind: "access_request"; data: { email: string; login: string; note: string } }
97+ | { kind: "security_report"; data: { id: string; summary: string } }
9798 | { kind: "org_membership"; data: { org: string; role: string | null; previous: string | null; by?: string } }
9899 | { kind: "repo_access"; data: { repo: string; role: string | null; previous: string | null } }
99100 | { kind: "repo_created"; data: { repo: string; creator: string; visibility: string; defaultBranch: string; description: string } }
@@ -128,6 +129,8 @@
128129 case "push_blocked":
129130 case "account_status":
130131 case "site_admin":
132+ // A vulnerability report for the site administrators (lib/security-reports.ts).
133+ case "security_report":
131134 return "security";
132135 case "account_deleted":
133136 case "export_ready":
modifiedapps/diffinery/src/mail/web.tsx+1−1View file
@@ -603,7 +603,7 @@
603603 <td class="small">
604604 {x.reason}
605605 <div class="muted small">
606- {x.source === "admin" ? `by ${x.created_by ?? "an administrator"}` : `from ${x.source}`}
606+ {x.source === "admin" ? `by ${x.created_by ?? "an administrator"}` : x.source === "send" ? "Email Sending refused a send" : `from ${x.source}`}
607607 {x.detail ? ` · ${x.detail}` : ""}
608608 </div>
609609 </td>
modifiedapps/diffinery/src/web/account.tsx+5−2View file
@@ -646,9 +646,9 @@
646646
647647 // ---------------------------------------------------------------- /settings
648648
649-export type SettingsTab = "profile" | "account" | "appearance" | "security" | "tokens" | "agents" | "model-access" | "orgs" | "invitations" | "notifications" | "email-address" | "blocked" | "linked" | "admin" | "reports" | "email" | "audit" | "kill-switches" | "errors";
649+export type SettingsTab = "profile" | "account" | "appearance" | "security" | "tokens" | "agents" | "model-access" | "orgs" | "invitations" | "notifications" | "email-address" | "blocked" | "linked" | "admin" | "reports" | "email" | "audit" | "kill-switches" | "errors" | "security-reports";
650650 /** The site administration pages: they show when hosted compute is paused. */
651-export const ADMIN_TABS: readonly SettingsTab[] = ["admin", "reports", "email", "audit", "kill-switches", "errors"];
651+export const ADMIN_TABS: readonly SettingsTab[] = ["admin", "reports", "email", "audit", "kill-switches", "errors", "security-reports"];
652652
653653 export const SettingsNav: FC<{ me: Account; tab: SettingsTab }> = ({ me, tab }) => (
654654 <aside class="side vt-side">
@@ -716,6 +716,9 @@
716716 <a href="/settings/admin/kill-switches" class={tab === "kill-switches" ? "on" : ""}>
717717 Kill switches
718718 </a>
719+ <a href="/settings/admin/security-reports" class={tab === "security-reports" ? "on" : ""}>
720+ Security reports
721+ </a>
719722 <a href="/settings/audit" class={tab === "audit" ? "on" : ""}>
720723 Audit log
721724 </a>
modifiedapps/diffinery/src/web/routes.tsx+2−0View file
@@ -9,6 +9,7 @@
99 import { registerProject } from "../agentic/project-web.js";
1010 import { registerTerminals } from "../agentic/terminal-web.js";
1111 import { registerMailWeb } from "../mail/web.js";
12+import { registerSecurityReports } from "./security-reports.js";
1213 import { registerAuth } from "./auth.js";
1314 import { registerActions } from "./actions.js";
1415 import { registerCode } from "./code.js";
@@ -46,6 +47,7 @@
4647 registerAuth(web);
4748 registerPackages(web);
4849 registerMailWeb(web);
50+registerSecurityReports(web);
4951 // Before registerAccount: its /:owner catch-all would otherwise swallow /imports, /new/import and /settings/linked-accounts.
5052 registerImports(web);
5153 // Settings → Model access and /:owner/:repo/tasks: before the /:owner and /:owner/:repo catch-alls.
addedapps/diffinery/src/web/security-reports.tsx+335−0View file
@@ -1,0 +1,335 @@
1+/** @jsxImportSource hono/jsx */
2+import type { Child, FC } from "hono/jsx";
3+import { requireAccount, type AppContext } from "../auth/principal.js";
4+import { turnstileMode, verifyTurnstile } from "../auth/signup.js";
5+import type { Account } from "../lib/directory.js";
6+import { MAX_AREA, MAX_DETAILS, MAX_NOTE, MAX_SUMMARY, REPORT_STATES, SecurityReports, changeReport, submitReport, validateReport, type ReportState, type SecurityReport } from "../lib/security-reports.js";
7+import { HttpError } from "../lib/util.js";
8+import { contactUris } from "../http/security-txt.js";
9+import { SettingsNav } from "./account.js";
10+import { csrfToken } from "./csrf.js";
11+import { ComputePausedBanner } from "./kill-switches.js";
12+import { KSelect } from "./kumo.js";
13+import { Form, Time, act, post, principalAccount, prm, render, statusOf, type WebApp } from "./ui.js";
14+
15+/**
16+ * Private security reports in the web UI (lib/security-reports.ts): the report form at
17+ * /security/report, which security.txt names first, and the **Security reports** page of site
18+ * administration, the only place (with the API) where reports can be read.
19+ */
20+
21+/** Largest report form, in bytes: the details' cap with room for percent-encoded UTF-8. */
22+const MAX_FORM_BYTES = 256 * 1024;
23+
24+const STATE_TEXT: Record<ReportState, string> = { new: "New", triaged: "Triaged", resolved: "Resolved" };
25+const STATE_BADGE: Record<ReportState, string> = { new: "badge red", triaged: "badge amber", resolved: "badge green" };
26+export const StateBadge: FC<{ state: ReportState }> = ({ state }) => <span class={STATE_BADGE[state]}>{STATE_TEXT[state]}</span>;
27+
28+/** Who reports as themselves: an active person signed in. Anyone else goes through Turnstile. */
29+const reporterOf = (c: AppContext): Account | null => {
30+ const a = principalAccount(c);
31+ return a && a.kind === "user" && a.status === "active" ? a : null;
32+};
33+
34+const TURNSTILE_MISSING = "This instance has no Turnstile configured (TURNSTILE_SITE_KEY and TURNSTILE_SECRET), so it takes reports only from people who are signed in.";
35+
36+interface FormValues {
37+ summary?: string;
38+ details?: string;
39+ area?: string;
40+ replyTo?: string;
41+}
42+
43+function reportPage(c: AppContext, o: { values?: FormValues; error?: string; status?: number } = {}): Response | Promise<Response> {
44+ const me = reporterOf(c);
45+ const mode = turnstileMode(c.env);
46+ const siteKey = !me && mode === "verify" ? c.env.TURNSTILE_SITE_KEY : undefined;
47+ const closed = !me && mode === "missing";
48+ const others = contactUris(c.env.SECURITY_CONTACT);
49+ const v = o.values ?? {};
50+ return render(
51+ c,
52+ { title: "Report a security problem", status: o.status, turnstile: !!siteKey, description: "Report a security problem in this Diffinery instance privately to its site administrators." },
53+ <div style="max-width:720px;margin:32px auto">
54+ <h1>Report a security problem</h1>
55+ <p class="muted">
56+ Found a vulnerability in this Diffinery instance? Tell its site administrators privately here: only they can read what you send. Please read the <a href="/docs/security/disclosure">disclosure policy</a> first, for what is in scope and the safe harbour for good-faith research. Don't report it in a public issue.
57+ </p>
58+ {o.error ? (
59+ <div class="flash error" role="alert">
60+ {o.error}
61+ </div>
62+ ) : null}
63+ {closed ? (
64+ <div class="box">
65+ <div class="body">
66+ <p class="m0">
67+ {TURNSTILE_MISSING} <a href={`/login?next=${encodeURIComponent("/security/report")}`}>Sign in</a> to send a report
68+ {others.length ? (
69+ <>
70+ , or use {others.length === 1 ? "this contact" : "one of these contacts"}:{" "}
71+ {others.map((u, i) => (
72+ <>
73+ {i ? ", " : ""}
74+ <a href={u} rel="nofollow">
75+ {u.replace(/^mailto:/, "")}
76+ </a>
77+ </>
78+ ))}
79+ </>
80+ ) : null}
81+ .
82+ </p>
83+ </div>
84+ </div>
85+ ) : (
86+ <Form action="/security/report" t={csrfToken(c)} class="box">
87+ <div class="body">
88+ <div class="field">
89+ <label for="sr-summary">Summary</label>
90+ <input id="sr-summary" name="summary" type="text" required maxlength={MAX_SUMMARY} value={v.summary} placeholder="e.g. Stored XSS in release notes" autocomplete="off" />
91+ <div class="hint">One line. The site administrators get it by email; the details stay here.</div>
92+ </div>
93+ <div class="field">
94+ <label for="sr-details">Details</label>
95+ <textarea id="sr-details" name="details" required maxlength={MAX_DETAILS} style="min-height:220px" placeholder="What is affected, the steps or requests that show it, what you expected and what happened, and what an attacker could do with it.">
96+ {v.details ?? ""}
97+ </textarea>
98+ <div class="hint">Plain text, up to {MAX_DETAILS.toLocaleString("en")} characters. Leave out secrets and other people's data you came across; say that you saw them instead.</div>
99+ </div>
100+ <div class="field">
101+ <label for="sr-area">Affected URL or area (optional)</label>
102+ <input id="sr-area" name="area" type="text" maxlength={MAX_AREA} value={v.area} placeholder="e.g. /api/v3/repos/{owner}/{repo}/releases, git push, the MCP server" autocomplete="off" />
103+ </div>
104+ <div class="field">
105+ <label for="sr-reply">{me ? "Reply-to email (optional)" : "Reply-to email"}</label>
106+ <input id="sr-reply" name="reply_to" type="email" required={!me} maxlength={254} value={v.replyTo} autocomplete="email" />
107+ <div class="hint">{me ? <>Leave it empty and we reply to your account's address{me.email ? <> ({me.email})</> : null}.</> : <>Where we can reply. Or <a href={`/login?next=${encodeURIComponent("/security/report")}`}>sign in</a> to report as yourself.</>}</div>
108+ </div>
109+ {siteKey ? <div class="cf-turnstile" data-sitekey={siteKey} data-theme="auto" style="margin-bottom:12px"></div> : null}
110+ <button class="btn primary">Send the report</button>
111+ </div>
112+ </Form>
113+ )}
114+ </div>,
115+ );
116+}
117+
118+function sentPage(c: AppContext, r: SecurityReport): Response | Promise<Response> {
119+ return render(
120+ c,
121+ { title: "Report sent", status: 201 },
122+ <div style="max-width:720px;margin:32px auto">
123+ <h1>Thank you: your report was sent</h1>
124+ <p>
125+ The site administrators of this instance have it, and only they can read it. Your reference is <span class="mono">{r.id}</span>.
126+ </p>
127+ <p class="muted">
128+ {r.reply_to ? (
129+ <>
130+ They will reply to <b>{r.reply_to}</b>.
131+ </>
132+ ) : (
133+ <>You gave no address and your account has none, so they can't reply by email.</>
134+ )}{" "}
135+ The <a href="/docs/security/disclosure#what-to-expect">disclosure policy</a> says how quickly we aim to answer. Please keep the problem private until it is fixed.
136+ </p>
137+ </div>,
138+ );
139+}
140+
141+export function registerSecurityReports(web: WebApp): void {
142+ // ------------------------------------------------------------ the report form
143+
144+ web.get("/security/report", (c) => reportPage(c));
145+
146+ post(
147+ web,
148+ "/security/report",
149+ async (c, f) => {
150+ const values: FormValues = { summary: f.s("summary").slice(0, MAX_SUMMARY * 2), details: f.s("details").slice(0, MAX_DETAILS * 2), area: f.s("area").slice(0, MAX_AREA * 2), replyTo: f.s("reply_to").slice(0, 254) };
151+ try {
152+ const me = reporterOf(c);
153+ const ip = c.req.header("cf-connecting-ip") ?? null;
154+ if (!me) {
155+ // The same check as open sign-up: Turnstile when configured, none in local development, refused otherwise.
156+ const mode = turnstileMode(c.env);
157+ if (mode === "missing") throw new HttpError(503, TURNSTILE_MISSING);
158+ if (mode === "verify" && !(await verifyTurnstile(c.env.TURNSTILE_SECRET!, f.s("cf-turnstile-response"), ip))) throw new HttpError(403, "We couldn't confirm that you're a person. Please try again.");
159+ }
160+ const input = validateReport(values, !!me);
161+ const r = await submitReport(c.env, (e) => c.get("dir").audit(e), input, me, ip);
162+ return sentPage(c, r);
163+ } catch (e) {
164+ const s = statusOf(e);
165+ if (s.status >= 500 && s.status !== 503) throw e;
166+ return reportPage(c, { values, error: s.message, status: s.status });
167+ }
168+ },
169+ { maxBytes: MAX_FORM_BYTES },
170+ );
171+
172+ // ------------------------------------------------------------ site administration: Security reports
173+
174+ /** Reports don't exist for anyone but site administrators: 404, not 403. */
175+ const requireSiteAdmin = (c: AppContext): Account => {
176+ const me = requireAccount(c);
177+ if (!me.site_admin) throw new HttpError(404, "not found");
178+ return me;
179+ };
180+
181+ const page = (c: AppContext, me: Account, title: string, body: Child) =>
182+ render(
183+ c,
184+ { title },
185+ <div class="grid left">
186+ <SettingsNav me={me} tab="security-reports" />
187+ <div>
188+ <ComputePausedBanner env={c.env} />
189+ {body}
190+ </div>
191+ </div>,
192+ );
193+
194+ const BASE = "/settings/admin/security-reports";
195+
196+ web.get(BASE, async (c) => {
197+ const me = requireSiteAdmin(c);
198+ const q = c.req.query("state");
199+ const state = REPORT_STATES.includes(q as ReportState) ? (q as ReportState) : undefined;
200+ const reports = new SecurityReports(c.env.DB);
201+ const [rows, counts] = await Promise.all([reports.list({ state, limit: 200 }), reports.counts()]);
202+ return page(
203+ c,
204+ me,
205+ "Security reports",
206+ <>
207+ <h1>Security reports</h1>
208+ <p class="muted">
209+ Private vulnerability reports sent through <a href="/security/report">/security/report</a>, the first contact in <a href="/.well-known/security.txt">security.txt</a>. Only site administrators can see them. Each new one emails every site administrator its summary. Changes are in the <a href="/settings/audit">audit log</a>.
210+ </p>
211+ <form method="get" action={BASE} class="row mb">
212+ <KSelect
213+ name="state"
214+ label="Show"
215+ value={state ?? ""}
216+ autosubmit
217+ options={[{ value: "", label: `All (${counts.new + counts.triaged + counts.resolved})` }, ...REPORT_STATES.map((s) => ({ value: s, label: `${STATE_TEXT[s]} (${counts[s]})` }))]}
218+ />
219+ </form>
220+ <div class="box" style="overflow:auto">
221+ {rows.length ? (
222+ <table class="tbl">
223+ <thead>
224+ <tr>
225+ <th>Received</th>
226+ <th>Summary</th>
227+ <th>From</th>
228+ <th>State</th>
229+ </tr>
230+ </thead>
231+ <tbody>
232+ {rows.map((r) => (
233+ <tr>
234+ <td class="small nowrap">
235+ <Time at={r.created_at} />
236+ </td>
237+ <td>
238+ <a href={`${BASE}/${r.id}`}>{r.summary}</a>
239+ </td>
240+ <td class="small">{r.reporter_login ?? <span class="muted">not signed in</span>}</td>
241+ <td>
242+ <StateBadge state={r.state} />
243+ </td>
244+ </tr>
245+ ))}
246+ </tbody>
247+ </table>
248+ ) : (
249+ <div class="empty">{state ? `No ${STATE_TEXT[state].toLowerCase()} reports.` : "No security reports yet."}</div>
250+ )}
251+ </div>
252+ </>,
253+ );
254+ });
255+
256+ web.get(`${BASE}/:id`, async (c) => {
257+ const me = requireSiteAdmin(c);
258+ const r = await new SecurityReports(c.env.DB).get(prm(c, "id"));
259+ if (!r) throw new HttpError(404, "no security report with that id");
260+ return page(
261+ c,
262+ me,
263+ `Security report: ${r.summary}`,
264+ <>
265+ <p class="small">
266+ <a href={BASE}>← Security reports</a>
267+ </p>
268+ <h1>{r.summary}</h1>
269+ <div class="box">
270+ <div class="body">
271+ <dl class="kv m0">
272+ <dt>State</dt>
273+ <dd>
274+ <StateBadge state={r.state} />
275+ {r.updated_by ? (
276+ <span class="muted small">
277+ {" "}
278+ by {r.updated_by} <Time at={r.updated_at} />
279+ </span>
280+ ) : null}
281+ </dd>
282+ <dt>Received</dt>
283+ <dd>
284+ <Time at={r.created_at} /> <span class="muted small">{r.created_at}</span>
285+ </dd>
286+ <dt>From</dt>
287+ <dd>{r.reporter_login ? <a href={`/${r.reporter_login}`}>{r.reporter_login}</a> : <span class="muted">someone not signed in</span>}</dd>
288+ <dt>Reply to</dt>
289+ <dd>{r.reply_to ? <a href={`mailto:${r.reply_to}`}>{r.reply_to}</a> : <span class="muted">no address</span>}</dd>
290+ <dt>Affected</dt>
291+ <dd class="mono" style="overflow-wrap:anywhere">
292+ {r.area ?? <span class="muted">not given</span>}
293+ </dd>
294+ <dt>Reference</dt>
295+ <dd class="mono">{r.id}</dd>
296+ </dl>
297+ </div>
298+ </div>
299+ <h2>Details</h2>
300+ <div class="box">
301+ {/* Plain text exactly as sent: never rendered as Markdown or HTML. */}
302+ <pre class="body m0" style="white-space:pre-wrap;overflow-wrap:anywhere">
303+ {r.details}
304+ </pre>
305+ </div>
306+ <h2>Triage</h2>
307+ <Form action={`${BASE}/${r.id}`} t={csrfToken(c)} class="box mb">
308+ <div class="body">
309+ <div class="field">
310+ <label>State</label>
311+ <KSelect name="state" label="State" value={r.state === "new" ? "triaged" : r.state} options={REPORT_STATES.map((s) => ({ value: s, label: STATE_TEXT[s] }))} />
312+ </div>
313+ <div class="field">
314+ <label for="sr-note">Note</label>
315+ <textarea id="sr-note" name="note" maxlength={MAX_NOTE} style="min-height:90px" placeholder="e.g. Reproduced; fix in the next release. Replied to the reporter.">
316+ {r.note ?? ""}
317+ </textarea>
318+ <div class="hint">Only site administrators see it. Each change is in the audit log.</div>
319+ </div>
320+ <button class="btn primary">Save</button>
321+ </div>
322+ </Form>
323+ </>,
324+ );
325+ });
326+
327+ post(web, `${BASE}/:id`, async (c, f) => {
328+ const me = requireSiteAdmin(c);
329+ const id = prm(c, "id");
330+ return act(c, `${BASE}/${id}`, async () => {
331+ const r = await changeReport(c.env.DB, (e) => c.get("dir").audit(e), me, id, f.s("state"), f.s("note"), "web");
332+ return `${BASE}/${r.id}`;
333+ }, "Report updated.");
334+ });
335+}
modifiedapps/diffinery/test/e2e/security-txt.test.mjs+199−20View file
@@ -1,8 +1,12 @@
1-// End-to-end /.well-known/security.txt (RFC 9116): two real `wrangler dev` Workers, one without
2-// SECURITY_CONTACT (the file is 404, never a made-up address) and one with it (every field).
1+// End-to-end security reporting: /.well-known/security.txt (RFC 9116) and the private report form it
2+// names first, /security/report, on real `wrangler dev` Workers. One has no SECURITY_CONTACT (the file
3+// still serves, with the report form as its only contact) and no fixed dev identity, so it also covers
4+// anonymous and signed-in reports, admin-only visibility, the email to site administrators and
5+// audited triage; the other has SECURITY_CONTACT set (every field, the form first).
36 //
47 // pnpm --filter @diffinery/app exec node --test test/e2e/security-txt.test.mjs
58 import { spawn, execFileSync } from "node:child_process";
9+import { createHash, randomBytes } from "node:crypto";
610 import { mkdtempSync, rmSync } from "node:fs";
711 import { tmpdir } from "node:os";
812 import { join, dirname, resolve } from "node:path";
@@ -24,12 +28,14 @@
2428 });
2529 });
2630
27-/** Start a Worker with extra `--var`s; resolves to its base URL once /healthz answers. */
28-async function worker(name, vars) {
31+/** Start a Worker with extra `--var`s and seed SQL; resolves to its base URL once /healthz answers. */
32+async function worker(name, vars, seed = []) {
2933 const port = await freePort();
3034 const base = `http://127.0.0.1:${port}`;
3135 const persist = join(work, name);
32- execFileSync("pnpm", ["exec", "wrangler", "d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
36+ const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
37+ wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist);
38+ if (seed.length) wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", seed.join("; "));
3339 const args = ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--show-interactive-dev-session=false"];
3440 for (const [k, v] of Object.entries(vars)) args.push("--var", `${k}:${v}`);
3541 const dev = spawn("pnpm", args, { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true });
@@ -40,7 +46,7 @@
4046 const end = Date.now() + 120_000;
4147 while (Date.now() < end) {
4248 try {
43- if ((await fetch(`${base}/healthz`)).ok) return { base, stop: () => stopWorker(dev) };
49+ if ((await fetch(`${base}/healthz`)).ok) return { base, stop: () => stopWorker(dev), log: () => log };
4450 } catch {}
4551 await new Promise((r) => setTimeout(r, 500));
4652 }
@@ -58,19 +64,6 @@
5864 if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
5965 });
6066
61-test("security.txt: 404 without SECURITY_CONTACT", async () => {
62- const w = await worker("unset", {});
63- try {
64- const r = await fetch(`${w.base}/.well-known/security.txt`);
65- assert.equal(r.status, 404);
66- const body = await r.text();
67- assert.doesNotMatch(body, /Contact:/);
68- assert.match(body, /No security contact is configured/);
69- } finally {
70- w.stop();
71- }
72-});
73-
7467 test("security.txt: every RFC 9116 field with SECURITY_CONTACT set", async () => {
7568 const w = await worker("set", { SECURITY_CONTACT: "security@diffinery.test, https://diffinery.test/report" });
7669 try {
@@ -79,7 +72,8 @@
7972 assert.match(r.headers.get("content-type") ?? "", /^text\/plain; charset=utf-8/);
8073 const lines = (await r.text()).trim().split("\n");
8174 const fields = (k) => lines.filter((l) => l.startsWith(`${k}: `)).map((l) => l.slice(k.length + 2));
82- assert.deepEqual(fields("Contact"), ["mailto:security@diffinery.test", "https://diffinery.test/report"]);
75+ // The instance's own report form first, then SECURITY_CONTACT.
76+ assert.deepEqual(fields("Contact"), [`${w.base}/security/report`, "mailto:security@diffinery.test", "https://diffinery.test/report"]);
8377 assert.deepEqual(fields("Policy"), [`${w.base}/docs/security/disclosure`]);
8478 assert.deepEqual(fields("Preferred-Languages"), ["en"]);
8579 assert.deepEqual(fields("Canonical"), [`${w.base}/.well-known/security.txt`]);
@@ -98,3 +92,188 @@
9892 w.stop();
9993 }
10094 });
95+
96+// ---------------------------------------------------------------- private security reports
97+
98+const hash = (s) => createHash("sha256").update(s).digest("hex");
99+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
100+async function until(what, fn, w, timeoutMs = 60_000) {
101+ const end = Date.now() + timeoutMs;
102+ while (Date.now() < end) {
103+ const v = await fn();
104+ if (v) return v;
105+ await sleep(500);
106+ }
107+ assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${w.log().slice(-4000)}`);
108+}
109+
110+/** The Worker for the report tests, with a site administrator (root) and a person (ann); started once. */
111+let reportsWorker;
112+const T = { root: `dfy_root_${randomBytes(12).toString("hex")}`, ann: `dfy_ann_${randomBytes(12).toString("hex")}` };
113+const S = { root: `root-session-${randomBytes(12).toString("hex")}`, ann: `ann-session-${randomBytes(12).toString("hex")}` };
114+const ids = {};
115+
116+async function reports() {
117+ if (reportsWorker) return reportsWorker;
118+ const at = new Date().toISOString();
119+ const later = new Date(Date.now() + 30 * 86400_000).toISOString();
120+ const person = (id, login, email, admin) => `INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES ('${id}', '${login}', 'user', '${login}', '${email}', 'active', ${admin}, '${at}')`;
121+ const token = (id, account, t) => `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('${id}', '${account}', 'e2e', '${hash(t)}', '${t.slice(0, 10)}', '["admin"]', '${later}', '${at}')`;
122+ const session = (account, cookie) => `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(cookie)}', '${account}', 'passkey', '${at}', '${at}', '${later}')`;
123+ // No SECURITY_CONTACT, no fixed dev identity (so callers can be anonymous) and no Turnstile keys,
124+ // which a Worker on this machine skips like sign-up does.
125+ const w = await worker("reports", { DEV_AUTH: "" }, [
126+ person("u-root", "root", "root@example.com", 1),
127+ person("u-ann", "ann", "ann@example.com", 0),
128+ token("t-root", "u-root", T.root),
129+ token("t-ann", "u-ann", T.ann),
130+ session("u-root", S.root),
131+ session("u-ann", S.ann),
132+ ]);
133+ const cookieOf = (who) => (who ? `__Host-dfy_session=${S[who]}` : "");
134+ const api = async (method, path, body, tok) => {
135+ const r = await fetch(`${w.base}/api/v3${path}`, { method, headers: { "content-type": "application/json", ...(tok ? { authorization: `Bearer ${tok}` } : {}) }, body: body === undefined ? undefined : JSON.stringify(body) });
136+ const text = await r.text();
137+ let json = null;
138+ try {
139+ json = text ? JSON.parse(text) : null;
140+ } catch {
141+ json = text;
142+ }
143+ return { status: r.status, json };
144+ };
145+ const page = (path, who) => fetch(`${w.base}${path}`, { headers: { cookie: cookieOf(who) }, redirect: "manual" });
146+ /** Submit a form on `pagePath` to `action` the way a browser does: the page's CSRF pair, same-origin. */
147+ const submit = async (pagePath, action, fields, who) => {
148+ const res = await page(pagePath, who);
149+ assert.equal(res.status, 200, `GET ${pagePath} -> ${res.status}`);
150+ const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1];
151+ const pair = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
152+ assert.ok(csrf && pair, `no CSRF pair on ${pagePath}`);
153+ const r = await fetch(`${w.base}${action}`, { method: "POST", body: new URLSearchParams({ _csrf: csrf, ...fields }), redirect: "manual", headers: { cookie: [cookieOf(who), `dfy_csrf=${pair}`].filter(Boolean).join("; "), "sec-fetch-site": "same-origin" } });
154+ return { status: r.status, location: r.headers.get("location"), html: await r.text() };
155+ };
156+ reportsWorker = { ...w, api, page, submit };
157+ return reportsWorker;
158+}
159+const reference = (html) => /Your reference is <span class="mono">([0-9a-f]{32})</.exec(html)?.[1];
160+const anonSummary = `Stored XSS <script>alert(1)</script> ${randomBytes(4).toString("hex")}`;
161+const anonDetails = "Steps:\n1. Create a release\n2. <img src=x onerror=alert(1)>";
162+
163+test("security reports: security.txt names the form, and an anonymous report needs a reply-to address", async () => {
164+ const { base, page, submit, api } = await reports();
165+ // security.txt is served without SECURITY_CONTACT, and its only contact is the report form.
166+ const txt = await fetch(`${base}/.well-known/security.txt`);
167+ assert.equal(txt.status, 200);
168+ const contacts = (await txt.text()).split("\n").filter((l) => l.startsWith("Contact: "));
169+ assert.deepEqual(contacts, [`Contact: ${base}/security/report`]);
170+
171+ const form = await page("/security/report");
172+ assert.equal(form.status, 200);
173+ const formHtml = await form.text();
174+ assert.match(formHtml, /Report a security problem/);
175+ assert.match(formHtml, /name="reply_to"[^>]*required/);
176+ assert.doesNotMatch(formHtml, /cf-turnstile/, "no widget without Turnstile keys on this machine");
177+ const noReply = await submit("/security/report", "/security/report", { summary: "No address", details: "Details.", area: "", reply_to: "" });
178+ assert.equal(noReply.status, 422);
179+ assert.match(noReply.html, /Enter an email address we can reply to, or sign in/);
180+ const tooLong = await submit("/security/report", "/security/report", { summary: "Too long", details: "x".repeat(20_001), area: "", reply_to: "r@example.net" });
181+ assert.equal(tooLong.status, 422);
182+ assert.match(tooLong.html, /20,000 characters/);
183+
184+ const anon = await submit("/security/report", "/security/report", { summary: anonSummary, details: anonDetails, area: "/acme/app/releases", reply_to: "Researcher@Example.net" });
185+ assert.equal(anon.status, 201, anon.html.slice(0, 500));
186+ assert.match(anon.html, /your report was sent/);
187+ assert.match(anon.html, /researcher@example\.net/);
188+ ids.anon = reference(anon.html);
189+ assert.ok(ids.anon, "the reference is shown");
190+ const stored = await api("GET", `/diffinery/admin/security-reports/${ids.anon}`, undefined, T.root);
191+ assert.deepEqual(
192+ { ...stored.json, created_at: undefined },
193+ { id: ids.anon, state: "new", summary: anonSummary, details: anonDetails, area: "/acme/app/releases", reply_to: "researcher@example.net", reporter: null, note: null, updated_by: null, updated_at: null, created_at: undefined, html_url: `${base}/settings/admin/security-reports/${ids.anon}` },
194+ );
195+});
196+
197+test("security reports: a signed-in report replies to the account's address", async () => {
198+ const { page, submit, api } = await reports();
199+ const annForm = await (await page("/security/report", "ann")).text();
200+ assert.match(annForm, /Reply-to email \(optional\)/);
201+ assert.match(annForm, /ann@example\.com/);
202+ const signed = await submit("/security/report", "/security/report", { summary: "Token shows up in job logs", details: "A masked secret leaks when split across lines.", area: "", reply_to: "" }, "ann");
203+ assert.equal(signed.status, 201, signed.html.slice(0, 500));
204+ ids.ann = reference(signed.html);
205+ assert.ok(ids.ann);
206+ const r = await api("GET", `/diffinery/admin/security-reports/${ids.ann}`, undefined, T.root);
207+ assert.equal(r.json.reporter, "ann");
208+ assert.equal(r.json.reply_to, "ann@example.com");
209+});
210+
211+test("security reports: only site administrators can see them (404 for anyone else)", async () => {
212+ const { page, api } = await reports();
213+ assert.ok(ids.anon && ids.ann, "the reports from the tests above");
214+ for (const path of ["/settings/admin/security-reports", `/settings/admin/security-reports/${ids.anon}`]) {
215+ assert.equal((await page(path, "ann")).status, 404, `${path} is 404 for a non-admin`);
216+ const anonPage = await page(path);
217+ assert.equal(anonPage.status, 302, `${path} sends a signed-out visitor to sign in`);
218+ assert.match(anonPage.headers.get("location") ?? "", /^\/login\?next=/);
219+ }
220+ assert.equal((await api("GET", "/diffinery/admin/security-reports", undefined, T.ann)).status, 404);
221+ assert.equal((await api("GET", `/diffinery/admin/security-reports/${ids.anon}`, undefined, T.ann)).status, 404);
222+ assert.equal((await api("PATCH", `/diffinery/admin/security-reports/${ids.anon}`, { state: "resolved" }, T.ann)).status, 404);
223+ assert.equal((await api("GET", "/diffinery/admin/security-reports")).status, 401);
224+ assert.doesNotMatch(await (await page("/settings", "ann")).text(), /Security reports/);
225+
226+ const list = await api("GET", "/diffinery/admin/security-reports", undefined, T.root);
227+ assert.equal(list.status, 200, JSON.stringify(list.json));
228+ assert.deepEqual(new Set(list.json.map((r) => r.id)), new Set([ids.anon, ids.ann]));
229+ const adminList = await page("/settings/admin/security-reports", "root");
230+ assert.equal(adminList.status, 200);
231+ const adminListHtml = await adminList.text();
232+ assert.match(adminListHtml, /Token shows up in job logs/);
233+ assert.ok(!adminListHtml.includes("<script>alert(1)</script>"), "the summary is escaped");
234+ const detail = await (await page(`/settings/admin/security-reports/${ids.anon}`, "root")).text();
235+ assert.match(detail, /Steps:\n1\. Create a release/);
236+ assert.ok(detail.includes("&lt;img src=x onerror=alert(1)&gt;"), "the details are plain text");
237+ assert.match(detail, /mailto:researcher@example\.net/);
238+});
239+
240+test("security reports: each new report emails the site administrators, and only them", async () => {
241+ const w = await reports();
242+ assert.ok(ids.anon && ids.ann, "the reports from the tests above");
243+ const mails = await until(
244+ "the security report emails",
245+ async () => {
246+ const r = await w.api("GET", "/diffinery/emails?all=1&kind=security_report&per_page=50", undefined, T.root);
247+ return r.status === 200 && r.json.length >= 2 ? r.json : null;
248+ },
249+ w,
250+ );
251+ assert.equal(mails.length, 2, JSON.stringify(mails));
252+ for (const m of mails) {
253+ assert.equal(m.to_addr, "root@example.com", "only the site administrator is emailed");
254+ assert.equal(m.subject, "[Diffinery] New security report");
255+ }
256+});
257+
258+test("security reports: triage and resolve, on the web and through the API, audited", async () => {
259+ const { submit, api, page } = await reports();
260+ assert.ok(ids.anon, "the report from the tests above");
261+ const path = `/settings/admin/security-reports/${ids.anon}`;
262+ const triaged = await submit(path, path, { state: "triaged", note: "Reproduced; fixing." }, "root");
263+ assert.equal(triaged.status, 303);
264+ assert.equal(triaged.location, path);
265+ const after1 = await api("GET", `/diffinery/admin/security-reports/${ids.anon}`, undefined, T.root);
266+ assert.equal(after1.json.state, "triaged");
267+ assert.equal(after1.json.note, "Reproduced; fixing.");
268+ assert.equal(after1.json.updated_by, "root");
269+ const resolved = await api("PATCH", `/diffinery/admin/security-reports/${ids.anon}`, { state: "resolved", note: "Fixed in 2026.10.15." }, T.root);
270+ assert.equal(resolved.status, 200, JSON.stringify(resolved.json));
271+ assert.equal(resolved.json.state, "resolved");
272+ assert.equal((await api("PATCH", `/diffinery/admin/security-reports/${ids.anon}`, { state: "closed" }, T.root)).status, 422);
273+ assert.equal((await api("GET", "/diffinery/admin/security-reports?state=resolved", undefined, T.root)).json.length, 1);
274+
275+ const audit = await (await page("/settings/audit", "root")).text();
276+ for (const action of ["security_report.create", "security_report.triage", "security_report.resolve"]) assert.ok(audit.includes(action), `${action} is audited`);
277+ assert.ok(audit.includes(ids.anon), "the audit log names the report");
278+ assert.ok(!audit.includes("onerror"), "the audit log holds no report content");
279+});
addedapps/diffinery/test/unit/deploy-turnstile.test.ts+253−0View file
@@ -1,0 +1,253 @@
1+import { spawnSync } from "node:child_process";
2+import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3+import { tmpdir } from "node:os";
4+import { join } from "node:path";
5+import { afterEach, describe, expect, it } from "vitest";
6+
7+/**
8+ * scripts/turnstile.sh (run by bootstrap.sh), its use in ensure-secrets.sh, and render-config.mjs's
9+ * TURNSTILE_SITE_KEY, against a fake Cloudflare API (curl) and a fake wrangler (pnpm) on PATH.
10+ */
11+
12+const repo = new URL("../../../../", import.meta.url).pathname;
13+const SECRET = "0x4AAAAAAAsecret-key-of-the-widget";
14+
15+type Widget = { sitekey: string; secret: string; name: string; domains: string[]; mode: string };
16+type State = {
17+ /** Every Turnstile call answers "Authentication error", as for a token without Turnstile Edit. */
18+ denied?: boolean;
19+ /** Only creating widgets and reading a widget's secret fail. */
20+ readOnly?: boolean;
21+ widgets: Widget[];
22+ /** The deployed Worker's bindings, or null before the first deploy. */
23+ worker: Array<{ type: string; name: string; text?: string }> | null;
24+ /** Names wrangler secret list returns. */
25+ secrets: string[];
26+};
27+
28+// curl -sS -X METHOD URL [-H ...] [--data BODY]: answers from state.json, logs each call.
29+const FAKE_CURL = `#!/usr/bin/env node
30+const fs = require("node:fs");
31+const dir = process.env.FAKE_DIR;
32+const state = JSON.parse(fs.readFileSync(dir + "/state.json", "utf8"));
33+const a = process.argv.slice(2);
34+const method = a[a.indexOf("-X") + 1] ?? "GET";
35+const url = a.find((x) => x.startsWith("https://"));
36+const body = a.includes("--data") ? a[a.indexOf("--data") + 1] : null;
37+fs.appendFileSync(dir + "/calls.log", JSON.stringify({ method, url, body }) + "\\n");
38+const path = new URL(url).pathname.replace(/^\\/client\\/v4\\/accounts\\/acct/, "");
39+const fail = (code, message) => ({ success: false, errors: [{ code, message }], result: null });
40+let out;
41+if (path.startsWith("/challenges/widgets")) {
42+ const key = path.split("/")[3];
43+ if (state.denied) out = fail(10000, "Authentication error");
44+ else if (method === "GET" && !key) out = { success: true, result: state.widgets.map(({ secret, ...w }) => w) };
45+ else if (state.readOnly) out = fail(10000, "Authentication error");
46+ else if (method === "POST") {
47+ const w = { ...JSON.parse(body), sitekey: "0x4AAAAAAAnew", secret: "${SECRET}" };
48+ state.widgets.push(w);
49+ fs.writeFileSync(dir + "/state.json", JSON.stringify(state));
50+ out = { success: true, result: w };
51+ } else {
52+ const w = state.widgets.find((x) => x.sitekey === key);
53+ out = w ? { success: true, result: w } : fail(10404, "not found");
54+ }
55+} else if (path === "/workers/scripts/diffinery/settings") {
56+ out = state.worker ? { success: true, result: { bindings: state.worker } } : fail(10007, "This Worker does not exist on your account.");
57+} else out = fail(7003, "no route for " + path);
58+process.stdout.write(JSON.stringify(out));
59+`;
60+
61+// pnpm --dir APP exec wrangler secret (list|put NAME) ...: logs each put with what came on stdin.
62+const FAKE_PNPM = `#!/usr/bin/env node
63+const fs = require("node:fs");
64+const dir = process.env.FAKE_DIR;
65+const state = JSON.parse(fs.readFileSync(dir + "/state.json", "utf8"));
66+const a = process.argv.slice(process.argv.indexOf("wrangler") + 1);
67+if (a[0] === "secret" && a[1] === "list") process.stdout.write(JSON.stringify(state.secrets.map((name) => ({ name, type: "secret_text" }))));
68+else if (a[0] === "secret" && a[1] === "put") {
69+ const value = fs.readFileSync(0, "utf8");
70+ fs.appendFileSync(dir + "/puts.log", JSON.stringify({ name: a[2], value, args: a.slice(3) }) + "\\n");
71+ console.log("Success! Uploaded secret " + a[2]);
72+} else { console.error("fake pnpm: unexpected " + a.join(" ")); process.exit(1); }
73+`;
74+
75+let dir = "";
76+afterEach(() => {
77+ if (dir) rmSync(dir, { recursive: true, force: true });
78+ dir = "";
79+});
80+
81+function tree(state: State, wranglerVars: Record<string, string> = {}): string {
82+ dir = mkdtempSync(join(tmpdir(), "turnstile-"));
83+ for (const d of ["scripts", "apps/diffinery", "bin"]) mkdirSync(join(dir, d), { recursive: true });
84+ for (const f of ["turnstile.sh", "ensure-secrets.sh", "jsonc.mjs", "render-config.mjs"]) copyFileSync(join(repo, "scripts", f), join(dir, "scripts", f));
85+ const cfg = readFileSync(join(repo, "apps/diffinery/wrangler.jsonc"), "utf8");
86+ const vars = Object.entries(wranglerVars).map(([k, v]) => `\n ${JSON.stringify(k)}: ${JSON.stringify(v)},`).join("");
87+ writeFileSync(join(dir, "apps/diffinery/wrangler.jsonc"), cfg.replace(/"vars": \{/, `"vars": {${vars}`));
88+ writeFileSync(join(dir, "bin/curl"), FAKE_CURL);
89+ writeFileSync(join(dir, "bin/pnpm"), FAKE_PNPM);
90+ chmodSync(join(dir, "bin/curl"), 0o755);
91+ chmodSync(join(dir, "bin/pnpm"), 0o755);
92+ writeFileSync(join(dir, "state.json"), JSON.stringify(state));
93+ return dir;
94+}
95+
96+function run(cmd: string[], extraEnv: Record<string, string> = {}) {
97+ const r = spawnSync(cmd[0]!, cmd.slice(1), {
98+ cwd: dir,
99+ encoding: "utf8",
100+ env: { PATH: `${join(dir, "bin")}:${process.env.PATH}`, HOME: dir, FAKE_DIR: dir, CLOUDFLARE_API_TOKEN: "token", CLOUDFLARE_ACCOUNT_ID: "acct", DIFFINERY_DOMAIN: "git.example.com", ...extraEnv },
101+ });
102+ const lines = (f: string) => (existsSync(join(dir, f)) ? readFileSync(join(dir, f), "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)) : []);
103+ const calls = lines("calls.log") as Array<{ method: string; url: string; body: string | null }>;
104+ const puts = lines("puts.log") as Array<{ name: string; value: string; args: string[] }>;
105+ // Whatever happens, the secret key never reaches the log.
106+ expect(r.stdout + r.stderr).not.toContain(SECRET);
107+ return { status: r.status, stdout: r.stdout.trim(), stderr: r.stderr, calls, puts };
108+}
109+
110+const turnstile = () => run(["bash", join(dir, "scripts/turnstile.sh")]);
111+const widget = (sitekey = "0x4AAAAAAAold"): Widget => ({ sitekey, secret: SECRET, name: "diffinery git.example.com", domains: ["git.example.com"], mode: "managed" });
112+const PERMISSION = "Account → Turnstile → Edit";
113+
114+describe("scripts/turnstile.sh", () => {
115+ it("creates the widget when there is none, and prints its site key", () => {
116+ tree({ widgets: [{ ...widget("0x4AAAAAAAother"), name: "someone else's" }], worker: null, secrets: [] });
117+ const r = turnstile();
118+ expect(r.status).toBe(0);
119+ expect(r.stdout).toBe("0x4AAAAAAAnew");
120+ expect(r.calls.map((c) => `${c.method} ${new URL(c.url).pathname}${new URL(c.url).search}`)).toEqual([
121+ "GET /client/v4/accounts/acct/challenges/widgets?per_page=1000",
122+ "POST /client/v4/accounts/acct/challenges/widgets",
123+ "GET /client/v4/accounts/acct/workers/scripts/diffinery/settings",
124+ ]);
125+ expect(JSON.parse(r.calls[1]!.body!)).toEqual({ name: "diffinery git.example.com", domains: ["git.example.com"], mode: "managed" });
126+ // The Worker isn't deployed yet: ensure-secrets.sh sets TURNSTILE_SECRET after the deploy.
127+ expect(r.puts).toEqual([]);
128+ expect(r.stderr).toContain("ensure-secrets.sh sets TURNSTILE_SECRET");
129+ });
130+
131+ it("reuses the widget it finds by name, and leaves a secret that goes with it alone", () => {
132+ tree({ widgets: [widget()], worker: [{ type: "plain_text", name: "TURNSTILE_SITE_KEY", text: "0x4AAAAAAAold" }, { type: "secret_text", name: "TURNSTILE_SECRET" }], secrets: [] });
133+ const r = turnstile();
134+ expect(r.status).toBe(0);
135+ expect(r.stdout).toBe("0x4AAAAAAAold");
136+ expect(r.calls.some((c) => c.method === "POST")).toBe(false);
137+ expect(r.puts).toEqual([]);
138+ expect(r.stderr).not.toContain("::warning::");
139+ });
140+
141+ it("leaves a missing TURNSTILE_SECRET to ensure-secrets.sh", () => {
142+ tree({ widgets: [widget()], worker: [{ type: "plain_text", name: "TURNSTILE_SITE_KEY", text: "0x4AAAAAAAold" }], secrets: [] });
143+ const r = turnstile();
144+ expect(r.stdout).toBe("0x4AAAAAAAold");
145+ expect(r.puts).toEqual([]);
146+ });
147+
148+ it("replaces a TURNSTILE_SECRET that goes with another site key before the deploy", () => {
149+ for (const deployed of ["0x4AAAAAAAgone", undefined]) {
150+ tree({ widgets: [widget()], worker: [...(deployed ? [{ type: "plain_text", name: "TURNSTILE_SITE_KEY", text: deployed }] : []), { type: "secret_text", name: "TURNSTILE_SECRET" }], secrets: [] });
151+ const r = turnstile();
152+ expect(r.status).toBe(0);
153+ expect(r.stdout).toBe("0x4AAAAAAAold");
154+ expect(r.puts).toEqual([{ name: "TURNSTILE_SECRET", value: SECRET, args: ["--name", "diffinery"] }]);
155+ expect(r.calls.some((c) => c.method === "GET" && c.url.endsWith("/challenges/widgets/0x4AAAAAAAold"))).toBe(true);
156+ rmSync(dir, { recursive: true, force: true });
157+ }
158+ });
159+
160+ it("without the permission, warns naming it, prints no site key and exits 0", () => {
161+ tree({ denied: true, widgets: [], worker: null, secrets: [] });
162+ const r = turnstile();
163+ expect(r.status).toBe(0);
164+ expect(r.stdout).toBe("");
165+ expect(r.stderr).toContain("::warning::Could not read the account's Turnstile widgets");
166+ expect(r.stderr).toContain(PERMISSION);
167+ expect(r.stderr).toContain("10000: Authentication error");
168+ expect(r.puts).toEqual([]);
169+ });
170+
171+ it("with read-only access, warns when it can't create the widget", () => {
172+ tree({ readOnly: true, widgets: [], worker: null, secrets: [] });
173+ const r = turnstile();
174+ expect(r.status).toBe(0);
175+ expect(r.stdout).toBe("");
176+ expect(r.stderr).toContain('::warning::Could not create the Turnstile widget "diffinery git.example.com"');
177+ expect(r.stderr).toContain(PERMISSION);
178+ });
179+
180+ it("deploys no new site key when a secret that goes with another widget can't be replaced", () => {
181+ tree({ readOnly: true, widgets: [widget()], worker: [{ type: "plain_text", name: "TURNSTILE_SITE_KEY", text: "0x4AAAAAAAgone" }, { type: "secret_text", name: "TURNSTILE_SECRET" }], secrets: [] });
182+ const r = turnstile();
183+ expect(r.status).toBe(0);
184+ expect(r.stdout).toBe("");
185+ expect(r.puts).toEqual([]);
186+ expect(r.stderr).toContain("::warning::Could not read the secret key of Turnstile widget 0x4AAAAAAAold");
187+ expect(r.stderr).toContain("TURNSTILE_SECRET goes with another Turnstile widget and couldn't be replaced");
188+ });
189+
190+ it("leaves keys set in wrangler.jsonc alone", () => {
191+ tree({ widgets: [], worker: null, secrets: [] }, { TURNSTILE_SITE_KEY: "0x4AAAAAAAmine" });
192+ const r = turnstile();
193+ expect(r.status).toBe(0);
194+ expect(r.stdout).toBe("");
195+ expect(r.calls).toEqual([]);
196+ });
197+});
198+
199+describe("scripts/ensure-secrets.sh and TURNSTILE_SECRET", () => {
200+ const deployConfig = (vars: Record<string, string>) =>
201+ writeFileSync(join(dir, "apps/diffinery/wrangler.deploy.jsonc"), `// Generated by scripts/render-config.mjs; do not edit.\n${JSON.stringify({ name: "diffinery", vars })}\n`);
202+ const ensure = () => run(["bash", join(dir, "scripts/ensure-secrets.sh")]);
203+
204+ it("sets it from the deployed site key's widget when it's missing", () => {
205+ tree({ widgets: [widget()], worker: [], secrets: ["MASTER_KEY", "OIDC_PRIVATE_KEY"] });
206+ deployConfig({ TURNSTILE_SITE_KEY: "0x4AAAAAAAold" });
207+ const r = ensure();
208+ expect(r.status).toBe(0);
209+ expect(r.puts).toEqual([{ name: "TURNSTILE_SECRET", value: SECRET, args: ["-c", "wrangler.deploy.jsonc"] }]);
210+ expect(r.stdout).toContain("set TURNSTILE_SECRET");
211+ });
212+
213+ it("leaves it alone when it exists, or when no site key was deployed", () => {
214+ tree({ widgets: [widget()], worker: [], secrets: ["MASTER_KEY", "OIDC_PRIVATE_KEY", "TURNSTILE_SECRET"] });
215+ deployConfig({ TURNSTILE_SITE_KEY: "0x4AAAAAAAold" });
216+ expect(ensure().puts).toEqual([]);
217+ rmSync(dir, { recursive: true, force: true });
218+ tree({ widgets: [widget()], worker: [], secrets: ["MASTER_KEY", "OIDC_PRIVATE_KEY"] });
219+ deployConfig({});
220+ const r = ensure();
221+ expect(r.puts).toEqual([]);
222+ expect(r.calls).toEqual([]);
223+ });
224+
225+ it("warns, naming the permission, and goes on when it can't read the widget's secret", () => {
226+ tree({ denied: true, widgets: [], worker: [], secrets: ["MASTER_KEY", "OIDC_PRIVATE_KEY"] });
227+ deployConfig({ TURNSTILE_SITE_KEY: "0x4AAAAAAAold" });
228+ const r = ensure();
229+ expect(r.status).toBe(0);
230+ expect(r.puts).toEqual([]);
231+ expect(r.stderr).toContain(PERMISSION);
232+ expect(r.stderr).toContain("::warning::TURNSTILE_SECRET is not set");
233+ });
234+});
235+
236+describe("scripts/render-config.mjs and TURNSTILE_SITE_KEY", () => {
237+ const render = (env: Record<string, string>) => {
238+ const r = run(["node", join(dir, "scripts/render-config.mjs"), "--d1", "db-id", "--public-url", "https://git.example.com"], env);
239+ expect(r.status, r.stderr).toBe(0);
240+ return JSON.parse(readFileSync(join(dir, "apps/diffinery/wrangler.deploy.jsonc"), "utf8").split("\n").slice(1).join("\n")) as { vars: Record<string, string> };
241+ };
242+
243+ it("deploys the site key bootstrap passes, unless wrangler.jsonc sets its own", () => {
244+ tree({ widgets: [], worker: null, secrets: [] });
245+ expect(render({ TURNSTILE_SITE_KEY: "0x4AAAAAAAold" }).vars.TURNSTILE_SITE_KEY).toBe("0x4AAAAAAAold");
246+ expect(render({}).vars.TURNSTILE_SITE_KEY).toBeUndefined();
247+ rmSync(dir, { recursive: true, force: true });
248+ tree({ widgets: [], worker: null, secrets: [] }, { TURNSTILE_SITE_KEY: "0x4AAAAAAAmine" });
249+ expect(render({ TURNSTILE_SITE_KEY: "0x4AAAAAAAold" }).vars.TURNSTILE_SITE_KEY).toBe("0x4AAAAAAAmine");
250+ // Keys don't open sign-up: render-config never sets SIGNUP.
251+ expect(render({ TURNSTILE_SITE_KEY: "0x4AAAAAAAold" }).vars.SIGNUP).toBeUndefined();
252+ });
253+});
modifiedapps/diffinery/test/unit/docs.test.ts+1−1View file
@@ -42,7 +42,7 @@
4242 }
4343
4444 /** Paths outside /docs that pages may link to: real routes of the app. */
45-const SITE_PATHS = [/^\/$/, /^\/llms(-full)?\.txt$/, /^\/-\/cli\/diffinery\.mjs$/, /^\/settings(\/[\w/-]*)?$/, /^\/new$/, /^\/login$/, /^\/notifications$/, /^\/auth\/access$/, /^\/mcp$/, /^\/api\/v3(\/.*)?$/, /^\/\.well-known\/[\w.-]+$/, /^\/healthz$/];
45+const SITE_PATHS = [/^\/$/, /^\/llms(-full)?\.txt$/, /^\/-\/cli\/diffinery\.mjs$/, /^\/settings(\/[\w/-]*)?$/, /^\/new$/, /^\/login$/, /^\/notifications$/, /^\/auth\/access$/, /^\/mcp$/, /^\/api\/v3(\/.*)?$/, /^\/\.well-known\/[\w.-]+$/, /^\/security\/report$/, /^\/healthz$/];
4646
4747 describe("docs pages", () => {
4848 it("has every page in the navigation, in order", () => {
modifiedapps/diffinery/test/unit/rate-limit.test.ts+4−0View file
@@ -29,6 +29,10 @@
2929 expect(bucketsFor("POST", "/invite/abc/email", anon, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]);
3030 expect(bucketsFor("POST", "/signup", anon, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]);
3131 expect(bucketsFor("POST", "/signup/verify", anon, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]);
32+ // Security reports, signed in or not, count per IP address like sign-up.
33+ expect(bucketsFor("POST", "/security/report", anon, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]);
34+ expect(bucketsFor("POST", "/security/report", person, "203.0.113.9")).toEqual([["RL_AUTH", "ip:203.0.113.9"]]);
35+ expect(bucketsFor("GET", "/security/report", anon, "203.0.113.9")).toEqual([["RL_ANON", "ip:203.0.113.9"]]);
3236 expect(bucketsFor("GET", "/signup", anon, "203.0.113.9")).toEqual([["RL_ANON", "ip:203.0.113.9"]]);
3337 expect(bucketsFor("GET", "/login", anon, "203.0.113.9")).toEqual([["RL_ANON", "ip:203.0.113.9"]]);
3438 expect(bucketsFor("GET", "/api/v3/repos/acme/app", anon, "203.0.113.9")).toEqual([["RL_ANON", "ip:203.0.113.9"]]);
addedapps/diffinery/test/unit/security-reports.test.ts+114−0View file
@@ -1,0 +1,114 @@
1+import { describe, expect, it } from "vitest";
2+import type { Env } from "../../src/env.js";
3+import type { Account } from "../../src/lib/directory.js";
4+import { HttpError } from "../../src/lib/util.js";
5+import { ANONYMOUS_REPORTS_PER_DAY, MAX_DETAILS, MAX_SUMMARY, REPORTS_PER_ACCOUNT_PER_DAY, REPORTS_PER_IP_PER_DAY, SecurityReports, changeReport, reportJson, submitReport, validateReport } from "../../src/lib/security-reports.js";
6+import type { MailJob } from "../../src/mail/types.js";
7+import { testD1 } from "./stubs/d1.js";
8+
9+const at = "2026-10-01T00:00:00.000Z";
10+const PUB = "https://diffinery.example";
11+
12+/** A D1 with two site administrators (one disabled), one person, and a mail queue that records what it gets. */
13+function setup() {
14+ const DB = testD1();
15+ const add = DB.sqlite.prepare("INSERT INTO accounts (id, login, kind, email, status, site_admin, created_at) VALUES (?, ?, 'user', ?, ?, ?, ?)");
16+ add.run("u-root", "root", "root@example.com", "active", 1, at);
17+ add.run("u-old", "old", "old@example.com", "disabled", 1, at);
18+ add.run("u-ann", "ann", "ann@example.com", "active", 0, at);
19+ const queued: MailJob[] = [];
20+ const MAIL = { sendBatch: async (b: Array<{ body: MailJob }>) => void queued.push(...b.map((m) => m.body)) };
21+ const env = { DB, MAIL, PUBLIC_URL: PUB } as unknown as Env;
22+ const audits: Array<{ action: string; target?: string; actorLogin?: string; detail?: unknown }> = [];
23+ const audit = async (e: (typeof audits)[number]) => void audits.push(e);
24+ const ann = DB.sqlite.prepare("SELECT * FROM accounts WHERE id = 'u-ann'").get() as unknown as Account;
25+ const root = DB.sqlite.prepare("SELECT * FROM accounts WHERE id = 'u-root'").get() as unknown as Account;
26+ return { env, DB, queued, audits, audit, ann, root };
27+}
28+
29+const refused = (f: () => unknown, status: number, msg: RegExp) => {
30+ try {
31+ f();
32+ } catch (e) {
33+ expect(e).toBeInstanceOf(HttpError);
34+ expect((e as HttpError).status).toBe(status);
35+ expect((e as HttpError).detail).toMatch(msg);
36+ return;
37+ }
38+ throw new Error("expected it to be refused");
39+};
40+
41+describe("report validation", () => {
42+ const good = { summary: " Stored XSS\n in release notes ", details: "\n\nSteps:\r\n1. Create a release\r\n2. Open it\n\n ", area: " /acme/app/releases ", replyTo: " Researcher@Example.net " };
43+
44+ it("keeps a one-line summary, the details' lines, the area and a lower-cased reply-to", () => {
45+ expect(validateReport(good, false)).toEqual({ summary: "Stored XSS in release notes", details: "Steps:\n1. Create a release\n2. Open it", area: "/acme/app/releases", replyTo: "researcher@example.net" });
46+ expect(validateReport({ ...good, area: " ", replyTo: "" }, true)).toMatchObject({ area: null, replyTo: null });
47+ });
48+
49+ it("needs a summary and details, refuses too much rather than cutting it, and checks the address", () => {
50+ refused(() => validateReport({ ...good, summary: " \n " }, true), 422, /summary/);
51+ refused(() => validateReport({ ...good, details: "\n \n" }, true), 422, /Describe the problem/);
52+ refused(() => validateReport({ ...good, summary: "x".repeat(MAX_SUMMARY + 1) }, true), 422, /200 characters/);
53+ expect(validateReport({ ...good, summary: "x".repeat(MAX_SUMMARY) }, true).summary).toHaveLength(MAX_SUMMARY);
54+ refused(() => validateReport({ ...good, details: "x".repeat(MAX_DETAILS + 1) }, true), 422, /20,000 characters/);
55+ expect(validateReport({ ...good, details: "x".repeat(MAX_DETAILS) }, true).details).toHaveLength(MAX_DETAILS);
56+ refused(() => validateReport({ ...good, area: "x".repeat(501) }, true), 422, /500 characters/);
57+ refused(() => validateReport({ ...good, replyTo: "not an address" }, true), 422, /valid email address/);
58+ refused(() => validateReport({ ...good, summary: 42, details: ["x"] }, true), 422, /summary/);
59+ });
60+
61+ it("asks for a reply-to address only from someone who isn't signed in", () => {
62+ refused(() => validateReport({ ...good, replyTo: "" }, false), 422, /reply to, or sign in/);
63+ expect(validateReport({ ...good, replyTo: "" }, true).replyTo).toBeNull();
64+ });
65+});
66+
67+describe("sending and triaging reports", () => {
68+ const input = validateReport({ summary: "Token leak in logs", details: "Tokens show up in job logs.", area: "", replyTo: "" }, true);
69+
70+ it("stores a report, audits it without its content and emails each active site administrator only the summary", async () => {
71+ const { env, queued, audits, audit, ann } = setup();
72+ const r = await submitReport(env, audit, input, ann, "203.0.113.9");
73+ expect(r).toMatchObject({ summary: "Token leak in logs", reply_to: "ann@example.com", account_id: "u-ann", reporter_login: "ann", state: "new" });
74+ expect(r.ip_hash).toMatch(/^[0-9a-f]{64}$/);
75+ expect(await new SecurityReports(env.DB).get(r.id)).toMatchObject({ id: r.id, details: "Tokens show up in job logs." });
76+ expect(audits).toEqual([{ actorId: "u-ann", actorLogin: "ann", action: "security_report.create", target: r.id, detail: { signed_in: true } }]);
77+ expect(queued).toEqual([{ kind: "security_report", data: { id: r.id, summary: "Token leak in logs" }, to: { accountId: "u-root" }, reason: "admin", at: r.created_at }]);
78+ expect(JSON.stringify(queued)).not.toContain("job logs");
79+ expect(reportJson(env, r).html_url).toBe(`${PUB}/settings/admin/security-reports/${r.id}`);
80+ });
81+
82+ it("limits reports per day: per IP address for anyone not signed in, per account, and for everyone not signed in together", async () => {
83+ const { env, audit, ann } = setup();
84+ const anon = { ...input, replyTo: "x@example.net" };
85+ for (let i = 0; i < REPORTS_PER_IP_PER_DAY; i++) await submitReport(env, audit, anon, null, "203.0.113.9");
86+ await expect(submitReport(env, audit, anon, null, "203.0.113.9")).rejects.toMatchObject({ status: 429 });
87+ // Another network, or signed in from the same one, still works.
88+ await submitReport(env, audit, anon, null, "198.51.100.7");
89+ for (let i = 0; i < REPORTS_PER_ACCOUNT_PER_DAY; i++) await submitReport(env, audit, input, ann, "203.0.113.9");
90+ await expect(submitReport(env, audit, input, ann, "198.51.100.8")).rejects.toMatchObject({ status: 429 });
91+ // Everyone not signed in, together.
92+ const fill = env.DB.sqlite.prepare("INSERT INTO security_reports (id, summary, details, ip_hash, created_at) VALUES (lower(hex(randomblob(16))), 's', 'd', ?, ?)");
93+ for (let i = 0; i < ANONYMOUS_REPORTS_PER_DAY; i++) fill.run(`h${i}`, new Date().toISOString());
94+ await expect(submitReport(env, audit, anon, null, "192.0.2.1")).rejects.toMatchObject({ status: 429 });
95+ });
96+
97+ it("marks a report triaged or resolved with a note, audited; refuses junk", async () => {
98+ const { env, audits, audit, ann, root } = setup();
99+ const r = await submitReport(env, audit, input, ann, null);
100+ audits.length = 0;
101+ const t = await changeReport(env.DB, audit, root, r.id, "triaged", " Reproduced.\r\nFix next week. ", "web");
102+ expect(t).toMatchObject({ state: "triaged", note: "Reproduced.\nFix next week.", updated_by: "root" });
103+ const done = await changeReport(env.DB, audit, root, r.id, "resolved", undefined, "api");
104+ expect(done).toMatchObject({ state: "resolved", note: null });
105+ expect(audits).toEqual([
106+ { actorId: "u-root", actorLogin: "root", action: "security_report.triage", target: r.id, detail: { from: "new", to: "triaged", note: "Reproduced.\nFix next week.", via: "web" } },
107+ { actorId: "u-root", actorLogin: "root", action: "security_report.resolve", target: r.id, detail: { from: "triaged", to: "resolved", note: null, via: "api" } },
108+ ]);
109+ await expect(changeReport(env.DB, audit, root, r.id, "closed", "", "api")).rejects.toMatchObject({ status: 422 });
110+ await expect(changeReport(env.DB, audit, root, r.id, "triaged", 7, "api")).rejects.toMatchObject({ status: 422 });
111+ await expect(changeReport(env.DB, audit, root, "0".repeat(32), "triaged", "", "api")).rejects.toMatchObject({ status: 404 });
112+ expect(await new SecurityReports(env.DB).counts()).toEqual({ new: 0, triaged: 0, resolved: 1 });
113+ });
114+});
modifiedapps/diffinery/test/unit/signup.test.ts+8−0View file
@@ -33,6 +33,14 @@
3333 for (const v of [undefined, "", "closed", "OPEN", "true", "1"]) expect(signupOpen({ SIGNUP: v }), String(v)).toBe(false);
3434 });
3535
36+ it("stays closed when the deploy sets Turnstile keys for the security report form", () => {
37+ // Every deploy sets TURNSTILE_SITE_KEY and TURNSTILE_SECRET (scripts/turnstile.sh); only SIGNUP=open opens sign-up.
38+ const deployed = { PUBLIC_URL: "https://diffinery.example", TURNSTILE_SITE_KEY: "0x4AAAAAAAsite", TURNSTILE_SECRET: "secret" } as Env;
39+ expect(turnstileMode(deployed)).toBe("verify");
40+ expect(signupOpen(deployed)).toBe(false);
41+ expect(signupOpen({ ...deployed, SIGNUP: "open" })).toBe(true);
42+ });
43+
3644 it("needs both Turnstile keys, except in local development", () => {
3745 expect(turnstileMode({ PUBLIC_URL: "https://diffinery.example", TURNSTILE_SITE_KEY: "site", TURNSTILE_SECRET: "secret" })).toBe("verify");
3846 expect(turnstileMode({ PUBLIC_URL: "https://diffinery.example", TURNSTILE_SITE_KEY: "site" })).toBe("missing");
modifiedapps/diffinery/test/unit/suppression.test.ts+62−8View file
@@ -1,8 +1,8 @@
11 import { describe, expect, it } from "vitest";
22 import type { Env } from "../../src/env.js";
33 import { contactUris, securityTxt } from "../../src/http/security-txt.js";
4-import { deliver } from "../../src/mail/send.js";
5-import { checkSignature, readVerifyToken, recordMailEvents, signEvents, suppressionFor, Suppressions, verifyToken } from "../../src/mail/suppression.js";
4+import { deliver, RetryableMailError } from "../../src/mail/send.js";
5+import { checkSignature, readVerifyToken, recordMailEvents, sendSuppression, signEvents, suppressionFor, Suppressions, verifyToken } from "../../src/mail/suppression.js";
66 import type { MailJob } from "../../src/mail/types.js";
77 import { testD1 } from "./stubs/d1.js";
88
@@ -86,6 +86,51 @@
8686 });
8787 });
8888
89+describe("Email Sending's answer to a send", () => {
90+ const err = (code: string, message = "nope") => Object.assign(new Error(message), { code });
91+
92+ it("suppresses a recipient refused for good, and nothing for a temporary failure", () => {
93+ expect(sendSuppression("Ann@Example.com", { error: err("E_RECIPIENT_SUPPRESSED", "recipient is suppressed") })).toEqual({ email: "ann@example.com", reason: "hard-bounce", detail: "Email Sending refused it: E_RECIPIENT_SUPPRESSED: recipient is suppressed" });
94+ // The REST API's shape lists permanent bounces per recipient.
95+ expect(sendSuppression("ann@example.com", { result: { delivered: [], permanent_bounces: ["ANN@example.com"], queued: [] } })).toEqual({ email: "ann@example.com", reason: "hard-bounce", detail: "Email Sending reported a permanent bounce when sending" });
96+ for (const code of ["E_RATE_LIMIT_EXCEEDED", "E_DAILY_LIMIT_EXCEEDED", "E_DELIVERY_FAILED", "E_INTERNAL_SERVER_ERROR", "E_SENDER_NOT_VERIFIED", "E_RECIPIENT_NOT_ALLOWED", "E_VALIDATION_ERROR", ""]) {
97+ expect(sendSuppression("ann@example.com", { error: err(code) }), code).toBeNull();
98+ }
99+ expect(sendSuppression("ann@example.com", { error: new Error("socket hang up") })).toBeNull();
100+ expect(sendSuppression("ann@example.com", { result: { messageId: "m1" } })).toBeNull();
101+ expect(sendSuppression("ann@example.com", { result: { delivered: [], permanent_bounces: ["bob@example.com"], queued: ["ann@example.com"] } })).toBeNull();
102+ });
103+
104+ it("puts a refused recipient on the list (source send) and audits it; temporary failures change nothing", async () => {
105+ const { env, log } = setup();
106+ let fail: Error | null = err("E_RECIPIENT_SUPPRESSED", "recipient is suppressed");
107+ (env as unknown as { EMAIL: unknown }).EMAIL = {
108+ send: async () => {
109+ if (fail) throw fail;
110+ return { messageId: "m1" };
111+ },
112+ };
113+ const r = await deliver(env, job());
114+ expect(r).toEqual({ status: "failed", detail: "E_RECIPIENT_SUPPRESSED: recipient is suppressed" });
115+ expect(await new Suppressions(env.DB).get("ann@example.com")).toMatchObject({ reason: "hard-bounce", source: "send", detail: "Email Sending refused it: E_RECIPIENT_SUPPRESSED: recipient is suppressed" });
116+ const audit = env.DB.sqlite.prepare("SELECT action, target, detail FROM audit_log").all() as Array<{ action: string; target: string; detail: string }>;
117+ expect(audit).toEqual([{ action: "email.suppression.add", target: "ann@example.com", detail: JSON.stringify({ reason: "hard-bounce", source: "send", detail: "Email Sending refused it: E_RECIPIENT_SUPPRESSED: recipient is suppressed" }) }]);
118+ expect(log().at(-1)).toMatchObject({ status: "failed" });
119+ // The next email to it is skipped by our own list.
120+ expect((await deliver(env, job())).status).toBe("skipped");
121+
122+ // Temporary failures (retried, or failed for this send only) leave another address alone.
123+ const bob = { kind: "test", data: { by: "ann" }, to: { email: "bob@example.com" }, reason: "account", at } as MailJob;
124+ fail = err("E_RATE_LIMIT_EXCEEDED");
125+ await expect(deliver(env, bob)).rejects.toBeInstanceOf(RetryableMailError);
126+ fail = err("E_DAILY_LIMIT_EXCEEDED");
127+ expect((await deliver(env, bob)).status).toBe("failed");
128+ expect(await new Suppressions(env.DB).get("bob@example.com")).toBeNull();
129+ fail = null;
130+ expect((await deliver(env, bob)).status).toBe("sent");
131+ });
132+});
133+
89134 describe("the signed intake", () => {
90135 const body = JSON.stringify({ type: "message.bounced", payload: { recipient: "a@example.net" } });
91136
@@ -119,16 +164,25 @@
119164 });
120165
121166 describe("security.txt", () => {
122- it("is not served without a contact", () => {
123- expect(securityTxt({ PUBLIC_URL: PUB })).toBeNull();
124- expect(securityTxt({ PUBLIC_URL: PUB, SECURITY_CONTACT: " " })).toBeNull();
125- expect(securityTxt({ PUBLIC_URL: PUB, SECURITY_CONTACT: "not a contact" })).toBeNull();
167+ it("is always served, with the instance's own report form as the only contact when nothing else is set", () => {
168+ const now = Date.parse("2026-10-14T15:30:00Z");
169+ for (const SECURITY_CONTACT of [undefined, " ", "not a contact", `${PUB}/security/report`]) {
170+ expect(securityTxt({ PUBLIC_URL: PUB, SECURITY_CONTACT }, now).split("\n")).toEqual([
171+ `Contact: ${PUB}/security/report`,
172+ "Expires: 2027-10-09T00:00:00Z",
173+ `Policy: ${PUB}/docs/security/disclosure`,
174+ "Preferred-Languages: en",
175+ `Canonical: ${PUB}/.well-known/security.txt`,
176+ "",
177+ ]);
178+ }
126179 });
127180
128- it("has every RFC 9116 field, with Expires about a year ahead", () => {
181+ it("has every RFC 9116 field, the report form first and SECURITY_CONTACT after it, with Expires about a year ahead", () => {
129182 const now = Date.parse("2026-10-14T15:30:00Z");
130- const txt = securityTxt({ PUBLIC_URL: `${PUB}/`, SECURITY_CONTACT: "security@diffinery.example, https://diffinery.example/report" }, now)!;
183+ const txt = securityTxt({ PUBLIC_URL: `${PUB}/`, SECURITY_CONTACT: "security@diffinery.example, https://diffinery.example/report" }, now);
131184 expect(txt.split("\n")).toEqual([
185+ `Contact: ${PUB}/security/report`,
132186 "Contact: mailto:security@diffinery.example",
133187 "Contact: https://diffinery.example/report",
134188 "Expires: 2027-10-09T00:00:00Z",
modifiedscripts/bootstrap.sh+81−2View file
@@ -2,7 +2,9 @@
22 # Idempotent production setup for Diffinery on a Cloudflare account.
33 #
44 # Creates (only when missing): the D1 database, R2 bucket, event and mail queues, the
5-# Email Sending setup for the domain (scripts/email-sending.sh) and the
5+# subscription that sends Email Sending's bounces and complaints to the diffinery-mail-events
6+# queue, the Email Sending setup for the domain (scripts/email-sending.sh), the Turnstile
7+# widget for the security report form (scripts/turnstile.sh) and the
68 # Cloudflare Access application for /auth/access, the email-based account
79 # recovery path. Everyday sign-in uses passkeys; git, the API, MCP and runners
810 # use Diffinery tokens.
@@ -95,10 +97,87 @@
9597 done
9698 endlog
9799
100+# ------------------------------------------------------------------ Email Sending events
101+# Bounces and complaints reach the Worker through a Queues event subscription: source email.sending
102+# (one per sending domain), events message.bounced and message.complained, destination the
103+# diffinery-mail-events queue. Created only when missing; one that lacks an event gets it added.
104+# Create: POST /accounts/{account_id}/event_subscriptions/subscriptions
105+# https://developers.cloudflare.com/api/resources/queues/subresources/subscriptions/methods/create/
106+# List: GET /accounts/{account_id}/event_subscriptions/subscriptions?queue_id=...
107+# Update: PATCH /accounts/{account_id}/event_subscriptions/subscriptions/{id}
108+# Source and event names: https://developers.cloudflare.com/queues/event-subscriptions/events-schemas/
109+# and https://developers.cloudflare.com/email-service/platform/event-subscriptions/. The email.sending
110+# source takes zone_id and domain, as `wrangler queues subscription create --source email.sending
111+# --zone-id ... --domain ...` sends them (https://developers.cloudflare.com/workers/wrangler/commands/queues/).
112+# The token needs Account → Queues → Edit (the API reference's "Queues Write") for this, and
113+# Zone → Zone → Read on the sending domain to find its zone. Without them this warns and the deploy
114+# goes on: bounces and complaints then don't reach the suppression list until someone subscribes the
115+# queue in the dashboard (docs/admin/site-administration.md).
116+log "Email Sending event subscription"
117+SEND_DOMAIN="${MAIL_DOMAIN:-$DOMAIN}"
118+SUB_EVENTS='["message.bounced","message.complained"]'
119+SUB_PERMISSION="Account → Queues → Edit (\"Queues Write\" in the API reference)"
120+api_errors() { jq -r '[.errors[]? | "\(.code): \(.message)"] | join("; ")' <<<"$1" 2>/dev/null || echo "$1"; }
121+subscribe_mail_events() {
122+ local qid zone="" name="$SEND_DOMAIN" list existing id body res
123+ qid="$(cf GET "/queues?per_page=100" | jq -r '.result[]? | select(.queue_name=="diffinery-mail-events") | .queue_id' | head -n1 || true)"
124+ if [[ -z "$qid" ]]; then warn "Could not find the diffinery-mail-events queue, so it isn't subscribed to Email Sending's bounce and complaint events."; return; fi
125+ # The zone that holds the sending domain (the domain itself or a parent), as scripts/email-sending.sh finds it.
126+ while [[ "$name" == *.* ]]; do
127+ zone="$(curl -sS "https://api.cloudflare.com/client/v4/zones?name=$name" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq -r '.result[0].id // empty' || true)"
128+ [[ -n "$zone" ]] && break
129+ name="${name#*.}"
130+ done
131+ if [[ -z "$zone" ]]; then warn "Could not find the Cloudflare zone for $SEND_DOMAIN (the token needs Zone → Zone → Read on it), so diffinery-mail-events isn't subscribed to Email Sending's bounce and complaint events. Subscribe it in the dashboard: see Site administration → Email in the docs."; return; fi
132+ list="$(cf GET "/event_subscriptions/subscriptions?queue_id=$qid&per_page=100" || true)"
133+ if ! jq -e '.success' >/dev/null 2>&1 <<<"$list"; then
134+ warn "Could not read the queue's event subscriptions, so diffinery-mail-events may not get bounces and complaints. The token needs $SUB_PERMISSION. Cloudflare said: $(api_errors "$list")"
135+ return
136+ fi
137+ existing="$(jq -c --arg d "$SEND_DOMAIN" --arg q "$qid" '[.result[]? | select(.source.type == "email.sending" and .source.domain == $d and (.destination.queue_id // $q) == $q)][0] // empty' <<<"$list")"
138+ if [[ -n "$existing" ]]; then
139+ id="$(jq -r '.id' <<<"$existing")"
140+ if jq -e --argjson want "$SUB_EVENTS" '.enabled != false and ((.events // []) as $have | all($want[]; . as $e | $have | index($e)))' >/dev/null <<<"$existing"; then
141+ echo "event subscription $id sends $SEND_DOMAIN's bounces and complaints to diffinery-mail-events"
142+ return
143+ fi
144+ body="$(jq -c --argjson want "$SUB_EVENTS" '{events: ((.events // []) + $want | unique), enabled: true}' <<<"$existing")"
145+ res="$(cf PATCH "/event_subscriptions/subscriptions/$id" "$body" || true)"
146+ if jq -e '.success' >/dev/null 2>&1 <<<"$res"; then echo "event subscription $id now includes bounced and complained events"; else warn "Could not update event subscription $id. The token needs $SUB_PERMISSION. Cloudflare said: $(api_errors "$res")"; fi
147+ return
148+ fi
149+ body="$(jq -nc --arg d "$SEND_DOMAIN" --arg z "$zone" --arg q "$qid" --argjson events "$SUB_EVENTS" '{
150+ name: ("diffinery-mail-events " + $d), enabled: true,
151+ source: {type: "email.sending", zone_id: $z, domain: $d},
152+ destination: {type: "queues.queue", queue_id: $q},
153+ events: $events
154+ }')"
155+ res="$(cf POST /event_subscriptions/subscriptions "$body" || true)"
156+ if jq -e '.success' >/dev/null 2>&1 <<<"$res"; then
157+ echo "created event subscription $(jq -r '.result.id' <<<"$res"): $SEND_DOMAIN's bounces and complaints go to diffinery-mail-events"
158+ else
159+ warn "Could not subscribe diffinery-mail-events to Email Sending's bounce and complaint events for $SEND_DOMAIN. The token needs $SUB_PERMISSION (and $SEND_DOMAIN onboarded to Email Sending). Cloudflare said: $(api_errors "$res")"
160+ fi
161+}
162+subscribe_mail_events
163+endlog
164+
98165 # ------------------------------------------------------------------ Email Sending
99166 # notifications@<MAIL_DOMAIN or DIFFINERY_DOMAIN>; warns (and email is skipped) until the domain is onboarded.
100167 DIFFINERY_DOMAIN="$DOMAIN" "$ROOT/scripts/email-sending.sh" || warn "Email Sending check failed; email may not be delivered."
101168
169+# ------------------------------------------------------------------ Turnstile
170+# The widget "diffinery <DIFFINERY_DOMAIN>", created when missing, so people who aren't signed in can
171+# send security reports (security.txt points researchers at the form). Its site key is deployed as the
172+# TURNSTILE_SITE_KEY var (render-config below); ensure-secrets.sh sets TURNSTILE_SECRET when it's missing,
173+# and turnstile.sh replaces it here, before the deploy, when it goes with another widget. Needs
174+# Account → Turnstile → Edit; without it turnstile.sh warns, naming it, and the deploy goes on with
175+# anonymous reports refused. Sign-up stays as SIGNUP says: keys alone don't open it.
176+log "Turnstile"
177+TURNSTILE_SITE_KEY="$(DIFFINERY_DOMAIN="$DOMAIN" "$ROOT/scripts/turnstile.sh")" || { warn "scripts/turnstile.sh failed; anonymous security reports stay refused."; TURNSTILE_SITE_KEY=""; }
178+echo "site key: ${TURNSTILE_SITE_KEY:-none}"
179+endlog
180+
102181 # ------------------------------------------------------------------ Access
103182 log "Cloudflare Access"
104183 TEAM="${ACCESS_TEAM_DOMAIN:-}"
@@ -144,7 +223,7 @@
144223
145224 # ------------------------------------------------------------------ config + migrations
146225 log "Render config and migrate D1"
147-node "$ROOT/scripts/render-config.mjs" --d1 "$D1_ID" ${TEAM:+--team "$TEAM"} ${AUD:+--aud "$AUD"} --public-url "https://$DOMAIN" ${DIFFINERY_ZONE:+--zone "$DIFFINERY_ZONE"} --mail-from "notifications@${MAIL_DOMAIN:-$DOMAIN}"
226+TURNSTILE_SITE_KEY="$TURNSTILE_SITE_KEY" node "$ROOT/scripts/render-config.mjs" --d1 "$D1_ID" ${TEAM:+--team "$TEAM"} ${AUD:+--aud "$AUD"} --public-url "https://$DOMAIN" ${DIFFINERY_ZONE:+--zone "$DIFFINERY_ZONE"} --mail-from "notifications@${MAIL_DOMAIN:-$DOMAIN}"
148227 "${WRANGLER[@]}" d1 migrations apply diffinery --remote -c wrangler.deploy.jsonc
149228 endlog
150229
modifiedscripts/diffinery-wait-runs.sh+9−0View file
@@ -52,6 +52,15 @@
5252 jq -r 'def secs: try ((.completed_at | fromdateiso8601) - (.started_at | fromdateiso8601)) catch null;
5353 .jobs[] | " \(.name): \(.conclusion)\(if .failure then " - " + (.failure.message // "") else "" end)\(if secs then " (\(secs)s)" else "" end)\(if .runner.image then " on \(.runner.image)" else "" end)",
5454 (.steps[]? | select((secs // 0) > 20) | " \(secs)s \(.name)")' <<<"$jobs"
55+ # A deploy that went on past a problem says so in warnings (the token missing a permission the
56+ # bootstrap needs, say): repeat them here, where they can be read without access to Diffinery.
57+ if [ "$(jq -r .name <<<"$run")" = Deploy ]; then
58+ for job in $(jq -r '.jobs[] | select(.conclusion != "skipped") | .id' <<<"$jobs"); do
59+ api "/actions/jobs/$job/logs" 2>/dev/null | sed -nE 's/^.*(##\[warning\]|::warning[^:]*::)//p' | head -n 40 | while IFS= read -r w; do
60+ echo "::warning::Diffinery Deploy: $w"
61+ done || true
62+ done
63+ fi
5564 # A skipped run (every job's `if` was false) is fine; a skipped Deploy after a failed CI
5665 # still fails the sync through CI.
5766 if ! grep -qxE 'success|skipped' <<<"$(jq -r .conclusion <<<"$run")"; then
modifiedscripts/ensure-secrets.sh+16−2View file
@@ -2,9 +2,13 @@
22 # Generate the Worker's own secrets once (never rotated automatically):
33 # MASTER_KEY root key for encrypting Actions secrets
44 # OIDC_PRIVATE_KEY ES256 key that signs workflow OIDC id tokens
5-# Values are created on the runner and sent straight to Cloudflare; they are never printed.
5+# and, when missing, sets TURNSTILE_SECRET from the Turnstile widget whose site key was deployed
6+# (scripts/turnstile.sh, through bootstrap and render-config).
7+# Values are created on the runner (or read from Cloudflare's API) and sent straight to Cloudflare;
8+# they are never printed.
69 set -euo pipefail
7-APP="$(cd "$(dirname "$0")/../apps/diffinery" && pwd)"
10+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
11+APP="$ROOT/apps/diffinery"
812 WRANGLER=(pnpm --dir "$APP" exec wrangler)
913
1014 # Fail closed: if listing fails we must not mistake "unknown" for "missing" and
@@ -27,3 +31,13 @@
2731 ' | "${WRANGLER[@]}" secret put OIDC_PRIVATE_KEY -c wrangler.deploy.jsonc >/dev/null
2832 echo "set OIDC_PRIVATE_KEY"
2933 fi
34+# Without the Turnstile permission this warns (scripts/turnstile.sh names it) and the deploy goes on:
35+# people who aren't signed in can't send security reports until it's set.
36+SITE_KEY="$(tail -n +2 "$APP/wrangler.deploy.jsonc" | jq -r '.vars.TURNSTILE_SITE_KEY // empty')"
37+if [[ -n "$SITE_KEY" ]] && ! grep -qx TURNSTILE_SECRET <<<"$EXISTING"; then
38+ if "$ROOT/scripts/turnstile.sh" secret "$SITE_KEY" -c wrangler.deploy.jsonc; then
39+ echo "set TURNSTILE_SECRET"
40+ else
41+ echo "::warning::TURNSTILE_SECRET is not set, so only people who are signed in can send security reports. The next deploy tries again." >&2
42+ fi
43+fi
addedscripts/jsonc.mjs+21−0View file
@@ -1,0 +1,21 @@
1+// JSONC (wrangler.jsonc) for the deploy scripts: scripts/render-config.mjs and scripts/turnstile.sh.
2+
3+/** Strip // and /* *\/ comments and trailing commas from JSONC, leaving strings intact. */
4+export function parseJsonc(text) {
5+ let out = "";
6+ for (let i = 0; i < text.length; i++) {
7+ const ch = text[i];
8+ if (ch === '"') {
9+ let j = i + 1;
10+ while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
11+ out += text.slice(i, j + 1);
12+ i = j;
13+ } else if (ch === "/" && text[i + 1] === "/") {
14+ while (i < text.length && text[i] !== "\n") i++;
15+ out += "\n";
16+ } else if (ch === "/" && text[i + 1] === "*") {
17+ i = text.indexOf("*/", i + 2) + 1;
18+ } else out += ch;
19+ }
20+ return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
21+}
modifiedscripts/render-config.mjs+6−20View file
@@ -7,34 +7,17 @@
77 //
88 // --public-url also points the Worker's route at that host (in --zone, by default
99 // the host's last two labels) and sends email from notifications@<host>.
10+// From the environment it takes RUNNER_IMAGE (scripts/runner-image.sh) and
11+// TURNSTILE_SITE_KEY (scripts/turnstile.sh, through bootstrap).
1012 import { readFileSync, writeFileSync } from "node:fs";
1113 import { dirname, join } from "node:path";
1214 import { fileURLToPath } from "node:url";
1315 import { parseArgs } from "node:util";
16+import { parseJsonc } from "./jsonc.mjs";
1417
1518 const { values } = parseArgs({ options: { d1: { type: "string" }, team: { type: "string" }, aud: { type: "string" }, "public-url": { type: "string" }, zone: { type: "string" }, "mail-from": { type: "string" } } });
1619 if (!values.d1) throw new Error("--d1 <database id> is required");
1720
18-/** Strip // and /* *\/ comments and trailing commas from JSONC, leaving strings intact. */
19-export function parseJsonc(text) {
20- let out = "";
21- for (let i = 0; i < text.length; i++) {
22- const ch = text[i];
23- if (ch === '"') {
24- let j = i + 1;
25- while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
26- out += text.slice(i, j + 1);
27- i = j;
28- } else if (ch === "/" && text[i + 1] === "/") {
29- while (i < text.length && text[i] !== "\n") i++;
30- out += "\n";
31- } else if (ch === "/" && text[i + 1] === "*") {
32- i = text.indexOf("*/", i + 2) + 1;
33- } else out += ch;
34- }
35- return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
36-}
37-
3821 const appDir = join(dirname(fileURLToPath(import.meta.url)), "..", "apps", "diffinery");
3922 const cfg = parseJsonc(readFileSync(join(appDir, "wrangler.jsonc"), "utf8"));
4023 delete cfg.env;
@@ -61,5 +44,8 @@
6144 delete c.image_build_context;
6245 }
6346 }
47+// The site key of the Turnstile widget scripts/turnstile.sh set up for this hostname (not a
48+// secret). One set in wrangler.jsonc is the operator's own and wins; turnstile.sh then leaves it alone.
49+if (process.env.TURNSTILE_SITE_KEY && !cfg.vars.TURNSTILE_SITE_KEY) cfg.vars.TURNSTILE_SITE_KEY = process.env.TURNSTILE_SITE_KEY;
6450 writeFileSync(join(appDir, "wrangler.deploy.jsonc"), `// Generated by scripts/render-config.mjs; do not edit.\n${JSON.stringify(cfg, null, 2)}\n`);
6551 console.log(`wrote ${join(appDir, "wrangler.deploy.jsonc")}`);
addedscripts/turnstile.sh+139−0View file
@@ -1,0 +1,139 @@
1+#!/usr/bin/env bash
2+# Cloudflare Turnstile for the security report form (and open sign-up, when SIGNUP=open).
3+# Run by scripts/bootstrap.sh on every deploy, and by scripts/ensure-secrets.sh.
4+#
5+# scripts/turnstile.sh
6+# Finds the account's Turnstile widget named "diffinery <DIFFINERY_DOMAIN>", creating it
7+# (managed mode, for that hostname) when there is none, and prints its site key: the only
8+# thing this prints on stdout. Bootstrap passes it to scripts/render-config.mjs, which deploys
9+# it as the TURNSTILE_SITE_KEY var. Prints nothing when the token can't manage Turnstile,
10+# or when wrangler.jsonc sets TURNSTILE_SITE_KEY itself (then Turnstile is the operator's).
11+# scripts/turnstile.sh secret <site key> [wrangler args...]
12+# Stores that widget's secret key as the Worker secret TURNSTILE_SECRET. ensure-secrets.sh
13+# does this after the deploy when TURNSTILE_SECRET is missing.
14+#
15+# The secret key goes from Cloudflare's API straight to `wrangler secret put`; it is never printed.
16+#
17+# Which secret goes with which site key: Worker secrets can't be read back, so the marker is the
18+# TURNSTILE_SITE_KEY var deployed with it. When the Worker has TURNSTILE_SECRET but its deployed
19+# site key isn't this widget's (the widget was deleted and created again, or the operator's own
20+# keys came before), the secret belongs to another widget, and this replaces it before the new
21+# site key is deployed. If it can't, it prints no site key, so the new key never goes live with the
22+# old secret; anonymous reports stay refused, and the next deploy tries again.
23+#
24+# Cloudflare API (https://developers.cloudflare.com/api/resources/turnstile/subresources/widgets/):
25+# List: GET /accounts/{account_id}/challenges/widgets?per_page=1000 (items have no secret)
26+# Create: POST /accounts/{account_id}/challenges/widgets {name, domains, mode}
27+# https://developers.cloudflare.com/api/resources/turnstile/subresources/widgets/methods/create/
28+# Get: GET /accounts/{account_id}/challenges/widgets/{sitekey} (with .result.secret)
29+# Deployed bindings, to compare site keys: GET /accounts/{account_id}/workers/scripts/diffinery/settings
30+# https://developers.cloudflare.com/api/resources/workers/subresources/scripts/subresources/script_and_version_settings/methods/get/
31+# The token needs Account → Turnstile → Edit ("Turnstile Sites Write" in the API reference;
32+# https://developers.cloudflare.com/turnstile/get-started/widget-management/api/). Without it this
33+# warns, naming it, and the deploy goes on: people who aren't signed in can't send security reports
34+# until someone gives the token that permission or sets the keys by hand (docs/self-hosting/configuration.md).
35+#
36+# Requires: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, node, jq, curl. Optional: DIFFINERY_DOMAIN.
37+set -euo pipefail
38+
39+: "${CLOUDFLARE_API_TOKEN:?set CLOUDFLARE_API_TOKEN}"
40+: "${CLOUDFLARE_ACCOUNT_ID:?set CLOUDFLARE_ACCOUNT_ID}"
41+DOMAIN="${DIFFINERY_DOMAIN:-diffinery.com}"
42+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
43+APP="$ROOT/apps/diffinery"
44+WRANGLER=(pnpm --dir "$APP" exec wrangler)
45+WORKER=diffinery
46+API="https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID"
47+WIDGET_NAME="diffinery $DOMAIN"
48+PERMISSION="Account → Turnstile → Edit (\"Turnstile Sites Write\" in the API reference)"
49+
50+cf() { # cf METHOD PATH [JSON]
51+ local method="$1" path="$2" body="${3:-}"
52+ if [[ -n "$body" ]]; then
53+ curl -sS -X "$method" "$API$path" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" --data "$body"
54+ else
55+ curl -sS -X "$method" "$API$path" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"
56+ fi
57+}
58+say() { echo "$*" >&2; } # stdout carries only the site key
59+warn() { echo "::warning::$*" >&2; }
60+# Only the error codes and messages: a widget's response may hold its secret.
61+api_errors() { jq -r '[.errors[]? | "\(.code): \(.message)"] | join("; ")' <<<"$1" 2>/dev/null || echo "not JSON"; }
62+
63+# put_secret SITEKEY [wrangler args...]: the widget's secret key, from the API to the Worker secret.
64+put_secret() {
65+ local sitekey="$1" res secret
66+ shift
67+ res="$(cf GET "/challenges/widgets/$sitekey" || true)"
68+ secret="$(jq -r '.result.secret // empty' <<<"$res" 2>/dev/null || true)"
69+ if [[ -z "$secret" ]]; then
70+ warn "Could not read the secret key of Turnstile widget $sitekey. The token needs $PERMISSION. Cloudflare said: $(api_errors "$res")"
71+ return 1
72+ fi
73+ printf '%s' "$secret" | "${WRANGLER[@]}" secret put TURNSTILE_SECRET "$@" >/dev/null
74+}
75+
76+if [[ "${1:-}" == "secret" ]]; then
77+ [[ -n "${2:-}" ]] || { echo "usage: scripts/turnstile.sh secret <site key> [wrangler args...]" >&2; exit 2; }
78+ put_secret "${@:2}"
79+ exit
80+fi
81+
82+# Keys set by hand in wrangler.jsonc are the operator's: leave them, and their secret, alone.
83+OWN="$(node --input-type=module -e '
84+ import { readFileSync } from "node:fs";
85+ import { pathToFileURL } from "node:url";
86+ const { parseJsonc } = await import(pathToFileURL(process.argv[1]).href);
87+ process.stdout.write(parseJsonc(readFileSync(process.argv[2], "utf8")).vars?.TURNSTILE_SITE_KEY ?? "");
88+' "$ROOT/scripts/jsonc.mjs" "$APP/wrangler.jsonc")"
89+if [[ -n "$OWN" ]]; then
90+ say "wrangler.jsonc sets TURNSTILE_SITE_KEY, so the deploy leaves Turnstile and TURNSTILE_SECRET to you"
91+ exit 0
92+fi
93+
94+# The widget, found by name; created only when missing.
95+LIST="$(cf GET "/challenges/widgets?per_page=1000" || true)"
96+if ! jq -e '.success' >/dev/null 2>&1 <<<"$LIST"; then
97+ warn "Could not read the account's Turnstile widgets, so the deploy can't set up Turnstile and only people who are signed in can send security reports. The token needs $PERMISSION. Cloudflare said: $(api_errors "$LIST")"
98+ exit 0
99+fi
100+SITEKEY="$(jq -r --arg n "$WIDGET_NAME" '[.result[]? | select(.name == $n)][0].sitekey // empty' <<<"$LIST")"
101+if [[ -n "$SITEKEY" ]]; then
102+ say "Turnstile widget \"$WIDGET_NAME\" exists: $SITEKEY"
103+ jq -e --arg n "$WIDGET_NAME" --arg d "$DOMAIN" 'any([.result[]? | select(.name == $n)][0].domains[]?; . == $d)' >/dev/null <<<"$LIST" \
104+ || warn "Turnstile widget \"$WIDGET_NAME\" doesn't list $DOMAIN among its hostnames, so its challenge fails there. Add $DOMAIN to it in the dashboard (Turnstile → the widget → Hostname management)."
105+else
106+ BODY="$(jq -nc --arg n "$WIDGET_NAME" --arg d "$DOMAIN" '{name: $n, domains: [$d], mode: "managed"}')"
107+ RES="$(cf POST /challenges/widgets "$BODY" || true)"
108+ SITEKEY="$(jq -r 'select(.success) | .result.sitekey // empty' <<<"$RES" 2>/dev/null || true)"
109+ if [[ -z "$SITEKEY" ]]; then
110+ warn "Could not create the Turnstile widget \"$WIDGET_NAME\", so only people who are signed in can send security reports. The token needs $PERMISSION. Cloudflare said: $(api_errors "$RES")"
111+ exit 0
112+ fi
113+ say "created Turnstile widget \"$WIDGET_NAME\" (managed, for $DOMAIN): $SITEKEY"
114+fi
115+
116+# Does the Worker's TURNSTILE_SECRET belong to this widget? Its deployed TURNSTILE_SITE_KEY says.
117+SETTINGS="$(cf GET "/workers/scripts/$WORKER/settings" || true)"
118+if jq -e '.success' >/dev/null 2>&1 <<<"$SETTINGS"; then
119+ DEPLOYED="$(jq -r '[.result.bindings[]? | select(.type == "plain_text" and .name == "TURNSTILE_SITE_KEY") | .text][0] // empty' <<<"$SETTINGS")"
120+ if ! jq -e '[.result.bindings[]? | select(.type == "secret_text" and .name == "TURNSTILE_SECRET")] | length > 0' >/dev/null <<<"$SETTINGS"; then
121+ say "TURNSTILE_SECRET isn't set yet; scripts/ensure-secrets.sh sets it after the deploy"
122+ elif [[ "$DEPLOYED" != "$SITEKEY" ]]; then
123+ if put_secret "$SITEKEY" --name "$WORKER"; then
124+ say "replaced TURNSTILE_SECRET: it went with site key ${DEPLOYED:-(none)}, not $SITEKEY"
125+ else
126+ warn "TURNSTILE_SECRET goes with another Turnstile widget and couldn't be replaced, so this deploy leaves TURNSTILE_SITE_KEY unset and only people who are signed in can send security reports. The next deploy tries again."
127+ exit 0
128+ fi
129+ else
130+ say "TURNSTILE_SECRET goes with site key $SITEKEY"
131+ fi
132+elif jq -e 'any(.errors[]?; .code == 10007)' >/dev/null 2>&1 <<<"$SETTINGS"; then
133+ # 10007: no such Worker yet (the first deploy; wrangler's WORKER_NOT_FOUND_ERR_CODE). ensure-secrets.sh sets TURNSTILE_SECRET after it.
134+ say "the Worker isn't deployed yet; scripts/ensure-secrets.sh sets TURNSTILE_SECRET after the deploy"
135+else
136+ warn "Could not read the $WORKER Worker's settings to check which Turnstile widget TURNSTILE_SECRET goes with, so this deploy leaves TURNSTILE_SITE_KEY unset. The token needs Account → Workers Scripts → Edit. Cloudflare said: $(api_errors "$SETTINGS")"
137+ exit 0
138+fi
139+echo "$SITEKEY"