diff --git a/.changeset/browser-pay-event-redaction.md b/.changeset/browser-pay-event-redaction.md new file mode 100644 index 000000000..f33c02153 --- /dev/null +++ b/.changeset/browser-pay-event-redaction.md @@ -0,0 +1,5 @@ +--- +"@sapiom/browser-pay": minor +--- + +Interceptor event URLs now expose only the request origin, and refusal reasons are restricted to known relay values (exported as the `RefusalReason` type). CDP attach errors use fixed messages to avoid forwarding transport details. diff --git a/packages/browser-pay/README.md b/packages/browser-pay/README.md index f383e182c..1c55c8ca0 100644 --- a/packages/browser-pay/README.md +++ b/packages/browser-pay/README.md @@ -53,7 +53,9 @@ Refusal, nothing forwarded: { "outcome": "refused", "reason": "already_relayed" } ``` -The reason string is passed through. Reasons David returns include `already_relayed`, `total_changed`, `key_rotated`, `card_removed`, `unknown_injection`, `injection_expired`, `payment_released`, `relay_in_progress`, `payment_not_authorized`, `destination_not_allowed`, `card_fields_invalid`, `merchant_mismatch`, and `provider_unavailable`. `relay_in_progress` is not terminal. The paused request stays open and the same idempotency key is posted again until the outcome is terminal or the deadline passes. +The `refused` event and logger pass through only these known reasons: `unknown_injection`, `injection_expired`, `payment_released`, `key_rotated`, `already_relayed`, `relay_in_progress`, `card_removed`, `payment_not_authorized`, `total_changed`, `destination_not_allowed`, `card_fields_invalid`, `merchant_mismatch`, and `provider_unavailable`. Any other reason becomes `unrecognized`. `relay_in_progress` is not terminal. The paused request stays open and the same idempotency key is posted again until the outcome is terminal or the deadline passes. + +Event `url` is origin-only (`scheme://host[:port]`), with any held placeholder masked. An opaque origin uses its scheme (for example, `about:`); an unparseable URL is `""`. Paths, queries, fragments, and userinfo are never included. Attach errors use fixed messages and do not include transport details. CDP command failures and timeouts use the generic messages `CDP command failed` and `CDP command timed out`. No answer after the send: diff --git a/packages/browser-pay/src/cdp.ts b/packages/browser-pay/src/cdp.ts index b18f437cc..f8ae53cc9 100644 --- a/packages/browser-pay/src/cdp.ts +++ b/packages/browser-pay/src/cdp.ts @@ -30,7 +30,7 @@ function parseCdpMessage(value: unknown): params: Record; sessionId?: string; result?: unknown; - errorMessage?: string; + failed?: true; } | undefined { if (!isRecord(value)) return undefined; @@ -40,19 +40,14 @@ function parseCdpMessage(value: unknown): const params = isRecord(value.params) ? value.params : {}; const sessionId = typeof value.sessionId === "string" ? value.sessionId : undefined; - let errorMessage: string | undefined; - if (isRecord(value.error) && typeof value.error.message === "string") { - errorMessage = value.error.message; - } else if (value.error) { - errorMessage = "CDP call failed"; - } + // The remote error text is dropped here so it can never reach a caller. return { ...(id !== undefined ? { id } : {}), ...(method !== undefined ? { method } : {}), params, ...(sessionId ? { sessionId } : {}), result: value.result, - ...(errorMessage ? { errorMessage } : {}), + ...(value.error ? { failed: true as const } : {}), }; } @@ -78,16 +73,20 @@ class WebSocketClient { this.closed = true; this.notifyClose(); }); - socket.on("error", (error: Error) => { - this.fail( - error instanceof Error ? error : new Error("CDP websocket error"), - ); + socket.on("error", () => { + this.fail(new Error("CDP websocket error")); }); } static connect(url: string, timeoutMs: number): Promise { return new Promise((resolve, reject) => { - const target = new URL(url); + let target: URL; + try { + target = new URL(url); + } catch { + reject(new Error("CDP websocket URL is invalid")); + return; + } const key = crypto.randomBytes(16).toString("base64"); const transport = target.protocol === "wss:" ? https : http; const request = transport.request({ @@ -130,7 +129,9 @@ class WebSocketClient { request.on("timeout", () => fail(new Error("CDP websocket connect timeout")), ); - request.on("error", fail); + request.on("error", () => + fail(new Error("CDP websocket connect failed")), + ); request.end(); }); } @@ -337,8 +338,8 @@ export class CdpConnection { if (pending && message.id !== undefined) { this.pending.delete(message.id); clearTimeout(pending.timer); - if (message.errorMessage) { - pending.reject(new Error(`CDP ${message.errorMessage}`)); + if (message.failed) { + pending.reject(new Error("CDP command failed")); } else { pending.resolve(message.result); } @@ -389,7 +390,7 @@ export class CdpConnection { } const timer = setTimeout(() => { this.pending.delete(id); - reject(new Error(`CDP ${method} timed out`)); + reject(new Error("CDP command timed out")); }, timeoutMs); this.pending.set(id, { resolve, @@ -405,12 +406,10 @@ export class CdpConnection { ...(sessionId ? { sessionId } : {}), }), ); - } catch (error) { + } catch { clearTimeout(timer); this.pending.delete(id); - reject( - error instanceof Error ? error : new Error("CDP connection closed"), - ); + reject(new Error("CDP connection closed")); } }); } diff --git a/packages/browser-pay/src/index.ts b/packages/browser-pay/src/index.ts index 71a4769b8..fcd581d19 100644 --- a/packages/browser-pay/src/index.ts +++ b/packages/browser-pay/src/index.ts @@ -5,6 +5,7 @@ export type { InterceptorEvent, InterceptorHandle, InterceptorTarget, + RefusalReason, } from "./interceptor.js"; export { httpPaymentRelay } from "./relay.js"; diff --git a/packages/browser-pay/src/interceptor.event-redaction.test.ts b/packages/browser-pay/src/interceptor.event-redaction.test.ts new file mode 100644 index 000000000..1464c3cfd --- /dev/null +++ b/packages/browser-pay/src/interceptor.event-redaction.test.ts @@ -0,0 +1,723 @@ +import crypto from "node:crypto"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import type { Duplex } from "node:stream"; +import { CdpConnection } from "./cdp.js"; +import { textHoldsPlaceholderNumber } from "./detect"; +import type { PayLogger } from "./logger"; +import { + attachInterceptor, + type InterceptorEvent, + type InterceptorHandle, +} from "./interceptor"; +import { + paymentIdempotencyKey, + type RelayRequest, + type RelayResponse, +} from "./contract"; +import { stubPaymentRelay } from "../test/stub-relay"; + +const HELD = { + number: "4242424242424242", + injectionRef: "inj_event_redaction", +}; + +const PLACEHOLDER_FORMS = [ + "4242424242424242", + "4242 4242 4242 4242", + "4242-4242-4242-4242", + "4242%204242%204242%204242", + "4242+4242+4242+4242", + String.raw`4242\u00204242\u00204242\u00204242`, +]; + +const MARKERS = [ + "synthuser", + "synthpass", + "synthopaquesession", + "synthqueryvalue", + "synthtoken", + "synthfragment", + ...PLACEHOLDER_FORMS, +]; + +const ORIGIN = "https://checkout.example.test:8443"; +const BODY = '{"note":"synthbody"}'; + +interface Command { + method: string; + params: Record; + sessionId?: string; +} + +interface StubOptions { + failFetch?: boolean; + failFulfill?: boolean; + errorOnMethod?: { method: string; message: string }; + noReplyOnMethod?: string; + attachOnAutoAttach?: { targetId: string; url: string }; +} + +function serverFrame(text: string): Buffer { + const payload = Buffer.from(text); + if (payload.length < 126) { + return Buffer.concat([Buffer.from([0x81, payload.length]), payload]); + } + const header = Buffer.alloc(4); + header[0] = 0x81; + header[1] = 126; + header.writeUInt16BE(payload.length, 2); + return Buffer.concat([header, payload]); +} + +type CdpStub = Awaited>; + +async function startCdp(options: StubOptions = {}): Promise<{ + url: string; + commands: Command[]; + emit: (event: Record) => void; + close: () => Promise; +}> { + const commands: Command[] = []; + let socket: Duplex | undefined; + const chunks: Buffer[] = []; + let buffered = 0; + let emittedAttachedTarget = false; + + const reply = (id: number, result: unknown, error?: { message: string }) => { + socket?.write( + serverFrame(JSON.stringify(error ? { id, error } : { id, result })), + ); + }; + + const consume = (length: number): Buffer | null => { + if (buffered < length) return null; + const out = Buffer.allocUnsafe(length); + let offset = 0; + while (offset < length) { + const head = chunks[0]; + const need = length - offset; + if (head.length <= need) { + head.copy(out, offset); + offset += head.length; + chunks.shift(); + } else { + head.copy(out, offset, 0, need); + chunks[0] = head.subarray(need); + offset += need; + } + } + buffered -= length; + return out; + }; + + const peek = (length: number): Buffer | null => { + if (buffered < length) return null; + if (chunks[0] && chunks[0].length >= length) { + return chunks[0].subarray(0, length); + } + const out = Buffer.allocUnsafe(length); + let offset = 0; + for (const chunk of chunks) { + const take = Math.min(chunk.length, length - offset); + chunk.copy(out, offset, 0, take); + offset += take; + if (offset >= length) break; + } + return out; + }; + + const emit = (event: Record) => { + socket?.write(serverFrame(JSON.stringify(event))); + }; + + const onMessage = (text: string) => { + const message = JSON.parse(text) as { + id?: number; + method?: string; + params?: Record; + sessionId?: string; + }; + if (!message.id || !message.method) return; + commands.push({ + method: message.method, + params: message.params ?? {}, + ...(message.sessionId ? { sessionId: message.sessionId } : {}), + }); + + if (options.errorOnMethod?.method === message.method) { + reply(message.id, undefined, { message: options.errorOnMethod.message }); + return; + } + if (options.noReplyOnMethod === message.method) return; + if ( + message.method === "Target.setAutoAttach" && + !message.sessionId && + options.attachOnAutoAttach && + !emittedAttachedTarget + ) { + emittedAttachedTarget = true; + emit({ + method: "Target.attachedToTarget", + params: { + sessionId: "page-1", + waitingForDebugger: true, + targetInfo: { + targetId: options.attachOnAutoAttach.targetId, + type: "page", + url: options.attachOnAutoAttach.url, + }, + }, + }); + } + if (message.method === "Fetch.enable" && options.failFetch) { + reply(message.id, undefined, { message: "Fetch is unsupported" }); + return; + } + if (message.method === "Fetch.fulfillRequest" && options.failFulfill) { + reply(message.id, undefined, { message: "request is gone" }); + return; + } + if (message.method === "Target.getTargets") { + reply(message.id, { targetInfos: [] }); + return; + } + reply(message.id, {}); + }; + + const parse = () => { + for (;;) { + const header = peek(2); + if (!header) return; + const second = header[1]; + let length = second & 0x7f; + let offset = 2; + if (length === 126) { + const extended = peek(4); + if (!extended) return; + length = extended.readUInt16BE(2); + offset = 4; + } + const masked = (second & 0x80) !== 0; + const maskOffset = offset; + if (masked) offset += 4; + const frame = consume(offset + length); + if (!frame) return; + let payload = frame.subarray(offset, offset + length); + if (masked) { + const mask = frame.subarray(maskOffset, maskOffset + 4); + const unmasked = Buffer.allocUnsafe(payload.length); + for (let index = 0; index < payload.length; index++) { + unmasked[index] = payload[index] ^ mask[index % 4]; + } + payload = unmasked; + } + if ((header[0] & 0x0f) === 0x8) return; + if ((header[0] & 0x0f) !== 0x1) continue; + onMessage(payload.toString("utf8")); + } + }; + + const server = http.createServer(); + server.on("upgrade", (request, upgraded, head) => { + const key = String(request.headers["sec-websocket-key"] ?? ""); + const accept = crypto + .createHash("sha1") + .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest("base64"); + upgraded.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + socket = upgraded; + if (head?.length) { + chunks.push(Buffer.from(head)); + buffered += head.length; + } + upgraded.on("data", (chunk: Buffer) => { + chunks.push(chunk); + buffered += chunk.length; + parse(); + }); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address() as AddressInfo; + return { + url: `ws://127.0.0.1:${address.port}`, + commands, + emit, + close() { + socket?.destroy(); + return new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +function checkoutUrl(placeholderForm: string): string { + return `https://synthuser:synthpass@checkout.example.test:8443/s/synthopaquesession/pay/${placeholderForm}?session=synthqueryvalue&token=synthtoken#synthfragment`; +} + +type Warning = { + message: string; + fields?: Parameters[1]; +}; + +function loggerFor(warnings: Warning[]): PayLogger { + return { + warn(message, fields) { + warnings.push({ message, fields }); + }, + }; +} + +function assertCleanText(text: string): void { + for (const marker of MARKERS) expect(text).not.toContain(marker); + expect(textHoldsPlaceholderNumber(text, HELD.number)).toBe(false); +} + +function assertNoMarkers(value: unknown): void { + assertCleanText(JSON.stringify(value)); +} + +function emitPage(stub: CdpStub) { + stub.emit({ + method: "Target.attachedToTarget", + params: { + sessionId: "page-1", + waitingForDebugger: true, + targetInfo: { + targetId: "target-page", + type: "page", + url: "https://shop.example/checkout", + }, + }, + }); +} + +function emitPause(stub: CdpStub, requestId: string, url: string): void { + stub.emit({ + method: "Fetch.requestPaused", + sessionId: "page-1", + params: { + requestId, + request: { + url, + method: "POST", + headers: { "content-type": "application/json" }, + postData: BODY, + }, + }, + }); +} + +async function waitFor(predicate: () => boolean, label: string): Promise { + const deadline = Date.now() + 3_000; + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error(`timed out waiting for ${label}`); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +function success(): RelayResponse { + return { + outcome: "relayed", + response: { + status: 201, + headers: { "content-type": "application/json" }, + body: '{"ok":true}', + }, + }; +} + +function attachOptions( + relay: ReturnType, + logger?: PayLogger, +) { + return { + relay, + placeholder: HELD, + profileId: "synthetic-profile", + ...(logger ? { logger } : {}), + }; +} + +/** Attaches, records events, and waits until the page session is armed. */ +async function attachArmed( + stub: CdpStub, + relay: ReturnType, + logger?: PayLogger, +): Promise<{ handle: InterceptorHandle; events: InterceptorEvent[] }> { + const events: InterceptorEvent[] = []; + const handle = await attachInterceptor( + stub.url, + attachOptions(relay, logger), + ); + handle.on((event) => events.push(event)); + emitPage(stub); + await waitFor( + () => stub.commands.some((command) => command.method === "Fetch.enable"), + "Fetch.enable", + ); + return { handle, events }; +} + +async function captureError(promise: Promise): Promise { + try { + await promise; + } catch (error) { + if (error instanceof Error) return error; + throw new Error("expected an Error rejection"); + } + throw new Error("expected the promise to reject"); +} + +function assertSafeError(error: Error): void { + const values = [ + error.message, + String(error), + JSON.stringify(error, Object.getOwnPropertyNames(error)), + ]; + for (const value of values) assertCleanText(value); +} + +describe("interceptor event redaction", () => { + it("emits only origins for every held URL form and relays the raw request unchanged", async () => { + const stub = await startCdp(); + const calls: RelayRequest[] = []; + const warnings: Warning[] = []; + try { + const { handle, events } = await attachArmed( + stub, + stubPaymentRelay(async (request) => { + calls.push(request); + return success(); + }), + loggerFor(warnings), + ); + + for (let index = 0; index < PLACEHOLDER_FORMS.length; index++) { + const rawUrl = checkoutUrl(PLACEHOLDER_FORMS[index]); + emitPause(stub, `req-${index}`, rawUrl); + await waitFor( + () => + events.filter((event) => event.type === "relayed").length === + index + 1, + `relayed ${index + 1}`, + ); + expect(calls[index].request.url).toBe(rawUrl); + expect(calls[index].request.body).toBe(BODY); + expect(calls[index].idempotencyKey).toBe( + paymentIdempotencyKey("POST", rawUrl, BODY), + ); + } + + expect(events).toContainEqual({ + type: "paused", + url: ORIGIN, + method: "POST", + targetType: "page", + }); + expect(events).toContainEqual({ + type: "relayed", + url: ORIGIN, + status: 201, + }); + expect(calls).toHaveLength(PLACEHOLDER_FORMS.length); + assertNoMarkers(events); + assertNoMarkers(warnings); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("preserves known refusal reasons and sanitizes custom reasons in events and logs", async () => { + const stub = await startCdp(); + const warnings: Warning[] = []; + let calls = 0; + try { + const { handle, events } = await attachArmed( + stub, + stubPaymentRelay(async () => { + calls += 1; + return calls === 1 + ? { outcome: "refused", reason: "total_changed" } + : { + outcome: "refused", + reason: "bad synthtoken https://x/?session=synthqueryvalue", + }; + }), + loggerFor(warnings), + ); + + emitPause(stub, "known-refusal", checkoutUrl(PLACEHOLDER_FORMS[1])); + await waitFor( + () => events.filter((event) => event.type === "refused").length === 1, + "known refusal", + ); + emitPause(stub, "custom-refusal", checkoutUrl(PLACEHOLDER_FORMS[2])); + await waitFor( + () => events.filter((event) => event.type === "refused").length === 2, + "custom refusal", + ); + + expect(events).toContainEqual({ + type: "refused", + url: ORIGIN, + reason: "total_changed", + }); + expect(events).toContainEqual({ + type: "refused", + url: ORIGIN, + reason: "unrecognized", + }); + expect(warnings.map((warning) => warning.fields)).toEqual([ + { reason: "total_changed" }, + { reason: "unrecognized" }, + ]); + assertNoMarkers(events); + assertNoMarkers(warnings); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("uses origin-only URLs for network and unknown failures", async () => { + const stub = await startCdp(); + let calls = 0; + try { + const { handle, events } = await attachArmed( + stub, + stubPaymentRelay(async () => { + calls += 1; + if (calls === 1) { + throw new Error( + `boom synthtoken ${checkoutUrl(PLACEHOLDER_FORMS[0])}`, + ); + } + return { outcome: "unknown" }; + }), + ); + + emitPause(stub, "network-failure", checkoutUrl(PLACEHOLDER_FORMS[3])); + await waitFor( + () => events.some((event) => event.type === "failed"), + "network failure", + ); + expect(events).toContainEqual({ + type: "failed", + url: ORIGIN, + reason: "network", + }); + + emitPause(stub, "unknown", checkoutUrl(PLACEHOLDER_FORMS[4])); + await waitFor( + () => + events.some( + (event) => event.type === "failed" && event.reason === "unknown", + ), + "unknown outcome", + ); + expect(events).toContainEqual({ + type: "failed", + url: ORIGIN, + reason: "unknown", + }); + assertNoMarkers(events); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("uses origin-only URLs for untracked sessions and opaque or invalid inputs", async () => { + const stub = await startCdp(); + const events: InterceptorEvent[] = []; + try { + const handle = await attachInterceptor( + stub.url, + attachOptions(stubPaymentRelay(async () => success())), + ); + handle.on((event) => events.push(event)); + + const urls = [ + checkoutUrl(PLACEHOLDER_FORMS[0]), + "https://4242424242424242.example.test/x", + "about:blank", + "not a URL", + ]; + for (let index = 0; index < urls.length; index++) { + stub.emit({ + method: "Fetch.requestPaused", + sessionId: `untracked-${index}`, + params: { + requestId: `req-${index}`, + request: { url: urls[index], method: "GET" }, + }, + }); + } + await waitFor(() => events.length === urls.length, "unknown sessions"); + + expect(events).toEqual([ + { + type: "failed", + url: ORIGIN, + reason: "unknown_session", + }, + { + type: "failed", + url: "https://••••.example.test", + reason: "unknown_session", + }, + { type: "failed", url: "about:", reason: "unknown_session" }, + { type: "failed", url: "", reason: "unknown_session" }, + ]); + assertNoMarkers(events); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("delivers a buffered intercept_unavailable event with only the target origin", async () => { + const targetUrl = checkoutUrl(PLACEHOLDER_FORMS[0]); + const stub = await startCdp({ + failFetch: true, + attachOnAutoAttach: { targetId: "target-sensitive", url: targetUrl }, + }); + const events: InterceptorEvent[] = []; + try { + const handle = await attachInterceptor( + stub.url, + attachOptions(stubPaymentRelay(async () => success())), + ); + handle.on((event) => events.push(event)); + expect(events).toEqual([ + { + type: "failed", + url: ORIGIN, + reason: "intercept_unavailable", + }, + ]); + assertNoMarkers(events); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("uses origin-only URLs for relayed_unfulfilled events", async () => { + const stub = await startCdp({ failFulfill: true }); + try { + const { handle, events } = await attachArmed( + stub, + stubPaymentRelay(async () => success()), + ); + emitPause(stub, "unfulfilled", checkoutUrl(PLACEHOLDER_FORMS[0])); + await waitFor( + () => events.some((event) => event.type === "relayed_unfulfilled"), + "relayed_unfulfilled", + ); + expect(events).toContainEqual({ + type: "relayed_unfulfilled", + url: ORIGIN, + status: 201, + }); + assertNoMarkers(events); + await handle.detach(); + } finally { + await stub.close(); + } + }); + + it("does not expose an invalid CDP websocket URL", async () => { + const invalidUrlError = await captureError( + attachInterceptor( + "ws://synthuser:synthpass@[bad", + attachOptions(stubPaymentRelay(async () => success())), + ), + ); + expect(invalidUrlError.message).toBe("CDP websocket URL is invalid"); + expect(Object.prototype.hasOwnProperty.call(invalidUrlError, "input")).toBe( + false, + ); + assertSafeError(invalidUrlError); + }); + + it("does not expose transport details when the connect fails", async () => { + const closed = http.createServer(); + await new Promise((resolve) => { + closed.listen(0, "127.0.0.1", resolve); + }); + const closedPort = (closed.address() as AddressInfo).port; + await new Promise((resolve) => closed.close(() => resolve())); + const connectError = await captureError( + attachInterceptor( + `ws://127.0.0.1:${closedPort}/devtools/browser/synthopaquesession?token=synthtoken`, + attachOptions(stubPaymentRelay(async () => success())), + ), + ); + expect(connectError.message).toBe("CDP websocket connect failed"); + assertSafeError(connectError); + }); + + it("does not expose CDP error text", async () => { + const stub = await startCdp({ + errorOnMethod: { + method: "Target.setAutoAttach", + message: "synthtoken synthfragment synthqueryvalue synthopaquesession", + }, + }); + try { + const protocolError = await captureError( + attachInterceptor( + stub.url, + attachOptions(stubPaymentRelay(async () => success())), + ), + ); + expect(protocolError.message).toBe("CDP command failed"); + assertSafeError(protocolError); + } finally { + await stub.close(); + } + }); + + it("uses generic errors for secret-bearing CDP methods", async () => { + const method = "Synthetic.method?token=SYNTHETIC_METHOD_SECRET_1"; + const errorStub = await startCdp({ + errorOnMethod: { + method, + message: "SYNTHETIC_METHOD_SECRET_1 peer rejected command", + }, + }); + const errorConnection = await CdpConnection.connect(errorStub.url); + try { + const error = await captureError( + errorConnection.send(method, {}, undefined, 1_000), + ); + expect(error.message).toBe("CDP command failed"); + expect(error.message).not.toContain("SYNTHETIC_METHOD_SECRET"); + } finally { + errorConnection.close(); + await errorStub.close(); + } + + const timeoutStub = await startCdp({ noReplyOnMethod: method }); + const timeoutConnection = await CdpConnection.connect(timeoutStub.url); + try { + const error = await captureError( + timeoutConnection.send(method, {}, undefined, 10), + ); + expect(error.message).toBe("CDP command timed out"); + expect(error.message).not.toContain("SYNTHETIC_METHOD_SECRET"); + } finally { + timeoutConnection.close(); + await timeoutStub.close(); + } + }); +}); diff --git a/packages/browser-pay/src/interceptor.integration.test.ts b/packages/browser-pay/src/interceptor.integration.test.ts index 3681c5a33..4e00afc96 100644 --- a/packages/browser-pay/src/interceptor.integration.test.ts +++ b/packages/browser-pay/src/interceptor.integration.test.ts @@ -463,7 +463,7 @@ if (!chromeBin) { expect(events.map((event) => event.type)).toEqual(["paused", "refused"]); expect(events[1]).toEqual({ type: "refused", - url: `http://127.0.0.1:${card.port}/sessions`, + url: `http://127.0.0.1:${card.port}`, reason: "already_relayed", }); expect(JSON.stringify(events)).not.toContain(PLACEHOLDER.number); diff --git a/packages/browser-pay/src/interceptor.protocol.test.ts b/packages/browser-pay/src/interceptor.protocol.test.ts index 7187b7dda..22884240b 100644 --- a/packages/browser-pay/src/interceptor.protocol.test.ts +++ b/packages/browser-pay/src/interceptor.protocol.test.ts @@ -310,7 +310,7 @@ describe("interceptor CDP decisions", () => { placeholder: HELD, profileId: "shopify-card-vault", }), - ).rejects.toThrow("CDP auto-attach refused"); + ).rejects.toThrow("CDP command failed"); await waitFor(() => stub.sawClose(), "websocket close"); } finally { await stub.close(); @@ -346,7 +346,7 @@ describe("interceptor CDP decisions", () => { ); expect(events).toContainEqual({ type: "failed", - url: "https://shop.example/checkout", + url: "https://shop.example", reason: "intercept_unavailable", }); await handle.detach(); @@ -1557,7 +1557,7 @@ describe("interceptor CDP decisions", () => { handle.on((event) => events.push(event)); expect(events).toContainEqual({ type: "failed", - url: "https://checkout.example/card", + url: "https://checkout.example", reason: "intercept_unavailable", }); await handle.detach(); diff --git a/packages/browser-pay/src/interceptor.ts b/packages/browser-pay/src/interceptor.ts index c15d16918..cb435f73c 100644 --- a/packages/browser-pay/src/interceptor.ts +++ b/packages/browser-pay/src/interceptor.ts @@ -56,11 +56,38 @@ export type FailureReason = | "network" | "response_credential"; +const KNOWN_REFUSAL_REASONS = [ + "unknown_injection", + "injection_expired", + "payment_released", + "key_rotated", + "already_relayed", + "relay_in_progress", + "card_removed", + "payment_not_authorized", + "total_changed", + "destination_not_allowed", + "card_fields_invalid", + "merchant_mismatch", + "provider_unavailable", +] as const; + +/** A relay refusal reason. Any reason outside the known set is `unrecognized`. */ +export type RefusalReason = + | (typeof KNOWN_REFUSAL_REASONS)[number] + | "unrecognized"; + +/** + * Event `url` is the request origin (`scheme://host[:port]`) with any held + * placeholder masked, `""` when unparseable, or the scheme (for example, + * `about:`) for opaque origins. Path, query, fragment, and userinfo are never + * included. + */ export type InterceptorEvent = | { type: "paused"; url: string; method: string; targetType: string } | { type: "relayed"; url: string; status: number } | { type: "relayed_unfulfilled"; url: string; status: number } - | { type: "refused"; url: string; reason: string } + | { type: "refused"; url: string; reason: RefusalReason } | { type: "failed"; url: string; reason: FailureReason }; export interface InterceptorHandle { @@ -87,6 +114,25 @@ const RELAY_POLL_MAX_MS = 2_000; const TARGET_FILTER = [{ type: "page" }, { type: "iframe" }]; const CHILD_FILTER = [{ type: "iframe" }]; +function eventUrl(value: string, number: string): string { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return ""; + } + const origin = parsed.origin !== "null" ? parsed.origin : parsed.protocol; + const redacted = redactHeld(origin, number); + // Fail closed if masking ever leaves a held number behind. + return textHoldsPlaceholderNumber(redacted, number) ? "" : redacted; +} + +function publicReason(reason: string): RefusalReason { + return (KNOWN_REFUSAL_REASONS as readonly string[]).includes(reason) + ? (reason as RefusalReason) + : "unrecognized"; +} + /** GET/HEAD with no body and no held number in the URL. Safe to continue immediately. */ function canContinueWithoutReading( method: string, @@ -202,7 +248,7 @@ type HeldRead = type Decision = | { action: "continue" } | { action: "fail"; reason: FailureReason } - | { action: "refused"; reason: string } + | { action: "refused"; reason: RefusalReason } | { action: "relayed"; response: RelayedHttpResponse }; type SettleAction = "continue" | "fail" | { fulfill: RelayedHttpResponse }; @@ -385,7 +431,7 @@ class PaymentInterceptor { if (this.closed) return false; this.emit({ type: "failed", - url: this.safe(session.url || session.targetId), + url: session.url, reason: "intercept_unavailable", }); await this.connection @@ -461,7 +507,7 @@ class PaymentInterceptor { void failRequest(this.connection, sessionId, requestId); this.emit({ type: "failed", - url: this.safe(request.url), + url: request.url, reason: "unknown_session", }); return; @@ -493,20 +539,18 @@ class PaymentInterceptor { entry: Inflight, request: PausedRequest, ): Promise { - const safeUrl = this.safe(request.url); try { - const decision = await this.decide(entry, request, safeUrl); - await this.apply(entry, safeUrl, decision); + const decision = await this.decide(entry, request); + await this.apply(entry, request.url, decision); } catch { await this.settle(entry, "fail"); - this.emit({ type: "failed", url: safeUrl, reason: "network" }); + this.emit({ type: "failed", url: request.url, reason: "network" }); } } private async decide( entry: Inflight, request: PausedRequest, - safeUrl: string, ): Promise { const read = await this.readHeld(entry.sessionId, request); if (read.kind === "unreadable") { @@ -517,7 +561,7 @@ class PaymentInterceptor { const targetType = this.sessions.get(entry.sessionId)?.type ?? ""; this.emit({ type: "paused", - url: safeUrl, + url: request.url, method: request.method, targetType, }); @@ -672,8 +716,9 @@ class PaymentInterceptor { return { action: "fail", reason: "unknown" }; } if (response.outcome === "refused") { - this.logRefusalOnce(idempotencyKey, response.reason); - return { action: "refused", reason: response.reason }; + const reason = publicReason(response.reason); + this.logRefusalOnce(idempotencyKey, reason); + return { action: "refused", reason }; } if (textHoldsPlaceholderNumber(response.response.body, this.number)) { return { action: "fail", reason: "response_credential" }; @@ -681,7 +726,7 @@ class PaymentInterceptor { return { action: "relayed", response: response.response }; } - private logRefusalOnce(idempotencyKey: string, reason: string): void { + private logRefusalOnce(idempotencyKey: string, reason: RefusalReason): void { if (this.loggedRefusals.has(idempotencyKey)) return; this.loggedRefusals.add(idempotencyKey); this.logger.warn("payment relay refused the paused request", { reason }); @@ -689,7 +734,7 @@ class PaymentInterceptor { private async apply( entry: Inflight, - safeUrl: string, + url: string, decision: Decision, ): Promise { if (decision.action === "continue") { @@ -698,21 +743,21 @@ class PaymentInterceptor { } if (decision.action === "fail") { await this.settle(entry, "fail"); - this.emit({ type: "failed", url: safeUrl, reason: decision.reason }); + this.emit({ type: "failed", url, reason: decision.reason }); return; } if (decision.action === "refused") { await this.settle(entry, "fail"); - this.emit({ type: "refused", url: safeUrl, reason: decision.reason }); + this.emit({ type: "refused", url, reason: decision.reason }); return; } const fulfilled = await this.settle(entry, { fulfill: decision.response }); this.emit( fulfilled - ? { type: "relayed", url: safeUrl, status: decision.response.status } + ? { type: "relayed", url, status: decision.response.status } : { type: "relayed_unfulfilled", - url: safeUrl, + url, status: decision.response.status, }, ); @@ -833,12 +878,15 @@ class PaymentInterceptor { }; } + // Callers pass the raw request URL. It is cut to the origin here, once, so + // no event leaves with a path, query, or held number. private emit(event: InterceptorEvent): void { + const safe = { ...event, url: eventUrl(event.url, this.number) }; if (this.buffering && this.backlog) { - this.backlog.push(event); + this.backlog.push(safe); return; } - for (const listener of this.listeners) this.deliver(listener, event); + for (const listener of this.listeners) this.deliver(listener, safe); } private deliver(listener: Listener, event: InterceptorEvent): void { @@ -848,10 +896,6 @@ class PaymentInterceptor { // A listener must not break interception. } } - - private safe(value: string): string { - return redactHeld(value, this.number); - } } function inflightKey(sessionId: string, requestId: string): string {