mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +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>
135 lines
5.4 KiB
TypeScript
135 lines
5.4 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { redactSensitive, stripSecretBearingUrlParts } from "../middleware/redact-sensitive.js";
|
|
|
|
describe("redactSensitive", () => {
|
|
it("redacts a plaintext password field on a sign-in body", () => {
|
|
const body = { email: "user@example.com", password: "founding6gomez6croaking" };
|
|
|
|
const out = redactSensitive(body) as Record<string, unknown>;
|
|
|
|
expect(out.email).toBe("user@example.com");
|
|
expect(out.password).toBe("[REDACTED]");
|
|
expect((body as Record<string, unknown>).password).toBe("founding6gomez6croaking");
|
|
});
|
|
|
|
it("redacts password key regardless of casing", () => {
|
|
expect((redactSensitive({ Password: "x" }) as Record<string, unknown>).Password).toBe("[REDACTED]");
|
|
expect((redactSensitive({ PASSWORD: "x" }) as Record<string, unknown>).PASSWORD).toBe("[REDACTED]");
|
|
});
|
|
|
|
it("redacts known credential-shaped keys", () => {
|
|
const out = redactSensitive({
|
|
currentPassword: "a",
|
|
newPassword: "b",
|
|
access_token: "c",
|
|
refresh_token: "d",
|
|
api_key: "e",
|
|
authorization: "Bearer f",
|
|
}) as Record<string, string>;
|
|
|
|
for (const value of Object.values(out)) {
|
|
expect(value).toBe("[REDACTED]");
|
|
}
|
|
});
|
|
|
|
it("redacts an OAuth provider's error_description and error_uri from a callback query", () => {
|
|
const out = redactSensitive({
|
|
state: "paperclip-state",
|
|
error: "access_denied",
|
|
error_description: "\u001b[31mPaste your recovery key\u001b[0m sk-live-canary",
|
|
error_uri: "https://attacker.example/explain?leak=sk-live-canary",
|
|
}) as Record<string, unknown>;
|
|
|
|
// The `error` code is Paperclip's one allowlisted label, so it stays legible
|
|
// in logs; the provider's prose does not.
|
|
expect(out.error).toBe("access_denied");
|
|
expect(out.state).toBe("paperclip-state");
|
|
expect(out.error_description).toBe("[REDACTED]");
|
|
expect(out.error_uri).toBe("[REDACTED]");
|
|
expect(JSON.stringify(out)).not.toContain("sk-live-canary");
|
|
expect(JSON.stringify(out)).not.toContain("\\u001b");
|
|
});
|
|
|
|
it("redacts bare value and token fields recursively", () => {
|
|
const out = redactSensitive({
|
|
token: "secret-token",
|
|
nested: { value: "secret-value" },
|
|
entries: [{ value: "array-secret" }],
|
|
limit: 20,
|
|
}) as Record<string, unknown>;
|
|
|
|
expect(out.token).toBe("[REDACTED]");
|
|
expect((out.nested as Record<string, unknown>).value).toBe("[REDACTED]");
|
|
expect((out.entries as Array<Record<string, unknown>>)[0].value).toBe("[REDACTED]");
|
|
expect(out.limit).toBe(20);
|
|
expect(JSON.stringify(out)).not.toMatch(/secret-token|secret-value|array-secret/);
|
|
});
|
|
|
|
it("strips secret-bearing query and fragment values from source URLs", () => {
|
|
const out = redactSensitive({
|
|
source: "https://github.com/acme/private-skill?token=secret#token=secret",
|
|
}) as Record<string, unknown>;
|
|
|
|
expect(out.source).toBe("https://github.com/acme/private-skill");
|
|
});
|
|
|
|
it("recurses into nested objects and arrays", () => {
|
|
const out = redactSensitive({
|
|
user: { email: "user@example.com", password: "secret-pass" },
|
|
tokens: [{ access_token: "t1" }, { access_token: "t2" }],
|
|
}) as Record<string, unknown>;
|
|
|
|
expect((out.user as Record<string, unknown>).email).toBe("user@example.com");
|
|
expect((out.user as Record<string, unknown>).password).toBe("[REDACTED]");
|
|
const tokens = out.tokens as Array<Record<string, unknown>>;
|
|
expect(tokens[0].access_token).toBe("[REDACTED]");
|
|
expect(tokens[1].access_token).toBe("[REDACTED]");
|
|
});
|
|
|
|
it("leaves primitives and non-sensitive keys untouched", () => {
|
|
const body = { email: "a@b.c", name: "Alice", count: 7, active: true, missing: null };
|
|
|
|
expect(redactSensitive(body)).toEqual(body);
|
|
});
|
|
|
|
it("returns primitives unchanged", () => {
|
|
expect(redactSensitive("hello")).toBe("hello");
|
|
expect(redactSensitive(42)).toBe(42);
|
|
expect(redactSensitive(null)).toBe(null);
|
|
expect(redactSensitive(undefined)).toBe(undefined);
|
|
});
|
|
|
|
it("caps recursion depth so cycles do not pin the logger", () => {
|
|
const cycle: Record<string, unknown> = { name: "root" };
|
|
cycle.self = cycle;
|
|
|
|
expect(() => redactSensitive(cycle)).not.toThrow();
|
|
});
|
|
|
|
it("omits deeply-nested arrays at the depth cap instead of leaking null entries to JSON", () => {
|
|
// Build an object whose array field is reached at MAX_DEPTH. Recursing
|
|
// into the array elements would exceed the cap; without the array-level
|
|
// guard, `value.map` would produce `[undefined, ...]` which JSON.stringify
|
|
// renders as `[null, ...]`. Object properties at the same cap are
|
|
// already absent from the JSON output (JSON.stringify skips undefined
|
|
// values on objects), so this test pins the array path to the same
|
|
// contract: silently absent, not visible as nulls.
|
|
let payload: Record<string, unknown> = { values: [1, 2, 3] };
|
|
for (let i = 0; i < 5; i++) payload = { nested: payload };
|
|
|
|
const out = redactSensitive(payload);
|
|
|
|
const json = JSON.stringify(out);
|
|
expect(json).not.toContain("null");
|
|
expect(json).not.toContain("[1,2,3]");
|
|
});
|
|
});
|
|
|
|
describe("stripSecretBearingUrlParts", () => {
|
|
it("keeps a request path legible while dropping its complete query and fragment", () => {
|
|
expect(stripSecretBearingUrlParts(
|
|
"/api/tools/oauth/callback?code=authorization-code&error_description=provider-prose#fragment",
|
|
)).toBe("/api/tools/oauth/callback");
|
|
});
|
|
});
|