From 174a61acfd0ec1546d3f2b4fff8dfb3df596009b Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Wed, 23 Sep 2026 14:49:00 -0400 Subject: [PATCH] fix: auth rate limit passed raw Date params to postgres.js Every sign-in and sign-up attempt returned HTTP 500 in production: TypeError: The "string" argument must be of type string or an instance of Buffer or ArrayBuffer. Received an instance of Date postgres.js cannot serialize a raw Date interpolated into a drizzle `sql` fragment - only drizzle's own column-typed bindings get mapped. The atomic rate-limit upsert interpolated two Date objects directly, so the statement failed before credentials were ever checked. Pre-stringify the timestamps to ISO and cast them back to timestamptz inside the statement. The upsert stays a single atomic statement that increments in the database. Verified against a real Postgres 16 instance: insert path, conflict/upsert path, atomic increment to count=5, throttling engaging, and a full sign-up -> wrong-password-401 -> correct-password-200 flow. --- app/lib/auth/rate-limit.ts | 10 ++++- tests/auth/rate-limit-params.test.ts | 59 ++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 tests/auth/rate-limit-params.test.ts diff --git a/app/lib/auth/rate-limit.ts b/app/lib/auth/rate-limit.ts index 882e636..47b7ce1 100644 --- a/app/lib/auth/rate-limit.ts +++ b/app/lib/auth/rate-limit.ts @@ -53,6 +53,12 @@ export async function recordAuthRateLimitAttempt({ const windowStart = new Date(); const expiredBefore = new Date(windowStart.getTime() - windowMs); + // postgres.js cannot serialize a raw Date interpolated into a `sql` fragment + // (only drizzle's column-typed bindings get mapped), so pre-stringify the + // timestamps and cast them back to timestamptz inside the statement. + const windowStartParam = sql`${windowStart.toISOString()}::timestamptz`; + const expiredBeforeParam = sql`${expiredBefore.toISOString()}::timestamptz`; + await db .insert(authRateLimits) .values({ key, kind, count: 1, windowStart, updatedAt: windowStart }) @@ -60,8 +66,8 @@ export async function recordAuthRateLimitAttempt({ target: authRateLimits.key, set: { kind, - count: sql`case when ${authRateLimits.windowStart} <= ${expiredBefore} then 1 else ${authRateLimits.count} + 1 end`, - windowStart: sql`case when ${authRateLimits.windowStart} <= ${expiredBefore} then ${windowStart} else ${authRateLimits.windowStart} end`, + count: sql`case when ${authRateLimits.windowStart} <= ${expiredBeforeParam} then 1 else ${authRateLimits.count} + 1 end`, + windowStart: sql`case when ${authRateLimits.windowStart} <= ${expiredBeforeParam} then ${windowStartParam} else ${authRateLimits.windowStart} end`, updatedAt: windowStart, }, }); diff --git a/tests/auth/rate-limit-params.test.ts b/tests/auth/rate-limit-params.test.ts new file mode 100644 index 0000000..75e06d3 --- /dev/null +++ b/tests/auth/rate-limit-params.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { drizzle } from 'drizzle-orm/pg-proxy'; + +/** + * Regression test for the production sign-in 500: + * + * TypeError: The "string" argument must be of type string or an instance of + * Buffer or ArrayBuffer. Received an instance of Date + * + * postgres.js cannot serialize a raw `Date` interpolated into a `sql` fragment, + * so every sign-in/sign-up attempt 500'd before credentials were checked. + * These tests drive the real rate-limit module through a proxy driver and assert + * the emitted parameters are all driver-serializable. + */ + +const captured: { sql: string; params: unknown[] }[] = []; + +const proxyDb = drizzle(async (sqlText, params) => { + captured.push({ sql: sqlText, params }); + return { rows: [] }; +}); + +vi.mock('@/app/lib/db/client', () => ({ db: proxyDb })); +vi.mock('server-only', () => ({})); + +describe('recordAuthRateLimitAttempt', () => { + beforeEach(() => { + captured.length = 0; + }); + + test('never passes a raw Date to the driver', async () => { + const { recordAuthRateLimitAttempt } = await import('@/app/lib/auth/rate-limit'); + + await recordAuthRateLimitAttempt({ + key: 'sign-in:user@example.com', + kind: 'sign-in', + windowMs: 15 * 60 * 1000, + }); + + expect(captured).toHaveLength(1); + const offenders = captured[0]!.params.filter((param) => param instanceof Date); + expect(offenders).toEqual([]); + }); + + test('still emits an atomic single-statement upsert that increments in the database', async () => { + const { recordAuthRateLimitAttempt } = await import('@/app/lib/auth/rate-limit'); + + await recordAuthRateLimitAttempt({ + key: 'sign-in:user@example.com', + kind: 'sign-in', + windowMs: 15 * 60 * 1000, + }); + + expect(captured).toHaveLength(1); + const { sql: text } = captured[0]!; + expect(text).toMatch(/on conflict/i); + expect(text).toMatch(/"count"\s*\+\s*1/i); + }); +});