Files
PaperClipAI/server/src/middleware/board-mutation-guard.ts
T
Dotta 9c29394f4d [codex] Allow cloud tenant import mutations without browser origin (#6378)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Paperclip Cloud imports local company data into tenant Paperclip
stacks through trusted server-to-server calls.
> - Tenant imports authenticate as board actors with `source:
"cloud_tenant"` because they act on behalf of an authorized stack user.
> - The board mutation guard correctly protects browser session
mutations with trusted `Origin`/`Referer` checks.
> - But the guard treated trusted Cloud tenant calls like browser
session mutations, so server-to-server imports without a browser origin
failed with `403 Board mutation requires trusted browser origin`.
> - This pull request exempts trusted Cloud tenant actors from
browser-origin enforcement while preserving the session-backed browser
guard.
> - The benefit is that authorized Cloud imports can persist into tenant
Paperclip storage without weakening browser CSRF protections.

## What Changed

- Allow `req.actor.source === "cloud_tenant"` through
`boardMutationGuard` without requiring browser `Origin` or `Referer`
headers.
- Add a focused regression test for Cloud tenant POST mutations without
an origin.
- Preserve the existing session-backed rejection test for board
mutations that lack a trusted browser origin.

## Verification

- `pnpm exec vitest run
server/src/__tests__/board-mutation-guard.test.ts`
- Result: 10 tests passed.

## Risks

- Low risk: this only expands the existing non-browser exemption list to
trusted Cloud tenant actors that have already passed tenant-server-token
authentication.
- The browser-session path remains covered by the existing rejection
test, so missing-origin browser mutations still fail.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool-enabled local repository
editing and shell verification.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-05-19 14:25:58 -05:00

79 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, board bearer keys, and trusted Cloud tenant calls 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"
|| req.actor.source === "cloud_tenant"
) {
next();
return;
}
if (!isTrustedBoardMutationRequest(req)) {
res.status(403).json({ error: "Board mutation requires trusted browser origin" });
return;
}
next();
};
}