From bf0ed120c4a29a0a7f8fc08a22e0d7bc6d7a9e6b Mon Sep 17 00:00:00 2001 From: ety001 Date: Mon, 7 Sep 2026 15:58:32 +0800 Subject: [PATCH] fix(analytics): bound and validate event timestamp, require object properties Closes the S7 residual from the 2026-08-04 audit re-verification: `timestamp` reached the log line with no type/length validation, and non-object `properties` slipped through the key-count/byte-size checks. Adds a 32-char cap with Date.parse sanity for timestamps and a plain-object check for properties, plus regression tests. --- src/app/api/analytics/event/route.ts | 19 ++++++++++++ tests/unit/analytics-event-route.test.ts | 39 ++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/app/api/analytics/event/route.ts b/src/app/api/analytics/event/route.ts index 1ec35986..e5823c7b 100644 --- a/src/app/api/analytics/event/route.ts +++ b/src/app/api/analytics/event/route.ts @@ -14,6 +14,7 @@ interface AnalyticsEventBody { const MAX_EVENT_NAME = 64; const MAX_PROPERTIES_BYTES = 2048; const MAX_PROPERTY_KEYS = 16; +const MAX_TIMESTAMP_LEN = 32; // ISO-8601 timestamps are at most 29 chars export async function POST(request: NextRequest) { try { @@ -47,7 +48,25 @@ export async function POST(request: NextRequest) { { status: 400 } ); } + // S7: timestamp is attacker-controlled too and reaches the log line — + // same rule: bounded length, parseable date. if ( + timestamp !== undefined && + (typeof timestamp !== 'string' || + timestamp.length > MAX_TIMESTAMP_LEN || + Number.isNaN(Date.parse(timestamp))) + ) { + return NextResponse.json( + { error: 'Invalid timestamp' }, + { status: 400 } + ); + } + // S7: properties must be a plain object — strings/numbers would slip + // through the key-count and byte-size checks below. + if ( + typeof properties !== 'object' || + properties === null || + Array.isArray(properties) || Object.keys(properties).length > MAX_PROPERTY_KEYS || JSON.stringify(properties).length > MAX_PROPERTIES_BYTES ) { diff --git a/tests/unit/analytics-event-route.test.ts b/tests/unit/analytics-event-route.test.ts index ce605a94..88bc74a2 100644 --- a/tests/unit/analytics-event-route.test.ts +++ b/tests/unit/analytics-event-route.test.ts @@ -63,4 +63,43 @@ describe('POST /api/analytics/event (S7 bounds)', () => { expect(res.status).toBe(400); expect(console.log).not.toHaveBeenCalled(); }); + + it('rejects a timestamp longer than 32 chars', async () => { + const res = await POST(makeRequest({ event: 'page_view', timestamp: 'x'.repeat(33) })); + expect(res.status).toBe(400); + expect(console.log).not.toHaveBeenCalled(); + }); + + it('rejects a non-string timestamp', async () => { + const res = await POST(makeRequest({ event: 'page_view', timestamp: 1234567890 })); + expect(res.status).toBe(400); + expect(console.log).not.toHaveBeenCalled(); + }); + + it('rejects an unparseable timestamp', async () => { + const res = await POST(makeRequest({ event: 'page_view', timestamp: 'not a date' })); + expect(res.status).toBe(400); + expect(console.log).not.toHaveBeenCalled(); + }); + + it('accepts a valid ISO timestamp and logs it through', async () => { + const res = await POST(makeRequest({ + event: 'page_view', + timestamp: '2026-09-06T12:00:00.000Z', + })); + expect(res.status).toBe(200); + expect(console.log).toHaveBeenCalledOnce(); + }); + + it('rejects non-object properties (string payload)', async () => { + const res = await POST(makeRequest({ event: 'page_view', properties: 'x'.repeat(10) })); + expect(res.status).toBe(400); + expect(console.log).not.toHaveBeenCalled(); + }); + + it('rejects null properties', async () => { + const res = await POST(makeRequest({ event: 'page_view', properties: null })); + expect(res.status).toBe(400); + expect(console.log).not.toHaveBeenCalled(); + }); });