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
10 changes: 8 additions & 2 deletions app/lib/auth/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,21 @@ 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 })
.onConflictDoUpdate({
target: authRateLimits.key,
set: {
kind,
count: sql<number>`case when ${authRateLimits.windowStart} <= ${expiredBefore} then 1 else ${authRateLimits.count} + 1 end`,
windowStart: sql<Date>`case when ${authRateLimits.windowStart} <= ${expiredBefore} then ${windowStart} else ${authRateLimits.windowStart} end`,
count: sql<number>`case when ${authRateLimits.windowStart} <= ${expiredBeforeParam} then 1 else ${authRateLimits.count} + 1 end`,
windowStart: sql<Date>`case when ${authRateLimits.windowStart} <= ${expiredBeforeParam} then ${windowStartParam} else ${authRateLimits.windowStart} end`,
updatedAt: windowStart,
},
});
Expand Down
59 changes: 59 additions & 0 deletions tests/auth/rate-limit-params.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading