Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/app/api/analytics/event/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
) {
Expand Down
39 changes: 39 additions & 0 deletions tests/unit/analytics-event-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading