mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The server controls secrets, host files, outbound requests, and workspace commands > - A red-team review found cases where restricted callers could cross these trust boundaries > - These cases could expose credentials or let untrusted input reach privileged resources > - This pull request applies least-privilege checks at each affected server boundary > - The benefit is safer agent execution without changing the private-instance bootstrap contract ## Linked Issues or Issue Description **What happened?** Several server paths used authorization, redaction, or content-delivery rules that were too broad. Restricted agent keys could obtain company-level operational data. Some adapter and instruction paths could reach server-owned network or file resources without the required owner approval. **Expected behavior** Paperclip must redact credential values, enforce restricted-key scopes, guard outbound network access, prevent same-origin script execution, and reserve host-level file and command controls for authorized operators. **Steps to reproduce** 1. Configure an authenticated development instance at the parent commit. 2. Exercise the affected APIs with a restricted agent key or a non-instance-admin company user. 3. Observe that the parent commit returns privileged data or accepts a privileged operation. 4. Repeat on this branch and observe a redacted response, a safe download, or an HTTP 403 response. **Paperclip version or commit** The findings reproduce from commit `39898ab22` and are fixed by this pull request. **Deployment mode** Authenticated self-hosted server and local development modes. **Installation method** Built from source with pnpm. ## What Changed - Redact generic secret `value` and `token` fields recursively in structured logs. - Classify exact and separator-suffixed `KEY` environment names as secrets in company exports. - Limit restricted self-identity responses and protect company run, log, and secret catalog APIs. - Route HTTP adapter requests through DNS-pinned SSRF protection with exact private-origin allowlisting. - Download HTML, SVG, and other script-capable assets with `nosniff` and a sandbox CSP. - Require instance-admin access for external instruction roots and exports that read them. - Block agent-authenticated host command persistence across supported workspace runtime shapes. - Apply the central runtime-management decision before workspace command controls. - Keep the documented first-user instance-admin claim contract unchanged. - Add regression tests and server-owner configuration documentation. ## Verification - `pnpm -r typecheck` passes. - The Node 24 remediation suite passes with 365 tests. It skips 25 environment-gated tests. - `pnpm build` passes under Node 24. - `git diff --check` passes. - The full local runner reaches known macOS-only general-server harness failures before the serialized route lane. The Linux PR matrix is the authoritative full-suite gate. ## Risks - Restricted agent keys now receive HTTP 403 responses from company-wide run, log, and secret catalog endpoints. - Script-capable assets now download instead of rendering inline. - External instruction roots now require instance-admin access. - Private HTTP adapter endpoints now require an exact origin in `PAPERCLIP_HTTP_ADAPTER_PRIVATE_ENDPOINT_ALLOWLIST`. - Public HTTP adapter endpoints remain enabled. Redirects and metadata or link-local targets remain blocked. - No database migration is required. > 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. The exact serving snapshot and context-window size are not exposed. The model used tool-enabled reasoning, repository access, code execution, and test execution. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
169 lines
6.0 KiB
TypeScript
169 lines
6.0 KiB
TypeScript
import { createServer, request as httpRequest } from "node:http";
|
|
import { Writable } from "node:stream";
|
|
import express from "express";
|
|
import pino from "pino";
|
|
import { pinoHttp } from "pino-http";
|
|
import request from "supertest";
|
|
import { describe, expect, it } from "vitest";
|
|
import { HTTP_LOG_REDACT_PATHS } from "../middleware/http-log-redaction.js";
|
|
import { createHttpLogger } from "../middleware/logger.js";
|
|
|
|
describe("HTTP logger redaction", () => {
|
|
it("defines the HTTP auth and cookie header paths that must be redacted", () => {
|
|
expect(HTTP_LOG_REDACT_PATHS).toContain("req.headers.authorization");
|
|
expect(HTTP_LOG_REDACT_PATHS).toContain("req.headers.cookie");
|
|
expect(HTTP_LOG_REDACT_PATHS).toContain('req.headers["set-cookie"]');
|
|
expect(HTTP_LOG_REDACT_PATHS).toContain('res.headers["set-cookie"]');
|
|
expect(HTTP_LOG_REDACT_PATHS).toContain('req.headers["proxy-authorization"]');
|
|
expect(HTTP_LOG_REDACT_PATHS).toContain('req.headers["x-csrf-token"]');
|
|
expect(HTTP_LOG_REDACT_PATHS).toContain('req.headers["x-xsrf-token"]');
|
|
expect(HTTP_LOG_REDACT_PATHS).toContain('req.headers["x-api-key"]');
|
|
});
|
|
|
|
it("redacts request and response header secrets from pino-http output", async () => {
|
|
const chunks: string[] = [];
|
|
const stream = new Writable({
|
|
write(chunk, _encoding, callback) {
|
|
chunks.push(chunk.toString());
|
|
callback();
|
|
},
|
|
});
|
|
const logger = pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream);
|
|
const httpLogger = pinoHttp({ logger });
|
|
const server = createServer((req, res) => {
|
|
httpLogger(req, res);
|
|
res.setHeader("set-cookie", "sid=response-secret");
|
|
res.end("ok");
|
|
});
|
|
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", () => resolve());
|
|
});
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("Expected server to listen on an ephemeral TCP port");
|
|
}
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const client = httpRequest(
|
|
{
|
|
hostname: "127.0.0.1",
|
|
port: address.port,
|
|
path: "/redaction-check",
|
|
headers: {
|
|
authorization: "Bearer auth-secret",
|
|
cookie: "sid=request-secret",
|
|
"set-cookie": "proxy-secret",
|
|
},
|
|
},
|
|
(res) => {
|
|
res.resume();
|
|
res.on("end", resolve);
|
|
},
|
|
);
|
|
client.on("error", reject);
|
|
client.end();
|
|
});
|
|
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.close((err) => (err ? reject(err) : resolve()));
|
|
});
|
|
}
|
|
|
|
const output = chunks.join("");
|
|
expect(output).not.toMatch(/auth-secret|request-secret|proxy-secret|response-secret/);
|
|
|
|
const log = JSON.parse(output.trim()) as {
|
|
req: { headers: Record<string, string> };
|
|
res: { headers: Record<string, string> };
|
|
};
|
|
expect(log.req.headers.authorization).toBe("[Redacted]");
|
|
expect(log.req.headers.cookie).toBe("[Redacted]");
|
|
expect(log.req.headers["set-cookie"]).toBe("[Redacted]");
|
|
expect(log.res.headers["set-cookie"]).toBe("[Redacted]");
|
|
});
|
|
|
|
it("drops OAuth callback query data from the message and structured request", async () => {
|
|
const chunks: string[] = [];
|
|
const stream = new Writable({
|
|
write(chunk, _encoding, callback) {
|
|
chunks.push(chunk.toString());
|
|
callback();
|
|
},
|
|
});
|
|
const testLogger = pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream);
|
|
const app = express();
|
|
app.use(createHttpLogger(testLogger));
|
|
app.get("/api/tools/oauth/callback", (_req, res) => {
|
|
res.status(400).json({ error: "callback rejected" });
|
|
});
|
|
|
|
const authorizationCode = "oauth-code-canary-61a88f";
|
|
const providerProse = "provider-prose-canary-2087e2";
|
|
const providerUriCanary = "provider-uri-canary-d91ac4";
|
|
const response = await request(app)
|
|
.get("/api/tools/oauth/callback")
|
|
.query({
|
|
code: authorizationCode,
|
|
error_description: providerProse,
|
|
error_uri: `https://provider.example/error?detail=${providerUriCanary}`,
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
const output = chunks.join("");
|
|
expect(output).not.toMatch(new RegExp(`${authorizationCode}|${providerProse}|${providerUriCanary}`));
|
|
|
|
const log = JSON.parse(output.trim()) as {
|
|
msg: string;
|
|
req: { method: string; url: string; query?: unknown };
|
|
reqQuery?: unknown;
|
|
};
|
|
expect(log.msg).toBe("GET /api/tools/oauth/callback 400");
|
|
expect(log.req).toMatchObject({ method: "GET", url: "/api/tools/oauth/callback" });
|
|
expect(log.req.query).toBeUndefined();
|
|
expect(log.reqQuery).toBeUndefined();
|
|
});
|
|
|
|
it("redacts failed secret payload values from structured request logs", async () => {
|
|
const chunks: string[] = [];
|
|
const stream = new Writable({
|
|
write(chunk, _encoding, callback) {
|
|
chunks.push(chunk.toString());
|
|
callback();
|
|
},
|
|
});
|
|
const testLogger = pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream);
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use(createHttpLogger(testLogger));
|
|
app.post("/api/companies/:companyId/secrets", (_req, res) => {
|
|
res.status(422).json({ error: "validation failed" });
|
|
});
|
|
|
|
const response = await request(app)
|
|
.post("/api/companies/company-1/secrets")
|
|
.send({
|
|
name: "OpenAI",
|
|
value: "value-canary-4c845d",
|
|
metadata: { token: "token-canary-902ffc" },
|
|
});
|
|
|
|
expect(response.status).toBe(422);
|
|
const output = chunks.join("");
|
|
expect(output).not.toMatch(/value-canary-4c845d|token-canary-902ffc/);
|
|
|
|
const log = JSON.parse(output.trim()) as {
|
|
reqBody: Record<string, unknown>;
|
|
};
|
|
expect(log.reqBody).toEqual({
|
|
name: "OpenAI",
|
|
value: "[REDACTED]",
|
|
metadata: { token: "[REDACTED]" },
|
|
});
|
|
});
|
|
});
|