mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
## 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
79 lines
2.3 KiB
TypeScript
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();
|
|
};
|
|
}
|