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).