From 44ecb666f218375e5b9bbe90321b6a09ac32ca35 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:11:13 -0700 Subject: [PATCH 1/5] Test hosted token limits import bounds and diagnostic redaction --- .../src/auth/access-token-options.test.ts | 33 ++++++++++ apps/cloud/src/auth/workos.node.test.ts | 18 +++++- apps/cloud/src/mcp/mcp-auth.node.test.ts | 13 ++-- .../src/observability/observability.test.ts | 48 ++++++++++++++ .../observability/redact-span-urls.test.ts | 25 +++++++- apps/cloud/src/request-limits.test.ts | 63 +++++++++++++++++++ e2e/cloud/request-body-limits.test.ts | 27 ++++++++ .../src/sdk/introspect-body-limit.test.ts | 34 ++++++++++ .../sdk/introspect-credential-logging.test.ts | 3 +- .../plugins/openapi/src/sdk/parse.test.ts | 23 +++++++ 10 files changed, 277 insertions(+), 10 deletions(-) create mode 100644 apps/cloud/src/auth/access-token-options.test.ts create mode 100644 apps/cloud/src/request-limits.test.ts create mode 100644 e2e/cloud/request-body-limits.test.ts create mode 100644 packages/plugins/graphql/src/sdk/introspect-body-limit.test.ts 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/apps/cloud/src/request-limits.test.ts b/apps/cloud/src/request-limits.test.ts new file mode 100644 index 0000000000..b52a6000e9 --- /dev/null +++ b/apps/cloud/src/request-limits.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "@effect/vitest"; +import { limitRequestBody } from "./request-limits"; + +const streamedRequest = (chunks: readonly string[], headers?: HeadersInit) => { + let index = 0; + let cancelled = false; + const stream = new ReadableStream({ + pull(controller) { + const value = chunks[index++]; + if (value === undefined) controller.close(); + else controller.enqueue(new TextEncoder().encode(value)); + }, + cancel() { + cancelled = true; + }, + }); + return { + request: new Request("https://example.test/api/import?format=json", { + method: "POST", + headers, + body: stream, + duplex: "half", + } as RequestInit), + cancelled: () => cancelled, + }; +}; + +describe("request body boundary", () => { + it("preserves body, URL, method and headers at the exact byte limit", async () => { + const { request } = streamedRequest(['{"a":', '"é"}'], { + "content-type": "application/json", + authorization: "Bearer fixture", + }); + const limited = await limitRequestBody(request, 10); + expect(limited).toBeInstanceOf(Request); + if (!(limited instanceof Request)) return; + expect(limited.url).toBe(request.url); + expect(limited.method).toBe("POST"); + expect(limited.headers.get("authorization")).toBe("Bearer fixture"); + expect(await limited.json()).toEqual({ a: "é" }); + }); + for (const headers of [undefined, { "content-length": "1" }]) { + it(`rejects oversized streamed bodies with ${headers ? "understated" : "absent"} length`, async () => { + const source = streamedRequest(["123", "456", "789", "more"], headers); + const response = await limitRequestBody(source.request, 5); + expect(response).toBeInstanceOf(Response); + if (!(response instanceof Response)) return; + expect(response.status).toBe(413); + expect(await response.json()).toEqual({ error: "Request body too large" }); + expect(source.cancelled()).toBe(true); + }); + } + it("rejects an oversized declared length before reading", async () => { + const source = streamedRequest(["body"], { "content-length": "100" }); + const response = await limitRequestBody(source.request, 5); + expect(response instanceof Response && response.status).toBe(413); + expect(source.cancelled()).toBe(true); + }); + it("passes bodyless requests through", async () => { + const request = new Request("https://example.test/mcp"); + expect(await limitRequestBody(request, 5)).toBe(request); + }); +}); diff --git a/e2e/cloud/request-body-limits.test.ts b/e2e/cloud/request-body-limits.test.ts new file mode 100644 index 0000000000..83861fc20b --- /dev/null +++ b/e2e/cloud/request-body-limits.test.ts @@ -0,0 +1,27 @@ +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { scenario } from "../src/scenario"; +import { Target } from "../src/services"; + +scenario( + "Request limits · API and MCP reject oversized bodies before parsing", + { timeout: 120_000 }, + Effect.gen(function* () { + const target = yield* Target; + for (const path of ["/api/integrations", "/mcp"]) { + const response = yield* Effect.promise(() => + fetch(new URL(path, target.baseUrl), { + method: "POST", + headers: { "content-type": "application/json" }, + body: "x".repeat(32 * 1024 * 1024 + 1), + }), + ); + expect(response.status).toBe(413); + expect(yield* Effect.promise(() => response.json())).toEqual({ + error: "Request body too large", + }); + } + const health = yield* Effect.promise(() => fetch(new URL("/api/account/me", target.baseUrl))); + expect(health.status).toBe(401); + }), +); diff --git a/packages/plugins/graphql/src/sdk/introspect-body-limit.test.ts b/packages/plugins/graphql/src/sdk/introspect-body-limit.test.ts new file mode 100644 index 0000000000..68fe03c958 --- /dev/null +++ b/packages/plugins/graphql/src/sdk/introspect-body-limit.test.ts @@ -0,0 +1,34 @@ +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 introspection body limit", () => { + for (const status of [200, 500]) { + it.effect(`cancels an oversized streamed ${status} response before parsing`, () => + Effect.gen(function* () { + let cancelled = false; + const response = new Response( + new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array(1024 * 1024)); + }, + cancel() { + cancelled = true; + }, + }), + { status, headers: { "content-length": "1" } }, + ); + const client = HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, response)), + ); + const error = yield* introspect("https://example.test/graphql").pipe( + Effect.provide(Layer.succeed(HttpClient.HttpClient)(client)), + Effect.flip, + ); + expect(error).toHaveProperty("reason", "response-too-large"); + expect(cancelled).toBe(true); + }), + ); + } +}); 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/openapi/src/sdk/parse.test.ts b/packages/plugins/openapi/src/sdk/parse.test.ts index 039f51534a..f4f4da55cd 100644 --- a/packages/plugins/openapi/src/sdk/parse.test.ts +++ b/packages/plugins/openapi/src/sdk/parse.test.ts @@ -155,6 +155,29 @@ describe("OpenAPI fetchSpecText", () => { }), ); + it.effect("bounds a streamed response even when Content-Length understates it", () => + Effect.gen(function* () { + let cancelled = false; + const response = new Response( + new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array(1024 * 1024)); + }, + cancel() { + cancelled = true; + }, + }), + { headers: { "content-length": "1" } }, + ); + const error = yield* fetchSpecText(specUrl).pipe( + Effect.provide(layerWithResponse(response)), + Effect.flip, + ); + expect(error).toHaveProperty("message", expect.stringMatching(/too large to parse/)); + expect(cancelled).toBe(true); + }), + ); + it.effect("fetches a document with an in-range declared length", () => Effect.gen(function* () { const specText = yield* fetchSpecText(specUrl).pipe( From 2cd7d4804d23f9db780accd6c3843293b64b377b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:12:54 -0700 Subject: [PATCH 2/5] Verify tool sync warnings omit raw upstream failures --- packages/core/sdk/src/connections.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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"); }), From 6e9c72c1b5ebabb8b797b1e693a070836569545b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:14:17 -0700 Subject: [PATCH 3/5] Exercise chunked request limits in the Worker --- e2e/cloud/request-body-limits.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/e2e/cloud/request-body-limits.test.ts b/e2e/cloud/request-body-limits.test.ts index 83861fc20b..147d3144c3 100644 --- a/e2e/cloud/request-body-limits.test.ts +++ b/e2e/cloud/request-body-limits.test.ts @@ -21,6 +21,27 @@ scenario( error: "Request body too large", }); } + // A streamed request has no Content-Length; the Worker must count real bytes. + let chunks = 0; + const stream = new ReadableStream({ + pull(controller) { + if (chunks++ < 33) controller.enqueue(new Uint8Array(1024 * 1024)); + else controller.close(); + }, + }); + const streamedRequest = { + method: "POST", + headers: { "content-type": "application/json" }, + body: stream, + duplex: "half", + }; + const streamedResponse = yield* Effect.promise(() => + fetch(new URL("/mcp", target.baseUrl), streamedRequest), + ); + expect(streamedResponse.status).toBe(413); + expect(yield* Effect.promise(() => streamedResponse.json())).toEqual({ + error: "Request body too large", + }); const health = yield* Effect.promise(() => fetch(new URL("/api/account/me", target.baseUrl))); expect(health.status).toBe(401); }), From ae50793dfc735e2b355109e46918901ba27aa5fb Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:29:01 -0700 Subject: [PATCH 4/5] Verify stored preview safety and large import compatibility --- apps/cloud/src/request-limits.test.ts | 63 --------------- e2e/cloud/request-body-limits.test.ts | 48 ------------ e2e/scenarios/artifact-preview-xss.test.ts | 76 +++++++++++++++++++ .../src/sdk/introspect-body-limit.test.ts | 34 --------- .../src/sdk/introspect-large-response.test.ts | 37 +++++++++ .../plugins/openapi/src/sdk/parse.test.ts | 23 ------ 6 files changed, 113 insertions(+), 168 deletions(-) delete mode 100644 apps/cloud/src/request-limits.test.ts delete mode 100644 e2e/cloud/request-body-limits.test.ts create mode 100644 e2e/scenarios/artifact-preview-xss.test.ts delete mode 100644 packages/plugins/graphql/src/sdk/introspect-body-limit.test.ts create mode 100644 packages/plugins/graphql/src/sdk/introspect-large-response.test.ts diff --git a/apps/cloud/src/request-limits.test.ts b/apps/cloud/src/request-limits.test.ts deleted file mode 100644 index b52a6000e9..0000000000 --- a/apps/cloud/src/request-limits.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { limitRequestBody } from "./request-limits"; - -const streamedRequest = (chunks: readonly string[], headers?: HeadersInit) => { - let index = 0; - let cancelled = false; - const stream = new ReadableStream({ - pull(controller) { - const value = chunks[index++]; - if (value === undefined) controller.close(); - else controller.enqueue(new TextEncoder().encode(value)); - }, - cancel() { - cancelled = true; - }, - }); - return { - request: new Request("https://example.test/api/import?format=json", { - method: "POST", - headers, - body: stream, - duplex: "half", - } as RequestInit), - cancelled: () => cancelled, - }; -}; - -describe("request body boundary", () => { - it("preserves body, URL, method and headers at the exact byte limit", async () => { - const { request } = streamedRequest(['{"a":', '"é"}'], { - "content-type": "application/json", - authorization: "Bearer fixture", - }); - const limited = await limitRequestBody(request, 10); - expect(limited).toBeInstanceOf(Request); - if (!(limited instanceof Request)) return; - expect(limited.url).toBe(request.url); - expect(limited.method).toBe("POST"); - expect(limited.headers.get("authorization")).toBe("Bearer fixture"); - expect(await limited.json()).toEqual({ a: "é" }); - }); - for (const headers of [undefined, { "content-length": "1" }]) { - it(`rejects oversized streamed bodies with ${headers ? "understated" : "absent"} length`, async () => { - const source = streamedRequest(["123", "456", "789", "more"], headers); - const response = await limitRequestBody(source.request, 5); - expect(response).toBeInstanceOf(Response); - if (!(response instanceof Response)) return; - expect(response.status).toBe(413); - expect(await response.json()).toEqual({ error: "Request body too large" }); - expect(source.cancelled()).toBe(true); - }); - } - it("rejects an oversized declared length before reading", async () => { - const source = streamedRequest(["body"], { "content-length": "100" }); - const response = await limitRequestBody(source.request, 5); - expect(response instanceof Response && response.status).toBe(413); - expect(source.cancelled()).toBe(true); - }); - it("passes bodyless requests through", async () => { - const request = new Request("https://example.test/mcp"); - expect(await limitRequestBody(request, 5)).toBe(request); - }); -}); diff --git a/e2e/cloud/request-body-limits.test.ts b/e2e/cloud/request-body-limits.test.ts deleted file mode 100644 index 147d3144c3..0000000000 --- a/e2e/cloud/request-body-limits.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { expect } from "@effect/vitest"; -import { Effect } from "effect"; -import { scenario } from "../src/scenario"; -import { Target } from "../src/services"; - -scenario( - "Request limits · API and MCP reject oversized bodies before parsing", - { timeout: 120_000 }, - Effect.gen(function* () { - const target = yield* Target; - for (const path of ["/api/integrations", "/mcp"]) { - const response = yield* Effect.promise(() => - fetch(new URL(path, target.baseUrl), { - method: "POST", - headers: { "content-type": "application/json" }, - body: "x".repeat(32 * 1024 * 1024 + 1), - }), - ); - expect(response.status).toBe(413); - expect(yield* Effect.promise(() => response.json())).toEqual({ - error: "Request body too large", - }); - } - // A streamed request has no Content-Length; the Worker must count real bytes. - let chunks = 0; - const stream = new ReadableStream({ - pull(controller) { - if (chunks++ < 33) controller.enqueue(new Uint8Array(1024 * 1024)); - else controller.close(); - }, - }); - const streamedRequest = { - method: "POST", - headers: { "content-type": "application/json" }, - body: stream, - duplex: "half", - }; - const streamedResponse = yield* Effect.promise(() => - fetch(new URL("/mcp", target.baseUrl), streamedRequest), - ); - expect(streamedResponse.status).toBe(413); - expect(yield* Effect.promise(() => streamedResponse.json())).toEqual({ - error: "Request body too large", - }); - const health = yield* Effect.promise(() => fetch(new URL("/api/account/me", target.baseUrl))); - expect(health.status).toBe(401); - }), -); 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/plugins/graphql/src/sdk/introspect-body-limit.test.ts b/packages/plugins/graphql/src/sdk/introspect-body-limit.test.ts deleted file mode 100644 index 68fe03c958..0000000000 --- a/packages/plugins/graphql/src/sdk/introspect-body-limit.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 introspection body limit", () => { - for (const status of [200, 500]) { - it.effect(`cancels an oversized streamed ${status} response before parsing`, () => - Effect.gen(function* () { - let cancelled = false; - const response = new Response( - new ReadableStream({ - pull(controller) { - controller.enqueue(new Uint8Array(1024 * 1024)); - }, - cancel() { - cancelled = true; - }, - }), - { status, headers: { "content-length": "1" } }, - ); - const client = HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, response)), - ); - const error = yield* introspect("https://example.test/graphql").pipe( - Effect.provide(Layer.succeed(HttpClient.HttpClient)(client)), - Effect.flip, - ); - expect(error).toHaveProperty("reason", "response-too-large"); - expect(cancelled).toBe(true); - }), - ); - } -}); 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); + }), + ); +}); diff --git a/packages/plugins/openapi/src/sdk/parse.test.ts b/packages/plugins/openapi/src/sdk/parse.test.ts index f4f4da55cd..039f51534a 100644 --- a/packages/plugins/openapi/src/sdk/parse.test.ts +++ b/packages/plugins/openapi/src/sdk/parse.test.ts @@ -155,29 +155,6 @@ describe("OpenAPI fetchSpecText", () => { }), ); - it.effect("bounds a streamed response even when Content-Length understates it", () => - Effect.gen(function* () { - let cancelled = false; - const response = new Response( - new ReadableStream({ - pull(controller) { - controller.enqueue(new Uint8Array(1024 * 1024)); - }, - cancel() { - cancelled = true; - }, - }), - { headers: { "content-length": "1" } }, - ); - const error = yield* fetchSpecText(specUrl).pipe( - Effect.provide(layerWithResponse(response)), - Effect.flip, - ); - expect(error).toHaveProperty("message", expect.stringMatching(/too large to parse/)); - expect(cancelled).toBe(true); - }), - ); - it.effect("fetches a document with an in-range declared length", () => Effect.gen(function* () { const specText = yield* fetchSpecText(specUrl).pipe( From 85b1c166bfd3090c2452c59f277f71abf900069c Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:42:53 -0700 Subject: [PATCH 5/5] Verify authentication carriers and login diagnostic privacy --- e2e/cloud/auth-evidence.test.ts | 183 ++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 e2e/cloud/auth-evidence.test.ts 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, + ), + ), + ); + }), +);