diff --git a/apps/cloud/src/auth/access-token-options.test.ts b/apps/cloud/src/auth/access-token-options.test.ts new file mode 100644 index 0000000000..57c6a32112 --- /dev/null +++ b/apps/cloud/src/auth/access-token-options.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "@effect/vitest"; +import { SignJWT, jwtVerify } from "jose"; +import { workosAccessTokenOptions } from "./access-token-options"; + +describe("WorkOS token age boundary", () => { + it("accepts a fresh token, rejects it after 24 hours, and rejects future issuance", async () => { + const key = new TextEncoder().encode("synthetic-signing-key-for-unit-test-only"); + const issuedAt = 1_700_000_000; + const token = await new SignJWT({}) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(issuedAt) + .setExpirationTime(issuedAt + 7 * 86400) + .sign(key); + await expect( + jwtVerify(token, key, { + ...workosAccessTokenOptions, + currentDate: new Date((issuedAt + 86399) * 1000), + }), + ).resolves.toHaveProperty("payload.iat", issuedAt); + await expect( + jwtVerify(token, key, { + ...workosAccessTokenOptions, + currentDate: new Date((issuedAt + 86401) * 1000), + }), + ).rejects.toHaveProperty("code", "ERR_JWT_EXPIRED"); + await expect( + jwtVerify(token, key, { + ...workosAccessTokenOptions, + currentDate: new Date((issuedAt - 1) * 1000), + }), + ).rejects.toHaveProperty("code", "ERR_JWT_CLAIM_VALIDATION_FAILED"); + }); +}); diff --git a/apps/cloud/src/auth/workos.node.test.ts b/apps/cloud/src/auth/workos.node.test.ts index cadeb1078b..c674957541 100644 --- a/apps/cloud/src/auth/workos.node.test.ts +++ b/apps/cloud/src/auth/workos.node.test.ts @@ -59,6 +59,7 @@ const signAccessToken = ( readonly organizationId?: string; readonly sessionId?: string; readonly expiresIn?: string | number; + readonly issuedAt?: number; } = {}, ) => { const jwt = new SignJWT({ @@ -67,7 +68,7 @@ const signAccessToken = ( }) .setProtectedHeader({ alg: "RS256", kid: keypair.kid }) .setSubject(claims.subject ?? USER.id) - .setIssuedAt(); + .setIssuedAt(claims.issuedAt); return ( typeof claims.expiresIn === "number" @@ -317,6 +318,21 @@ describe("authenticateSealedSession", () => { }); }); + it("refreshes a token beyond the local age limit even when WorkOS exp is later", async () => { + const keypair = await generateKeypair("k_old"); + await withWorkOSStub(keypair, async (stub) => { + const now = Math.floor(Date.now() / 1000); + const token = await signAccessToken(keypair, { + issuedAt: now - 86401, + expiresIn: now + 86400, + }); + const result = await runAuthenticate(await sealSession(token), stub.baseUrl); + expect(result?.sessionId).toBe("session_refreshed"); + expect(result?.refreshedSession).toEqual(expect.any(String)); + expect(stub.requests()[1]?.body).toMatchObject({ grant_type: "refresh_token" }); + }); + }); + it("returns null for garbage session data", async () => { const keypair = await generateKeypair("k_garbage"); await withWorkOSStub(keypair, async (stub) => { diff --git a/apps/cloud/src/mcp/mcp-auth.node.test.ts b/apps/cloud/src/mcp/mcp-auth.node.test.ts index bd47c3f92d..8cbf6afd01 100644 --- a/apps/cloud/src/mcp/mcp-auth.node.test.ts +++ b/apps/cloud/src/mcp/mcp-auth.node.test.ts @@ -151,7 +151,7 @@ describe("access token expiry and identity boundaries", () => { }), ); } - it.effect(`${kind} accepts a token issued more than a day ago that has not expired`, () => + it.effect(`${kind} rejects a token issued more than a day ago even when exp is later`, () => Effect.gen(function* () { const { publicKey, privateKey } = yield* Effect.promise(() => generateKeyPair("RS256")); const jwk = yield* Effect.promise(() => exportJWK(publicKey)); @@ -169,10 +169,13 @@ describe("access token expiry and identity boundaries", () => { .setProtectedHeader({ alg: "RS256", kid: "expiry-key" }) .sign(privateKey), ); - const verified = yield* kind === "mcp" - ? verifyMcpAccessToken(token, jwks, { issuer, audience: resource }) - : verifyWorkosUserManagementToken(token, jwks); - expect(verified).toEqual({ accountId: "user_test", organizationId: "org_test" }); + const error = yield* Effect.flip( + kind === "mcp" + ? verifyMcpAccessToken(token, jwks, { issuer, audience: resource }) + : verifyWorkosUserManagementToken(token, jwks), + ); + expect(error).toBeInstanceOf(McpJwtVerificationError); + expect(error.reason).toBe("expired"); }), ); it.effect(`${kind} rejects a non-string organization claim`, () => diff --git a/apps/cloud/src/observability/observability.test.ts b/apps/cloud/src/observability/observability.test.ts index 581377d12f..ae38a40638 100644 --- a/apps/cloud/src/observability/observability.test.ts +++ b/apps/cloud/src/observability/observability.test.ts @@ -409,3 +409,51 @@ describe("Durable Object platform reset noise", () => { expect(options.beforeSend(event)).toBeNull(); }); }); + +describe("Sentry privacy boundary", () => { + it("strips secrets from auto-captured errors while retaining diagnostic locations", () => { + const secret = "SYNTHETIC_PRIVATE_MARKER"; + const sent = cloudSentryOptions({ + SENTRY_DSN: "https://public@example.invalid/1", + } as Env).beforeSend({ + type: undefined, + event_id: "safe-event-id", + message: secret, + user: { email: secret }, + request: { + url: `https://example.test/?token=${secret}`, + headers: { authorization: secret }, + data: secret, + }, + extra: { cause: secret }, + breadcrumbs: [{ message: secret }], + tags: { token: secret, otel_trace_id: traceId }, + exception: { + values: [ + { + type: "TypeError", + value: secret, + stacktrace: { + frames: [ + { + filename: `/assets/example.js?token=${secret}`, + function: "handleRequest", + lineno: 42, + vars: { secret }, + }, + ], + }, + }, + ], + }, + }); + expect(JSON.stringify(sent)).not.toContain(secret); + expect(sent?.event_id).toBe("safe-event-id"); + expect(sent?.tags?.otel_trace_id).toBe(traceId); + expect(sent?.exception?.values?.[0]?.stacktrace?.frames?.[0]).toMatchObject({ + filename: "/assets/example.js", + function: "handleRequest", + lineno: 42, + }); + }); +}); diff --git a/apps/cloud/src/observability/redact-span-urls.test.ts b/apps/cloud/src/observability/redact-span-urls.test.ts index 288c0f65ce..65becc251f 100644 --- a/apps/cloud/src/observability/redact-span-urls.test.ts +++ b/apps/cloud/src/observability/redact-span-urls.test.ts @@ -216,13 +216,13 @@ describe("UrlRedactingSpanProcessor", () => { span.setStatus({ code: SpanStatusCode.ERROR, message }); }); - // Non-vacuous: the exception event exists and kept its scrubbed URL. + // The exception event and classification survive without raw error text. const events = JSON.stringify(exported?.events); expect(events).toContain("exception"); - expect(events).toContain("https://api.test/graphql"); + expect(events).toContain("[REDACTED]"); expect(events).not.toContain("synthetic-userinfo-secret"); expect(events).not.toContain("synthetic-key-secret"); - expect(exported?.status.message).toBe("Transport: fetch failed (GET https://api.test/graphql)"); + expect(exported?.status.message).toBe("[REDACTED]"); }); }); @@ -284,3 +284,22 @@ describe("credential canary — no export channel carries the secret", () => { }, ); }); + +describe("non-URL secrets in exceptions", () => { + it("does not export a provider response or SQL values as error text", () => { + const secret = "synthetic-plain-secret"; + const exported = exportSpanWith({ "http.response.status_code": "500" }, (span) => { + span.recordException({ + name: "ProviderError", + message: `Failed query values: ${secret}`, + stack: `at provider: ${secret}`, + }); + span.setStatus({ code: SpanStatusCode.ERROR, message: secret }); + }); + expect(JSON.stringify({ events: exported?.events, status: exported?.status })).not.toContain( + secret, + ); + expect(exported?.status.code).toBe(SpanStatusCode.ERROR); + expect(exported?.events[0]?.attributes?.["exception.type"]).toBe("ProviderError"); + }); +}); diff --git a/e2e/cloud/auth-evidence.test.ts b/e2e/cloud/auth-evidence.test.ts new file mode 100644 index 0000000000..4b6bdc2307 --- /dev/null +++ b/e2e/cloud/auth-evidence.test.ts @@ -0,0 +1,183 @@ +import { randomBytes } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "@effect/vitest"; +import { Effect, Schema } from "effect"; + +import { RUNS_DIR, scenario } from "../src/scenario"; +import { RunDir, Target, Telemetry } from "../src/services"; + +const decodeString = Schema.decodeUnknownSync(Schema.String); + +const decodeKey = Schema.decodeUnknownSync( + Schema.Struct({ id: Schema.String, value: Schema.String }), +); + +scenario( + "Authentication · valid credentials work in headers but not query parameters", + {}, + Effect.gen(function* () { + const target = yield* Target; + const runDir = yield* RunDir; + const identity = yield* target.newIdentity({ adminMfa: false }); + const keyResponse = yield* Effect.promise(() => + fetch(new URL("/api/account/api-keys", target.baseUrl), { + method: "POST", + headers: { + ...identity.headers, + origin: target.baseUrl, + "content-type": "application/json", + }, + body: JSON.stringify({ name: "authentication-evidence" }), + }), + ); + expect(keyResponse.status).toBe(200); + const key = decodeKey(yield* Effect.promise(() => keyResponse.json())); + yield* Effect.promise(async () => { + const cases: Array<{ surface: string; carrier: string; status: number }> = []; + for (const surface of ["api", "mcp"]) { + const send = async (query: string | null, header: boolean) => { + const url = new URL(surface === "api" ? "/api/policies" : "/mcp", target.baseUrl); + if (query) url.searchParams.set(query, key.value); + const response = await fetch(url, { + method: surface === "api" ? "GET" : "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + ...(header ? { authorization: `Bearer ${key.value}` } : {}), + }, + ...(surface === "mcp" + ? { + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "authentication-evidence", version: "1" }, + }, + }), + } + : {}), + }); + await response.text(); + cases.push({ + surface, + carrier: query ?? "Authorization header", + status: response.status, + }); + return response.status; + }; + expect(await send(null, true), `${surface} accepts the valid header credential`).toBe(200); + for (const query of [ + "api_key", + "apikey", + "key", + "token", + "access_token", + "authorization", + ]) { + expect(await send(query, false), `${surface} rejects query-only ${query}`).toBe( + surface === "api" ? 403 : 401, + ); + } + } + await writeFile( + join(runDir, "authentication-carriers.json"), + JSON.stringify({ cases }, null, 2), + ); + }).pipe( + Effect.ensuring( + Effect.promise(async () => { + const response = await fetch(new URL(`/api/account/api-keys/${key.id}`, target.baseUrl), { + method: "DELETE", + headers: { ...identity.headers, origin: target.baseUrl }, + }); + expect(response.status, "the disposable key is revoked").toBe(200); + }), + ), + ); + }), +); + +scenario( + "Authentication · successful login exports diagnostics without its credentials", + {}, + Effect.gen(function* () { + const target = yield* Target; + const telemetry = yield* Telemetry; + const runDir = yield* RunDir; + const identity = yield* target.newIdentity({ adminMfa: false }); + const bootLog = join(RUNS_DIR, "cloud", "server-logs", "boot.log"); + const initialLogLength = (yield* Effect.promise(() => readFile(bootLog, "utf8"))).length; + const traceId = randomBytes(16).toString("hex"); + const headers = { traceparent: `00-${traceId}-${randomBytes(8).toString("hex")}-01` }; + const credentials = yield* Effect.promise(async () => { + const login = await fetch(new URL("/api/auth/login", target.baseUrl), { redirect: "manual" }); + expect(login.status).toBe(302); + const authorize = new URL(decodeString(login.headers.get("location"))); + const state = decodeString(authorize.searchParams.get("state")); + const stateCookie = login.headers + .getSetCookie() + .find((cookie) => cookie.startsWith("wos-login-state=")); + expect(stateCookie !== undefined).toBe(true); + authorize.searchParams.set("login_hint", identity.label); + const consent = await fetch(authorize, { redirect: "manual" }); + expect(consent.status).toBe(302); + const callback = new URL(decodeString(consent.headers.get("location"))); + const code = decodeString(callback.searchParams.get("code")); + const signedIn = await fetch(callback, { + redirect: "manual", + headers: { + ...headers, + cookie: decodeString(stateCookie).split(";")[0] ?? "", + }, + }); + expect(signedIn.status).toBe(302); + const session = signedIn.headers + .getSetCookie() + .find((cookie) => cookie.startsWith("wos-session=")); + const sessionPair = decodeString(session).split(";")[0] ?? ""; + const verified = await fetch(new URL("/api/auth/me", target.baseUrl), { + headers: { cookie: sessionPair }, + }); + expect(verified.status).toBe(200); + return [state, code, sessionPair.slice("wos-session=".length)]; + }); + yield* telemetry.expectSpan({ traceId }); + const spans = yield* telemetry.searchSpans({ traceId }); + const exported = JSON.stringify(spans); + const logs = (yield* Effect.promise(() => readFile(bootLog, "utf8"))).slice(initialLogLength); + const matches = credentials.map((credential) => ({ + trace: exported.includes(credential), + serverLog: logs.includes(credential), + })); + // Assert booleans so even a failure cannot print a credential. + expect(matches.every((match) => !match.trace && !match.serverLog)).toBe(true); + yield* Effect.promise(() => + writeFile( + join(runDir, "login-diagnostics.json"), + JSON.stringify( + { + environment: "isolated cloud Worker with WorkOS emulator", + loginStatus: 302, + authenticatedSessionStatus: 200, + traceId, + exportedSpanCount: spans.length, + checkedCredentials: ["OAuth state", "authorization code", "sealed session cookie"], + credentialMatches: matches, + sample: spans.map(({ span }) => ({ + operation: span.operationName, + status: span.status, + attributeNames: Object.keys(span.tags), + })), + }, + null, + 2, + ), + ), + ); + }), +); diff --git a/e2e/scenarios/artifact-preview-xss.test.ts b/e2e/scenarios/artifact-preview-xss.test.ts new file mode 100644 index 0000000000..66eb1fa5f5 --- /dev/null +++ b/e2e/scenarios/artifact-preview-xss.test.ts @@ -0,0 +1,76 @@ +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { AccountHttpApi } from "@executor-js/api"; +import { composePluginApi } from "@executor-js/api/server"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; + +const coreApi = composePluginApi([] as const); + +scenario( + "Artifacts · uploaded previews stay inert after storage and reload", + { timeout: 120_000 }, + Effect.gen(function* () { + const target = yield* Target; + const api = yield* Api; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + const client = yield* api.client(coreApi, identity); + const account = yield* api.client(AccountHttpApi, identity); + const me = yield* account.account.me(); + const title = "Preview security check"; + const marker = "Safe preview content"; + const artifact = yield* client.artifacts.save({ + payload: { + title, + code: "function App() { return
Preview security check
; }", + }, + }); + + yield* Effect.gen(function* () { + const uploaded = yield* client.artifacts.setPreview({ + params: { artifactId: artifact.id }, + payload: { + preview: + `
${marker}` + + "" + + '' + + "" + + "Unsafe link
", + }, + }); + expect(uploaded.stored).toBe(true); + const saved = yield* client.artifacts.get({ params: { artifactId: artifact.id } }); + expect(saved.preview?.markup).toBe(`
${marker}Unsafe link
`); + + yield* browser.session(identity, async ({ page, step }) => { + const galleryPath = me.organization?.slug + ? `/${me.organization.slug}/artifacts` + : "/artifacts"; + const card = page.locator('[data-slot="artifact-card"]').filter({ hasText: title }); + const preview = card.locator('[data-slot="artifact-preview"]'); + const checkPreview = async () => { + await preview.getByText(`${marker}Unsafe link`, { exact: true }).waitFor(); + expect( + await preview.locator("script, img, iframe, a, [onclick], [onerror]").count(), + ).toBe(0); + expect(await page.evaluate(() => document.body.dataset.previewXss)).toBeUndefined(); + }; + await step("Open the saved preview with injected markup removed", async () => { + await visit(page, `${target.baseUrl}${galleryPath}`); + await checkPreview(); + }); + await step("Reload and verify the stored preview remains inert", async () => { + await page.reload(); + await checkPreview(); + }); + }); + }).pipe( + Effect.ensuring( + client.artifacts.remove({ params: { artifactId: artifact.id } }).pipe(Effect.ignore), + ), + ); + }), +); diff --git a/packages/core/sdk/src/connections.test.ts b/packages/core/sdk/src/connections.test.ts index 1fd653c093..28db1f9618 100644 --- a/packages/core/sdk/src/connections.test.ts +++ b/packages/core/sdk/src/connections.test.ts @@ -2630,10 +2630,11 @@ describe("tool catalog sync safety", () => { ); expect(failureWarning).toBeDefined(); expect(failureWarning).toContain("broken"); - // Both halves: the failure and the cause that names what to fix. A bare - // structural render of the error drops the cause entirely. - expect(failureWarning).toContain("upstream listing refused"); - expect(failureWarning).toContain("connect ECONNREFUSED"); + // Keep the failure class and affected connection, without raw provider + // text or nested causes that can carry credentials or SQL values. + expect(failureWarning).toContain("StorageError"); + expect(failureWarning).not.toContain("upstream listing refused"); + expect(failureWarning).not.toContain("connect ECONNREFUSED"); // The healthy peer is not swept into the failure. expect(failureWarning).not.toContain("healthy"); }), diff --git a/packages/plugins/graphql/src/sdk/introspect-credential-logging.test.ts b/packages/plugins/graphql/src/sdk/introspect-credential-logging.test.ts index 24950c34c8..0727baff0d 100644 --- a/packages/plugins/graphql/src/sdk/introspect-credential-logging.test.ts +++ b/packages/plugins/graphql/src/sdk/introspect-credential-logging.test.ts @@ -31,7 +31,8 @@ const ENDPOINT = "https://graph.example.test/graphql"; * live `message` getter, which is the exact path the leak took. */ const capturingLogger = (sink: Array) => Logger.make((options) => { - sink.push(String(options.message)); + // Preserve structured log fields so the secret check covers them too. + sink.push(JSON.stringify(options.message)); sink.push(Cause.pretty(options.cause)); }); diff --git a/packages/plugins/graphql/src/sdk/introspect-large-response.test.ts b/packages/plugins/graphql/src/sdk/introspect-large-response.test.ts new file mode 100644 index 0000000000..e42610d514 --- /dev/null +++ b/packages/plugins/graphql/src/sdk/introspect-large-response.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import { introspect } from "./introspect"; + +describe("GraphQL large introspection compatibility", () => { + it.effect("accepts a valid response larger than 32 MiB", () => + Effect.gen(function* () { + const description = "x".repeat(33 * 1024 * 1024); + const schema = { + queryType: { name: "Query" }, + mutationType: null, + types: [ + { + kind: "OBJECT", + name: "Query", + description, + fields: [], + inputFields: null, + enumValues: null, + }, + ], + }; + const client = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb(request, Response.json({ data: { __schema: schema } })), + ), + ); + const result = yield* introspect("https://example.test/graphql").pipe( + Effect.provide(Layer.succeed(HttpClient.HttpClient)(client)), + ); + expect(result.__schema.queryType).toEqual({ name: "Query" }); + expect(result.__schema.types[0]?.description?.length).toBe(description.length); + }), + ); +});