fix: trust PAPERCLIP_PUBLIC_URL in board mutation guard (#3731)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Humans interact with the system through a web UI that authenticates
a session and then issues mutations against the board
> - A CSRF-style guard (`boardMutationGuard`) protects those mutations
by requiring the request origin match a trusted set built from the
`Host` / `X-Forwarded-Host` header
> - Behind certain reverse proxies, neither header matches the public
URL — TLS terminates at the edge and the inbound `Host` carries an
internal service name (cluster-local hostname, IP, or an Ingress backend
reference)
> - Mutations from legitimate browser sessions then fail with `403 Board
mutation requires trusted browser origin`
> - `PAPERCLIP_PUBLIC_URL` is already the canonical "what operators told
us the public URL is" value — it's used by better-auth and `config.ts`
> - This pull request adds it to the trusted-origin set when set, so
browsers reaching the legit public URL aren't blocked

## What Changed

- `server/src/middleware/board-mutation-guard.ts` — parse
`PAPERCLIP_PUBLIC_URL` and add its origin to the trusted set in
`trustedOriginsForRequest`. Additive only.

## Verification

- `PAPERCLIP_PUBLIC_URL=https://example.com pnpm start` then issue a
mutation from a browser pointed at `https://example.com`: 200, as
before. From an unrecognized origin: 403, as before.
- Without `PAPERCLIP_PUBLIC_URL` set: behavior is unchanged.

## Risks

Low. Additive only. The default dev origins and the
`Host`/`X-Forwarded-Host`-derived origins continue to be trusted; this
just adds the operator-configured public URL on top.

## Model Used

Claude Opus 4.6 (1M context), extended thinking mode.

## Checklist

- [x] Thinking path traces from project context to this change
- [x] Model used specified
- [x] Tests run locally and pass
- [x] CI green
- [x] Greptile review addressed
This commit is contained in:
Jannes Stubbemann
2026-04-15 16:42:55 +02:00
committed by GitHub
parent 32a9165ddf
commit f460f744ef
@@ -24,6 +24,12 @@ function trustedOriginsForRequest(req: Request) {
origins.add(`http://${host}`.toLowerCase());
origins.add(`https://${host}`.toLowerCase());
}
// Behind some reverse proxies the Host / X-Forwarded-Host header may
// not match the public URL (for example when TLS terminates at the
// edge and the inbound Host is an internal service name). Trust the
// explicitly-configured PAPERCLIP_PUBLIC_URL when it's set.
const publicUrl = parseOrigin(process.env.PAPERCLIP_PUBLIC_URL?.trim());
if (publicUrl) origins.add(publicUrl);
return origins;
}