Skip to content

Commit 67d1862

Browse files
authored
Verify diagnostic privacy and hosted content boundaries (#2144)
* Verify diagnostic privacy and hosted content boundaries * Verify safe diagnostic reports through real requests
1 parent 7c26543 commit 67d1862

5 files changed

Lines changed: 429 additions & 0 deletions

File tree

‎apps/cloud/src/observability/observability.test.ts‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,3 +409,104 @@ describe("Durable Object platform reset noise", () => {
409409
expect(options.beforeSend(event)).toBeNull();
410410
});
411411
});
412+
413+
describe("Sentry privacy boundary", () => {
414+
it("rejects arbitrary values in classification tags and caller fingerprints", () => {
415+
const secret = "SYNTHETIC_PRIVATE_MARKER";
416+
const sent = beforeSendCloudEvent({
417+
type: undefined,
418+
fingerprint: [secret],
419+
tags: {
420+
operation: secret,
421+
reason: secret,
422+
status: secret,
423+
otel_trace_id: secret,
424+
otel_span_id: secret,
425+
"mcp.do.cause_owner": secret,
426+
code: secret,
427+
"executor.ui.surface": secret,
428+
"executor.ui.action": secret,
429+
},
430+
exception: { values: [{ type: secret, value: secret }] },
431+
});
432+
expect(sent).not.toBeNull();
433+
expect(JSON.stringify(sent)).not.toContain(secret);
434+
expect(sent?.exception?.values?.[0]?.type).toBe("Error");
435+
});
436+
437+
it("retains known failure classifications and disables Sentry log payloads", () => {
438+
const options = cloudSentryOptions({ SENTRY_DSN: "https://public@example.invalid/1" } as Env);
439+
const sent = options.beforeSend({
440+
type: undefined,
441+
tags: { operation: "getOrganization", reason: "connect_timeout", status: 503 },
442+
});
443+
expect(sent?.tags).toEqual({
444+
operation: "getOrganization",
445+
reason: "connect_timeout",
446+
status: "503",
447+
});
448+
expect(options.enableLogs).toBe(false);
449+
expect(options.sendDefaultPii).toBe(false);
450+
});
451+
452+
it("retains storage classifications without SQL or raw causes", () => {
453+
const sent = beforeSendCloudEvent({
454+
type: undefined,
455+
tags: { operation: "connection.create", code: "22021" },
456+
exception: { values: [{ type: "StorageError", value: "private SQL and bound values" }] },
457+
extra: { cause: "private SQL and bound values" },
458+
});
459+
expect(sent?.tags).toEqual({ operation: "connection.create", code: "22021" });
460+
expect(sent?.exception?.values?.[0]).toMatchObject({
461+
type: "StorageError",
462+
value: "connection.create failed (22021)",
463+
});
464+
expect(JSON.stringify(sent)).not.toContain("private SQL");
465+
});
466+
467+
it("strips secrets from auto-captured errors while retaining diagnostic locations", () => {
468+
const secret = "SYNTHETIC_PRIVATE_MARKER";
469+
const sent = cloudSentryOptions({
470+
SENTRY_DSN: "https://public@example.invalid/1",
471+
} as Env).beforeSend({
472+
type: undefined,
473+
event_id: "safe-event-id",
474+
message: secret,
475+
user: { email: secret },
476+
request: {
477+
url: `https://example.test/?token=${secret}`,
478+
headers: { authorization: secret },
479+
data: secret,
480+
},
481+
extra: { cause: secret },
482+
breadcrumbs: [{ message: secret }],
483+
tags: { token: secret, otel_trace_id: traceId },
484+
exception: {
485+
values: [
486+
{
487+
type: "TypeError",
488+
value: secret,
489+
stacktrace: {
490+
frames: [
491+
{
492+
filename: `/assets/example.js?token=${secret}`,
493+
function: "handleRequest",
494+
lineno: 42,
495+
vars: { secret },
496+
},
497+
],
498+
},
499+
},
500+
],
501+
},
502+
});
503+
expect(JSON.stringify(sent)).not.toContain(secret);
504+
expect(sent?.event_id).toBe("safe-event-id");
505+
expect(sent?.tags?.otel_trace_id).toBe(traceId);
506+
expect(sent?.exception?.values?.[0]?.stacktrace?.frames?.[0]).toMatchObject({
507+
filename: "/assets/example.js",
508+
function: "handleRequest",
509+
lineno: 42,
510+
});
511+
});
512+
});

‎apps/cloud/src/observability/redact-span-urls.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,17 @@ const exportSpanWith = (
4747
};
4848

4949
describe("UrlRedactingSpanProcessor", () => {
50+
it("omits non-URL exception payloads recorded as span attributes", () => {
51+
const secret = "SYNTHETIC_PRIVATE_MARKER";
52+
const exported = exportSpanWith({
53+
"exception.message": secret,
54+
"exception.stacktrace": secret,
55+
"http.request.method": "POST",
56+
});
57+
expect(exported?.attributes["http.request.method"]).toBe("POST");
58+
expect(JSON.stringify(exported?.attributes)).not.toContain(secret);
59+
});
60+
5061
it("scrubs the span before the exporter sees it", () => {
5162
const exported = exportSpanWith({
5263
"url.full": callbackUrl,
@@ -284,3 +295,22 @@ describe("credential canary — no export channel carries the secret", () => {
284295
},
285296
);
286297
});
298+
299+
describe("non-URL secrets in exceptions", () => {
300+
it("does not export a provider response or SQL values as error text", () => {
301+
const secret = "synthetic-plain-secret";
302+
const exported = exportSpanWith({ "http.response.status_code": "500" }, (span) => {
303+
span.recordException({
304+
name: "ProviderError",
305+
message: `Failed query values: ${secret}`,
306+
stack: `at provider: ${secret}`,
307+
});
308+
span.setStatus({ code: SpanStatusCode.ERROR, message: secret });
309+
});
310+
expect(JSON.stringify({ events: exported?.events, status: exported?.status })).not.toContain(
311+
secret,
312+
);
313+
expect(exported?.status.code).toBe(SpanStatusCode.ERROR);
314+
expect(exported?.events[0]?.attributes?.["exception.type"]).toBe("ProviderError");
315+
});
316+
});

‎e2e/cloud/auth-evidence.test.ts‎

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { randomBytes } from "node:crypto";
2+
import { readFile, writeFile } from "node:fs/promises";
3+
import { join } from "node:path";
4+
5+
import { expect } from "@effect/vitest";
6+
import { Effect, Schema } from "effect";
7+
8+
import { RUNS_DIR, scenario } from "../src/scenario";
9+
import { RunDir, Target, Telemetry } from "../src/services";
10+
11+
const decodeString = Schema.decodeUnknownSync(Schema.String);
12+
13+
const decodeKey = Schema.decodeUnknownSync(
14+
Schema.Struct({ id: Schema.String, value: Schema.String }),
15+
);
16+
17+
scenario(
18+
"Authentication · valid credentials work in headers but not query parameters",
19+
{},
20+
Effect.gen(function* () {
21+
const target = yield* Target;
22+
const runDir = yield* RunDir;
23+
const identity = yield* target.newIdentity();
24+
const keyResponse = yield* Effect.promise(() =>
25+
fetch(new URL("/api/account/api-keys", target.baseUrl), {
26+
method: "POST",
27+
headers: {
28+
...identity.headers,
29+
origin: target.baseUrl,
30+
"content-type": "application/json",
31+
},
32+
body: JSON.stringify({ name: "authentication-evidence" }),
33+
}),
34+
);
35+
expect(keyResponse.status).toBe(200);
36+
const key = decodeKey(yield* Effect.promise(() => keyResponse.json()));
37+
yield* Effect.promise(async () => {
38+
const cases: Array<{ surface: string; carrier: string; status: number }> = [];
39+
for (const surface of ["api", "mcp"]) {
40+
const send = async (query: string | null, header: boolean) => {
41+
const url = new URL(surface === "api" ? "/api/policies" : "/mcp", target.baseUrl);
42+
if (query) url.searchParams.set(query, key.value);
43+
const response = await fetch(url, {
44+
method: surface === "api" ? "GET" : "POST",
45+
headers: {
46+
accept: "application/json, text/event-stream",
47+
"content-type": "application/json",
48+
...(header ? { authorization: `Bearer ${key.value}` } : {}),
49+
},
50+
...(surface === "mcp"
51+
? {
52+
body: JSON.stringify({
53+
jsonrpc: "2.0",
54+
id: 1,
55+
method: "initialize",
56+
params: {
57+
protocolVersion: "2025-03-26",
58+
capabilities: {},
59+
clientInfo: { name: "authentication-evidence", version: "1" },
60+
},
61+
}),
62+
}
63+
: {}),
64+
});
65+
await response.text();
66+
cases.push({
67+
surface,
68+
carrier: query ?? "Authorization header",
69+
status: response.status,
70+
});
71+
return response.status;
72+
};
73+
expect(await send(null, true), `${surface} accepts the valid header credential`).toBe(200);
74+
for (const query of [
75+
"api_key",
76+
"apikey",
77+
"key",
78+
"token",
79+
"access_token",
80+
"authorization",
81+
]) {
82+
expect(await send(query, false), `${surface} rejects query-only ${query}`).toBe(
83+
surface === "api" ? 403 : 401,
84+
);
85+
}
86+
}
87+
await writeFile(
88+
join(runDir, "authentication-carriers.json"),
89+
JSON.stringify({ cases }, null, 2),
90+
);
91+
}).pipe(
92+
Effect.ensuring(
93+
Effect.promise(async () => {
94+
const response = await fetch(new URL(`/api/account/api-keys/${key.id}`, target.baseUrl), {
95+
method: "DELETE",
96+
headers: { ...identity.headers, origin: target.baseUrl },
97+
});
98+
expect(response.status, "the disposable key is revoked").toBe(200);
99+
}),
100+
),
101+
);
102+
}),
103+
);
104+
105+
scenario(
106+
"Authentication · successful login exports diagnostics without its credentials",
107+
{},
108+
Effect.gen(function* () {
109+
const target = yield* Target;
110+
const telemetry = yield* Telemetry;
111+
const runDir = yield* RunDir;
112+
const identity = yield* target.newIdentity();
113+
const bootLog = join(RUNS_DIR, "cloud", "server-logs", "boot.log");
114+
const initialLogLength = (yield* Effect.promise(() => readFile(bootLog, "utf8"))).length;
115+
const traceId = randomBytes(16).toString("hex");
116+
const headers = { traceparent: `00-${traceId}-${randomBytes(8).toString("hex")}-01` };
117+
const credentials = yield* Effect.promise(async () => {
118+
const login = await fetch(new URL("/api/auth/login", target.baseUrl), { redirect: "manual" });
119+
expect(login.status).toBe(302);
120+
expect(login.headers.get("referrer-policy")).toBe("no-referrer");
121+
const authorize = new URL(decodeString(login.headers.get("location")));
122+
const state = decodeString(authorize.searchParams.get("state"));
123+
const stateCookie = login.headers
124+
.getSetCookie()
125+
.find((cookie) => cookie.startsWith("wos-login-state="));
126+
expect(stateCookie !== undefined).toBe(true);
127+
authorize.searchParams.set("login_hint", identity.label);
128+
const consent = await fetch(authorize, { redirect: "manual" });
129+
expect(consent.status).toBe(302);
130+
const callback = new URL(decodeString(consent.headers.get("location")));
131+
const code = decodeString(callback.searchParams.get("code"));
132+
const signedIn = await fetch(callback, {
133+
redirect: "manual",
134+
headers: {
135+
...headers,
136+
cookie: decodeString(stateCookie).split(";")[0] ?? "",
137+
},
138+
});
139+
expect(signedIn.status).toBe(302);
140+
expect(signedIn.headers.get("referrer-policy")).toBe("no-referrer");
141+
const session = signedIn.headers
142+
.getSetCookie()
143+
.find((cookie) => cookie.startsWith("wos-session="));
144+
const sessionPair = decodeString(session).split(";")[0] ?? "";
145+
const verified = await fetch(new URL("/api/auth/me", target.baseUrl), {
146+
headers: { cookie: sessionPair },
147+
});
148+
expect(verified.status).toBe(200);
149+
return [state, code, sessionPair.slice("wos-session=".length)];
150+
});
151+
yield* telemetry.expectSpan({ traceId });
152+
const spans = yield* telemetry.searchSpans({ traceId });
153+
const exported = JSON.stringify(spans);
154+
const logs = (yield* Effect.promise(() => readFile(bootLog, "utf8"))).slice(initialLogLength);
155+
const matches = credentials.map((credential) => ({
156+
trace: exported.includes(credential),
157+
serverLog: logs.includes(credential),
158+
}));
159+
// Assert booleans so even a failure cannot print a credential.
160+
expect(matches.every((match) => !match.trace && !match.serverLog)).toBe(true);
161+
yield* Effect.promise(() =>
162+
writeFile(
163+
join(runDir, "login-diagnostics.json"),
164+
JSON.stringify(
165+
{
166+
environment: "isolated cloud Worker with WorkOS emulator",
167+
loginStatus: 302,
168+
authenticatedSessionStatus: 200,
169+
traceId,
170+
exportedSpanCount: spans.length,
171+
checkedCredentials: ["OAuth state", "authorization code", "sealed session cookie"],
172+
credentialMatches: matches,
173+
sample: spans.map(({ span }) => ({
174+
operation: span.operationName,
175+
status: span.status,
176+
attributeNames: Object.keys(span.tags),
177+
})),
178+
},
179+
null,
180+
2,
181+
),
182+
),
183+
);
184+
}),
185+
);

0 commit comments

Comments
 (0)