mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
fix(runner): harden browser credential redaction
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
20bb02d325
commit
7fa15aaaf8
2 files changed
+295
-28
No files matched your search
@@ -16,6 +16,16 @@ import type {
|
||||
import type { PrpEvent } from "../protocol/phase1-contract.js";
|
||||
import { Phase4bDemoServer } from "./phase4b-demo-server.js";
|
||||
|
||||
const OPAQUE_BROWSER_CREDENTIALS = {
|
||||
clientSecret: "opaque-client-secret-value",
|
||||
providerApiKey: "opaque-provider-api-key-value",
|
||||
sessionToken: "opaque-session-token-value",
|
||||
idToken: "opaque-id-token-value",
|
||||
password: "opaque-password-value",
|
||||
authorization: "opaque-authorization-value",
|
||||
cookie: "opaque-cookie-value",
|
||||
} as const;
|
||||
|
||||
class Queue<T> implements AsyncIterable<T> {
|
||||
#values: T[] = [];
|
||||
#waiters: Array<(value: IteratorResult<T>) => void> = [];
|
||||
@@ -127,7 +137,24 @@ class StubSession implements HarnessSession {
|
||||
itemId: "command-demo",
|
||||
status: "pending",
|
||||
prompt: "Approve the deterministic command.",
|
||||
details: { authorization: "Bearer browser-must-not-see" },
|
||||
details: {
|
||||
provider: {
|
||||
...OPAQUE_BROWSER_CREDENTIALS,
|
||||
nested: {
|
||||
signingKey: "opaque-signing-key-value",
|
||||
private_key: "opaque-private-key-value",
|
||||
},
|
||||
},
|
||||
usage: {
|
||||
tokenBudget: 4096,
|
||||
tokensUsed: 128,
|
||||
tokenUsage: {
|
||||
inputTokens: 96,
|
||||
outputTokens: 32,
|
||||
},
|
||||
},
|
||||
invalidUsage: { tokenBudget: "opaque-token-budget-string" },
|
||||
},
|
||||
};
|
||||
this.pending = [request];
|
||||
this.emit("runtime_request.created", {
|
||||
@@ -370,6 +397,37 @@ async function openEventStream(
|
||||
});
|
||||
}
|
||||
|
||||
async function readEventStreamReplay(url: string, sessionId: string): Promise<string> {
|
||||
const response = await browserRequest(
|
||||
url,
|
||||
`/api/phase4b/sessions/${sessionId}/stream?after=0`,
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const reader = response.body?.getReader();
|
||||
if (reader === undefined) throw new Error("event stream response did not include a body");
|
||||
let result = "";
|
||||
try {
|
||||
while (!result.includes("data:")) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
result += new TextDecoder().decode(chunk.value, { stream: true });
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function expectOpaqueCredentialsRedacted(serialized: string): void {
|
||||
for (const value of Object.values(OPAQUE_BROWSER_CREDENTIALS)) {
|
||||
expect(serialized).not.toContain(value);
|
||||
}
|
||||
expect(serialized).not.toContain("opaque-signing-key-value");
|
||||
expect(serialized).not.toContain("opaque-private-key-value");
|
||||
expect(serialized).not.toContain("opaque-token-budget-string");
|
||||
expect(serialized).toContain("[REDACTED]");
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.splice(0).map((server) => server.close()));
|
||||
});
|
||||
@@ -488,7 +546,7 @@ describe("Phase 4b package-local demo server", () => {
|
||||
|
||||
const credentialAttempt = await jsonBrowserRequest(url, "/api/phase4b/sessions", {
|
||||
objective: "Browser must not choose provider credentials.",
|
||||
openaiApiKey: "browser-secret",
|
||||
clientSecret: "browser-secret",
|
||||
});
|
||||
expect(credentialAttempt.status).toBe(422);
|
||||
await expect(credentialAttempt.json()).resolves.toMatchObject({
|
||||
@@ -577,6 +635,29 @@ describe("Phase 4b package-local demo server", () => {
|
||||
expect(JSON.stringify(created)).not.toContain("browser-must-not-see");
|
||||
expect(JSON.stringify(created)).not.toContain("server-only-secret");
|
||||
expect(JSON.stringify(created)).not.toContain("/srv/paperclip/home");
|
||||
expectOpaqueCredentialsRedacted(JSON.stringify(created));
|
||||
expect(created).toMatchObject({
|
||||
pendingRequests: [{
|
||||
details: {
|
||||
provider: {
|
||||
clientSecret: "[REDACTED]",
|
||||
providerApiKey: "[REDACTED]",
|
||||
sessionToken: "[REDACTED]",
|
||||
idToken: "[REDACTED]",
|
||||
nested: {
|
||||
signingKey: "[REDACTED]",
|
||||
private_key: "[REDACTED]",
|
||||
},
|
||||
},
|
||||
usage: {
|
||||
tokenBudget: 4096,
|
||||
tokensUsed: 128,
|
||||
tokenUsage: { inputTokens: 96, outputTokens: 32 },
|
||||
},
|
||||
invalidUsage: { tokenBudget: "[REDACTED]" },
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
const sessionId = String(created.sessionId);
|
||||
const replay = await browserRequest(url, `/api/phase4b/sessions/${sessionId}/events?after=0`)
|
||||
@@ -586,6 +667,12 @@ describe("Phase 4b package-local demo server", () => {
|
||||
expect(replay.events.every((event, index) => event.sourceSeq === index + 1)).toBe(true);
|
||||
expect(JSON.stringify(replay)).not.toContain("/srv/paperclip/home");
|
||||
expect(JSON.stringify(replay)).toContain("<server-path>");
|
||||
expectOpaqueCredentialsRedacted(JSON.stringify(replay));
|
||||
|
||||
const streamReplay = await readEventStreamReplay(url, sessionId);
|
||||
expectOpaqueCredentialsRedacted(streamReplay);
|
||||
expect(streamReplay).toContain('"tokenBudget":4096');
|
||||
expect(streamReplay).toContain('"tokensUsed":128');
|
||||
|
||||
const otherSession = await jsonBrowserRequest(url, "/api/phase4b/sessions", {
|
||||
objective: "Keep request scope separate.",
|
||||
@@ -658,6 +745,33 @@ describe("Phase 4b package-local demo server", () => {
|
||||
expect(afterReconnect.events.some(({ eventType }) => eventType === "session.resumed")).toBe(true);
|
||||
});
|
||||
|
||||
it("redacts structured credential diagnostics and bounds browser-visible errors", async () => {
|
||||
const errorSecret = "opaque-error-client-secret";
|
||||
const server = new Phase4bDemoServer({
|
||||
workingDirectory: "/safe/server-owned-workspace",
|
||||
driverFactory: () => {
|
||||
throw new Error(
|
||||
`provider failed: {"clientSecret":"${errorSecret}"} ${"x".repeat(100_000)}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
servers.push(server);
|
||||
const { url } = await server.start();
|
||||
|
||||
const response = await browserRequest(url, "/api/phase4b/sessions", {
|
||||
method: "POST",
|
||||
headers: { "connection": "close", "content-type": "application/json" },
|
||||
body: JSON.stringify({ objective: "Exercise a structured provider error." }),
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.json() as { error: string; message: string };
|
||||
expect(body.error).toBe("invalid_request");
|
||||
expect(body.message).not.toContain(errorSecret);
|
||||
expect(body.message).toContain('"clientSecret":"[REDACTED]"');
|
||||
expect(body.message.length).toBeLessThanOrEqual(16 * 1024 + "[TRUNCATED]".length);
|
||||
expect(body.message).toMatch(/\[TRUNCATED\]$/);
|
||||
});
|
||||
|
||||
it("admits an origin-less same-origin stream but still requires an origin to mutate", async () => {
|
||||
const server = new Phase4bDemoServer({
|
||||
workingDirectory: "/safe/server-owned-workspace",
|
||||
|
||||
@@ -31,6 +31,10 @@ import { redactCodexDiagnostic } from "../drivers/codex/app-server-transport.js"
|
||||
|
||||
const MAX_BROWSER_BODY_BYTES = 64 * 1024;
|
||||
const MAX_BROWSER_EVENTS = 4096;
|
||||
const MAX_BROWSER_STRING_CHARACTERS = 16 * 1024;
|
||||
const MAX_BROWSER_TOTAL_STRING_CHARACTERS = 512 * 1024;
|
||||
const MAX_BROWSER_VALUE_NODES = 65_536;
|
||||
const MAX_BROWSER_SERIALIZED_BYTES = 4 * 1024 * 1024;
|
||||
const DEFAULT_MAX_ACTIVE_SESSIONS = 16;
|
||||
const DEFAULT_MAX_SESSION_SUBSCRIBERS = 4;
|
||||
const MAX_CONFIGURED_ACTIVE_SESSIONS = 64;
|
||||
@@ -93,17 +97,61 @@ function record(value: unknown): Record<string, unknown> {
|
||||
: {};
|
||||
}
|
||||
|
||||
function browserSafe(value: unknown, depth = 0): unknown {
|
||||
interface BrowserSafeBudget {
|
||||
nodesRemaining: number;
|
||||
stringCharactersRemaining: number;
|
||||
}
|
||||
|
||||
const BROWSER_SAFE_CREDENTIAL_METADATA_KEYS = new Set([
|
||||
"credentialsexposed",
|
||||
"providerauthentication",
|
||||
]);
|
||||
|
||||
const BROWSER_SAFE_USAGE_KEYS = new Set([
|
||||
"cachedinputtokens",
|
||||
"inputtokens",
|
||||
"outputtokens",
|
||||
"reasoningoutputtokens",
|
||||
"tokenbudget",
|
||||
"tokensused",
|
||||
"tokenusage",
|
||||
"totaltokens",
|
||||
]);
|
||||
|
||||
function browserSafe(value: unknown): unknown {
|
||||
return browserSafeValue(value, 0, {
|
||||
nodesRemaining: MAX_BROWSER_VALUE_NODES,
|
||||
stringCharactersRemaining: MAX_BROWSER_TOTAL_STRING_CHARACTERS,
|
||||
});
|
||||
}
|
||||
|
||||
function browserSafeValue(value: unknown, depth: number, budget: BrowserSafeBudget): unknown {
|
||||
if (depth > 10) return "[TRUNCATED]";
|
||||
if (typeof value === "string") return redactBrowserString(value);
|
||||
if (Array.isArray(value)) return value.slice(0, 256).map((entry) => browserSafe(entry, depth + 1));
|
||||
if (budget.nodesRemaining <= 0) return "[TRUNCATED]";
|
||||
budget.nodesRemaining -= 1;
|
||||
if (typeof value === "string") {
|
||||
const redacted = redactBrowserString(value);
|
||||
const allowedCharacters = Math.min(
|
||||
redacted.length,
|
||||
budget.stringCharactersRemaining,
|
||||
);
|
||||
budget.stringCharactersRemaining -= allowedCharacters;
|
||||
return allowedCharacters === redacted.length
|
||||
? redacted
|
||||
: `${redacted.slice(0, allowedCharacters)}[TRUNCATED]`;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.slice(0, 256)
|
||||
.map((entry) => browserSafeValue(entry, depth + 1, budget));
|
||||
}
|
||||
if (typeof value !== "object" || value === null) return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).slice(0, 256).map(([key, entry]) => [
|
||||
key,
|
||||
isSensitiveBrowserKey(key)
|
||||
isSensitiveBrowserEntry(key, entry)
|
||||
? "[REDACTED]"
|
||||
: browserSafe(entry, depth + 1),
|
||||
: browserSafeValue(entry, depth + 1, budget),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -114,20 +162,133 @@ function browserSafe(value: unknown, depth = 0): unknown {
|
||||
* `.paperclip` — and none of them mean anything to a browser.
|
||||
*/
|
||||
function redactBrowserString(value: string): string {
|
||||
return redactCodexDiagnostic(value).replace(
|
||||
const truncated = value.length > MAX_BROWSER_STRING_CHARACTERS;
|
||||
const visible = truncated ? value.slice(0, MAX_BROWSER_STRING_CHARACTERS) : value;
|
||||
const redacted = redactStructuredBrowserCredentials(redactCodexDiagnostic(visible)).replace(
|
||||
/\/(?:srv\/paperclip\/home|home\/[^/\s]+|Users\/[^/\s]+)\/\.[^\s"'<>),\]}]*/g,
|
||||
"<server-path>",
|
||||
);
|
||||
return truncated ? `${redacted}[TRUNCATED]` : redacted;
|
||||
}
|
||||
|
||||
function isSensitiveBrowserKey(key: string): boolean {
|
||||
return /^(?:api[_-]?key|token|accessToken|refreshToken|secret|password|authorization|cookie)$/i.test(key) ||
|
||||
/(?:^|_)(?:api[_-]?key|token|secret|password|authorization|cookie)$/i.test(key);
|
||||
const normalized = normalizeBrowserKey(key);
|
||||
if (
|
||||
BROWSER_SAFE_CREDENTIAL_METADATA_KEYS.has(normalized) ||
|
||||
BROWSER_SAFE_USAGE_KEYS.has(normalized)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const words = key
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(Boolean);
|
||||
if (words.some((word) =>
|
||||
[
|
||||
"authorization",
|
||||
"cookie",
|
||||
"cookies",
|
||||
"credential",
|
||||
"credentials",
|
||||
"passwd",
|
||||
"passwds",
|
||||
"password",
|
||||
"passwords",
|
||||
"secret",
|
||||
"secrets",
|
||||
"token",
|
||||
"tokens",
|
||||
].includes(word)
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
if (words.some((word, index) =>
|
||||
["access", "api", "private", "signing"].includes(word) &&
|
||||
["key", "keys"].includes(words[index + 1] ?? "")
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
return /(?:apikeys?|accesskeys?|privatekeys?|signingkeys?|credentials?|passwds?|passwords?|secrets?|tokens?)$/.test(
|
||||
normalized,
|
||||
);
|
||||
}
|
||||
|
||||
function isSensitiveBrowserEntry(key: string, value: unknown): boolean {
|
||||
const normalized = normalizeBrowserKey(key);
|
||||
if (normalized === "credentialsexposed") return typeof value !== "boolean";
|
||||
if (normalized === "providerauthentication") return value !== "server-side";
|
||||
if (BROWSER_SAFE_USAGE_KEYS.has(normalized)) {
|
||||
if (normalized === "tokenusage") return !isBrowserRecord(value);
|
||||
if (normalized === "tokenbudget" && value === null) return false;
|
||||
return typeof value !== "number" || !Number.isFinite(value);
|
||||
}
|
||||
return isSensitiveBrowserKey(key);
|
||||
}
|
||||
|
||||
function isBrowserRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeBrowserKey(key: string): string {
|
||||
return key.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
function redactStructuredBrowserCredentials(value: string): string {
|
||||
return value
|
||||
.replace(
|
||||
/(["'])([A-Za-z][A-Za-z0-9_-]{0,127})\1(\s*:\s*)(["'])([^"'\r\n]*)/g,
|
||||
(
|
||||
match,
|
||||
keyQuote: string,
|
||||
key: string,
|
||||
separator: string,
|
||||
valueQuote: string,
|
||||
fieldValue: string,
|
||||
) =>
|
||||
isSensitiveBrowserDiagnosticField(key, fieldValue)
|
||||
? `${keyQuote}${key}${keyQuote}${separator}${valueQuote}[REDACTED]`
|
||||
: match,
|
||||
)
|
||||
.replace(
|
||||
/\b([A-Za-z][A-Za-z0-9_-]{0,127})(\s*[=:]\s*)([^\s,;}\]]+)/g,
|
||||
(match, key: string, separator: string, fieldValue: string) =>
|
||||
isSensitiveBrowserDiagnosticField(key, fieldValue)
|
||||
? `${key}${separator}[REDACTED]`
|
||||
: match,
|
||||
);
|
||||
}
|
||||
|
||||
function isSensitiveBrowserDiagnosticField(key: string, value: string): boolean {
|
||||
const normalized = normalizeBrowserKey(key);
|
||||
if (normalized === "credentialsexposed") return !/^(?:true|false)$/i.test(value);
|
||||
if (normalized === "providerauthentication") return value !== "server-side";
|
||||
if (BROWSER_SAFE_USAGE_KEYS.has(normalized)) {
|
||||
if (normalized === "tokenusage") return true;
|
||||
if (normalized === "tokenbudget" && value === "null") return false;
|
||||
return !/^-?\d+(?:\.\d+)?$/.test(value);
|
||||
}
|
||||
return isSensitiveBrowserKey(key);
|
||||
}
|
||||
|
||||
function serializeBrowserJson(body: unknown): { serialized: string; overflow: boolean } {
|
||||
const serialized = JSON.stringify(browserSafe(body)) ?? "null";
|
||||
if (Buffer.byteLength(serialized) <= MAX_BROWSER_SERIALIZED_BYTES) {
|
||||
return { serialized, overflow: false };
|
||||
}
|
||||
return {
|
||||
serialized: JSON.stringify({
|
||||
error: "browser_response_too_large",
|
||||
message: `Browser response exceeded ${MAX_BROWSER_SERIALIZED_BYTES} bytes`,
|
||||
}),
|
||||
overflow: true,
|
||||
};
|
||||
}
|
||||
|
||||
function json(response: ServerResponse, status: number, body: unknown): void {
|
||||
const serialized = JSON.stringify(browserSafe(body));
|
||||
response.writeHead(status, {
|
||||
const { serialized, overflow } = serializeBrowserJson(body);
|
||||
response.writeHead(overflow ? 413 : status, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
"content-length": Buffer.byteLength(serialized),
|
||||
@@ -322,18 +483,7 @@ function boundedOption(
|
||||
}
|
||||
|
||||
function browserCredentialField(body: Record<string, unknown>): string | null {
|
||||
const forbidden = new Set([
|
||||
"authorization",
|
||||
"cookie",
|
||||
"credentials",
|
||||
"apikey",
|
||||
"paperclipapikey",
|
||||
"openaiapikey",
|
||||
"providertoken",
|
||||
"refreshtoken",
|
||||
"accesstoken",
|
||||
]);
|
||||
return Object.keys(body).find((key) => forbidden.has(key.replace(/[-_]/g, "").toLowerCase())) ?? null;
|
||||
return Object.keys(body).find((key) => isSensitiveBrowserKey(key)) ?? null;
|
||||
}
|
||||
|
||||
export class Phase4bDemoServer {
|
||||
@@ -402,7 +552,7 @@ export class Phase4bDemoServer {
|
||||
} catch (error) {
|
||||
json(response, errorStatus(error), {
|
||||
error: errorCode(error),
|
||||
message: redactCodexDiagnostic(String(error instanceof Error ? error.message : error)),
|
||||
message: String(error instanceof Error ? error.message : error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -410,7 +560,7 @@ export class Phase4bDemoServer {
|
||||
if (!response.headersSent) {
|
||||
json(response, errorStatus(error), {
|
||||
error: errorCode(error),
|
||||
message: redactCodexDiagnostic(String(error)),
|
||||
message: String(error),
|
||||
});
|
||||
} else {
|
||||
response.end();
|
||||
@@ -529,7 +679,8 @@ export class Phase4bDemoServer {
|
||||
// bytes sent, leaving the browser stuck in CONNECTING.
|
||||
response.write(": stream open\n\n");
|
||||
for (const event of entry.events.slice(after)) {
|
||||
response.write(`data: ${JSON.stringify(browserSafe(event))}\n\n`);
|
||||
const output = serializeBrowserJson(event);
|
||||
if (!output.overflow) response.write(`data: ${output.serialized}\n\n`);
|
||||
}
|
||||
entry.subscribers.add(response);
|
||||
request.once("close", () => entry.subscribers.delete(response));
|
||||
@@ -667,9 +818,11 @@ export class Phase4bDemoServer {
|
||||
const event = browserSafe(rawEvent) as PrpEvent;
|
||||
const validation = validatePrpEvent(event);
|
||||
if (!validation.ok) continue;
|
||||
const output = serializeBrowserJson(event);
|
||||
if (output.overflow) continue;
|
||||
if (entry.events.length >= MAX_BROWSER_EVENTS) entry.events.shift();
|
||||
entry.events.push(event);
|
||||
const serialized = `data: ${JSON.stringify(event)}\n\n`;
|
||||
const serialized = `data: ${output.serialized}\n\n`;
|
||||
for (const subscriber of entry.subscribers) subscriber.write(serialized);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user