forked from farhoodlabs/paperclip
f460f744ef
## 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
74 lines
2.3 KiB
TypeScript
74 lines
2.3 KiB
TypeScript
import type { Request, RequestHandler } from "express";
|
|
|
|
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
const DEFAULT_DEV_ORIGINS = [
|
|
"http://localhost:3100",
|
|
"http://127.0.0.1:3100",
|
|
];
|
|
|
|
function parseOrigin(value: string | undefined) {
|
|
if (!value) return null;
|
|
try {
|
|
const url = new URL(value);
|
|
return `${url.protocol}//${url.host}`.toLowerCase();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function trustedOriginsForRequest(req: Request) {
|
|
const origins = new Set(DEFAULT_DEV_ORIGINS.map((value) => value.toLowerCase()));
|
|
const forwardedHost = req.header("x-forwarded-host")?.split(",")[0]?.trim();
|
|
const host = forwardedHost || req.header("host")?.trim();
|
|
if (host) {
|
|
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;
|
|
}
|
|
|
|
function isTrustedBoardMutationRequest(req: Request) {
|
|
const allowedOrigins = trustedOriginsForRequest(req);
|
|
const origin = parseOrigin(req.header("origin"));
|
|
if (origin && allowedOrigins.has(origin)) return true;
|
|
|
|
const refererOrigin = parseOrigin(req.header("referer"));
|
|
if (refererOrigin && allowedOrigins.has(refererOrigin)) return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
export function boardMutationGuard(): RequestHandler {
|
|
return (req, res, next) => {
|
|
if (SAFE_METHODS.has(req.method.toUpperCase())) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
if (req.actor.type !== "board") {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
// Local-trusted mode and board bearer keys are not browser-session requests.
|
|
// In these modes, origin/referer headers can be absent; do not block those mutations.
|
|
if (req.actor.source === "local_implicit" || req.actor.source === "board_key") {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
if (!isTrustedBoardMutationRequest(req)) {
|
|
res.status(403).json({ error: "Board mutation requires trusted browser origin" });
|
|
return;
|
|
}
|
|
|
|
next();
|
|
};
|
|
}
|