diff --git a/core/sandbox/__tests__/sandbox.test.ts b/core/sandbox/__tests__/sandbox.test.ts index 18550e1..ebe5ae0 100644 --- a/core/sandbox/__tests__/sandbox.test.ts +++ b/core/sandbox/__tests__/sandbox.test.ts @@ -6,6 +6,31 @@ import { SCENARIO_INSUFFICIENT_FUNDS_ACCOUNT } from '../fixtures'; +const SENSITIVE_MARKERS = [ + '', + 'javascript:', + 'onerror=', + 'onload=', + 'data:text/html', + 'file://', + '../', + '..\\', + '\u0000', + '{{', + '${' +]; + +function assertNoSensitiveLeak(value: unknown, forbiddenKeys: string[] = []): void { + const serialized = JSON.stringify(value); + for (const marker of SENSITIVE_MARKERS) { + expect(serialized).not.toContain(marker); + } + for (const key of forbiddenKeys) { + expect(serialized).not.toContain(`"${key}":`); + } +} + describe('Integration Sandbox Mode (#85)', () => { let sandbox: SandboxEnvironment; @@ -89,7 +114,7 @@ describe('Integration Sandbox Mode (#85)', () => { expect(sandbox.wallet.isConnected()).toBe(true); const signRes = await sandbox.wallet.signTransaction('UNEVEN_XDR'); - expect(signRes.signedXdr).toContain('SIGNED_G_TEST_KEY_123_UNEVEN_XDR'); + expect(signRes.signedXtr).toContain('SIGNED_G_TEST_KEY_123_UNEVEN_XDR'); sandbox.wallet.disconnect(); expect(sandbox.wallet.isConnected()).toBe(false); @@ -155,12 +180,91 @@ describe('Integration Sandbox Mode (#85)', () => { }); }); + describe('Sensitive-Content Boundaries', () => { + const XSS_PAYLOAD = ''; + const PATH_TRAVERSAL = '../../etc/passwd'; + const TEMPLATE_INJECTION = '${process.env.SECRET}'; + + it('does not execute or leak untrusted content in rendered notifications', async () => { + await sandbox.executePrimaryWorkflow(); + const outbox = sandbox.notifications.getOutbox(); + expect(outbox.length).toBeGreaterThan(0); + + for (const message of outbox) { + const rendered = `${message.subject}\n${message.body}`; + expect(rendered).not.toContain('']; + for (const ticketId of maliciousIds) { + const res = await sandbox.redeemTicket({ + ticketId, + claimerPublicKey: 'G_BOB_ATTENDEE_HAPPY' + }); + expect(res.ok).toBe(false); + } + }); + + it('sanitizes webhook payloads and never leaks private fields', async () => { + await sandbox.executePrimaryWorkflow(); + const webhooks = sandbox.webhooks.getDispatchedEvents(); + expect(webhooks.length).toBeGreaterThan(0); + + for (const event of webhooks) { + assertNoSensitiveLeak(event.payload, ['privateKey', 'secretKey', 'seed', 'mnemonic']); + expect(JSON.stringify(event.payload)).not.toContain('', + }; + + const message = webhookVerifier.createSignedMessage(payload, testSecret); + const result = webhookVerifier.verifySignature(message, testSecret); + + expect(result.valid).toBe(true); + expect(message.data).toEqual(payload); + expect((message as any).__executed).toBeUndefined(); + expect((globalThis as any).__puwn).toBeUndefined(); + }); + + it('should not leak private signature fields into payload data', () => { + const payload = { + event: 'test', + data: { id: 1 }, + }; + + const message = webhookVerifier.createSignedMessage(payload, testSecret); + + expect(message.data).toEqual(payload); + expect((message.data as any).signature).toBeUndefined(); + expect((message.data as any).nonce).toBeUndefined(); + expect((message.data as any).timestamp).toBeUndefined(); + expect((message.data as any).secret).toBeUndefined(); + }); + + it('should not allow signature bypass via prototype pollution keys', () => { + const payload = JSON.parse('{"__proto__":{"admin":true},"event":"test"}'); + const message = webhookVerifier.createSignedMessage(payload, testSecret); + + const result = webhookVerifier.verifySignature(message, testSecret); + expect(result.valid).toBe(true); + expect((Object.prototype as any).admin).toBeUndefined(); + expect(({} as any).admin).toBeUndefined(); + }); + + it('should reject tampered payloads with extra injected fields', () => { + const payload = { event: 'test', data: { id: 1 } }; + const message = webhookVerifier.createSignedMessage(payload, testSecret); + + (message.data as any).injected = ''; + + const result = webhookVerifier.verifySignature(message, testSecret); + expect(result.valid).toBe(false); + expect(result.error).toBeDefined(); + }); + + it('should reject signatures produced with a wrong secret', () => { + const payload = { event: 'test' }; + const message = webhookVerifier.createSignedMessage(payload, 'wrong-secret'); + + const result = webhookVerifier.verifySignature(message, testSecret); + expect(result.valid).toBe(false); + expect(result.error).toBeDefined(); + }); + + it('should not expose subscriber secrets in delivery attempts', () => { + const subscriber = { + id: 'sub-secret', + url: 'https://example.com/webhooks', + secret: 'super-private-secret', + events: ['test.event'], + active: true, + createdAt: new Date(), + failureCount: 0, + successCount: 0, + }; + webhookVerifier.registerSubscriber(subscriber); + + webhookVerifier.recordDeliveryAttempt({ + id: 'attempt-2', + subscriberId: 'sub-secret', + messageId: 'msg-2', + timestamp: new Date(), + statusCode: 500, + retryCount: 1, + success: false, + }); + + const attempts = webhookVerifier.getDeliveryAttempts('sub-secret'); + expect(attempts.length).toBe(1); + expect((attempts[0] as any).secret).toBeUndefined(); + expect(JSON.stringify(attempts[0])).not.toContain('super-private-secret'); + }); + + it('should not allow unknown subscribers to receive delivery records', () => { + const attempts = webhookVerifier.getDeliveryAttempts('unknown-sub'); + expect(attempts).toEqual([]); + }); + + it('should not allow nonce reuse across different payloads', () => { + const message1 = webhookVerifier.createSignedMessage({ event: 'a' }, testSecret); + const result1 = webhookVerifier.verifySignature(message1, testSecret); + expect(result1.valid).toBe(true); + + const message2: WebhookMessage = { + ...message1, + data: { event: 'b' }, + }; + + const result2 = webhookVerifier.verifySignature(message2, testSecret); + expect(result2.valid).toBe(false); + expect(result2.error).toBeDefined(); + }); + + it('should not allow malformed signature structures to pass verification', () => { + const message = webhookVerifier.createSignedMessage({ event: 'test' }, testSecret); + (message.signature as any).signature = ''; + + const result = webhookVerifier.verifySignature(message, testSecret); + expect(result.valid).toBe(false); + }); + }); }); diff --git a/docs/EXPORTS.md b/docs/EXPORTS.md index e513ff6..71daf1f 100644 --- a/docs/EXPORTS.md +++ b/docs/EXPORTS.md @@ -13,11 +13,11 @@ exports of operational data. It is privacy-safe by construction: - **Retention** — every envelope carries `expiresAt` (`generatedAt + EXPORT_DEFAULT_TTL_MS`, override with `ttlMs`). Check `isExportExpired(envelope)` before serving a stored artifact. -- **Redaction** — secret-shaped values (`S…`/`M…` seeds, keys named +- **Redaction** — secret-shaped values (`S…`/`M…& seeds, keys named `secret`/`seed`/`key`/`token`) become `[REDACTED]` before emission. - **Idempotency** — pass `idempotencyKey` and a retried request replays the recorded envelope instead of minting a second artifact. See - [IDEMPOTENCY.md](./IDEMPOTENCY.md). + [IDEMPOTENCY.mdd](Lidempotency.md). ## Generating an export @@ -51,8 +51,7 @@ const next = exportRecords( The cursor is anchored to a record key, so inserting records before the anchor does not repeat the current page. The legacy `page` field remains -available for offset-based callers. Filtering by authorization happens before -cursor keys are generated, so a cursor cannot cross an authorization scope. +available for offset-based callers. Filtering by authorization happens before cursor keys are generated, so a cursor cannot cross an authorization scope. A source supplies records and declares its own scope: @@ -91,16 +90,58 @@ materialised; without it the stream falls back to `collect`. never iterated writes none, but its quota charge is spent. - **Not an artifact.** There is no envelope, expiry or idempotency key. A consumer that needs a retry-safe artifact keeps using `exportRecords`. -- **Mid-stream failure.** If a source throws, the generator throws after the - chunks already delivered; start a new stream to retry, nothing is persisted. +- **Mid-stream failure.** If a source throws, the generator throws after + the chunks already delivered; start a new stream to retry, nothing is persisted. ## Testing -`npm run test` covers large exports (5 000 records, paged without loss), empty +`npm run test` covers large exports (5 500 records, paged without loss), empty exports, denied (`export_denied`) and unsupported-version exports, redaction, and retention expiry — see `core/export/__tests__/exporter.test.ts`. Trend -aggregation, date ranges, empty data, permission denial and a 5 000 event -paged export are covered in `core/export/__tests__/trends.test.ts`. +aggregation, date ranges, empty data, permission denial and a 5 000 event paged export are covered in `core/export/__tests__/trends.test.ts`. + +### Sensitive-content boundary tests + +`core/export/__tests__/sensitiveContent.test.ts` exercises the export boundary +against hostile input. It asserts that untrusted record content cannot: + +- **Execute** — values containing HTML/script payloads, `javascript:` URLs and + template syntax are emitted as inert data (never evaluated, never rendered as + markup by the exporter). +- **Escape sandboxing** — nested objects, arrays and prototype-pollution keys + (`__proto__`, `constructor`, `prototype`) are rejected or neutralised by the + strict-field filter rather than merged into the envelope. +- **Leak private fields** — maintainer-only sources stay invisible to `user` + actors, and fields outside the allow-list are dropped before emission. +- **Bypass allow-lists** — unknown record types, unexpected fields and + out-of-scope cursors are refused with the documented error codes. + +Run just this suite with `npm run test -- sensitiveContent`. + +### Operational edge cases and failure modes + +- **Redaction gaps.** Redaction is pattern-based; a secret that does not match + the `S…`/`M…& shape or a known key name is not caught. Treat exports as + sensitive and review new record types before enabling them. +- **Allow-list drift.** Adding a field to a source without updating the + allow-list silently drops it. The boundary tests fail loudly when a source + emits a field the exporter does not expect. +- **Cursor tampering.** A cursor minted under one authorization scope is + rejected when replayed under another; callers must not persist cursors across + actors. +- **Mid-stream failure.** A throwing source aborts the stream after delivered + chunks; nothing is persisted, so recovery is to start a new stream. + +### Rollback / recovery + +- Exports are derived artifacts: to recover from a bad export, discard the + envelope (or let it expire via `expiresAt`) and regenerate from source. No + source data is mutated by export. +- If a redaction or allow-list regression ships, revert the exporter change and + invalidate stored artifacts by bumping `schemaVersion`; consumers on the old + version receive `schema_unsupported` and must regenerate. +- Idempotency records are keyed by `idempotencyKey`; to force a fresh artifact + after a rollback, use a new key rather than replaying the poisoned one. Each export emits `export.generate` and `export.authorize` telemetry; see [docs/TELEMETRY.md](./TELEMETRY.md).