Skip to content
Open
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
108 changes: 108 additions & 0 deletions packages/starpod/src/lib/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Fixed-window rate limiting for API routes, scoped to a single deployed
* instance. Good enough to signal real limits on the write-side contact
* endpoint; not a substitute for a shared store (Upstash, Vercel KV, etc.)
* if this ever needs to hold across multiple instances/regions.
*/

interface RateLimitOptions {
limit: number;
windowMs: number;
}

interface RateLimitResult {
allowed: boolean;
limit: number;
remaining: number;
/** Epoch seconds the current window resets at. */
resetAt: number;
}

const buckets = new Map<string, { count: number; resetAt: number }>();

/** Drops any bucket whose window has already elapsed, so the map only ever
* holds entries for clients currently inside an active window - otherwise
* it grows forever, one entry per unique client key ever seen. */
function pruneExpiredBuckets(now: number): void {
for (const [key, bucket] of buckets) {
if (bucket.resetAt <= now) {
buckets.delete(key);
}
}
}

export function checkRateLimit(
key: string,
{ limit, windowMs }: RateLimitOptions
): RateLimitResult {
const now = Date.now();
pruneExpiredBuckets(now);
const bucket = buckets.get(key);

if (!bucket || bucket.resetAt <= now) {
const resetAt = now + windowMs;
buckets.set(key, { count: 1, resetAt });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return {
allowed: true,
limit,
remaining: limit - 1,
resetAt: Math.ceil(resetAt / 1000)
};
}

bucket.count += 1;

return {
allowed: bucket.count <= limit,
limit,
remaining: Math.max(0, limit - bucket.count),
resetAt: Math.ceil(bucket.resetAt / 1000)
};
}

/**
* Derives a rate-limit key from the client's IP, falling back to a shared
* bucket if no forwarding header is present (e.g. local dev).
*
* Prefers `x-vercel-forwarded-for`: on Vercel (this project's target
* deployment), `x-forwarded-for` is already overwritten at the edge and
* client-supplied values are stripped, so both headers carry the same real
* IP there - but `x-vercel-forwarded-for` stays trustworthy even behind an
* extra reverse proxy in front of Vercel, where `x-forwarded-for` could be
* appended to instead of replaced. Deployed anywhere else (not Vercel),
* `x-forwarded-for` is only as trustworthy as whatever's terminating TLS in
* front of the app - fine for this project's actual target, worth keeping
* in mind for anyone self-hosting the OSS template differently.
*/
export function clientKey(request: Request): string {
const forwardedFor =
request.headers.get('x-vercel-forwarded-for') ??
request.headers.get('x-forwarded-for');
return forwardedFor?.split(',')[0]?.trim() || 'unknown';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export function rateLimitHeaders(
result: RateLimitResult
): Record<string, string> {
const secondsUntilReset = Math.max(
0,
result.resetAt - Math.floor(Date.now() / 1000)
);
return {
'RateLimit-Limit': String(result.limit),
'RateLimit-Remaining': String(result.remaining),
// Per the IETF RateLimit header draft, Reset is seconds until the
// window resets (delta), not an absolute epoch timestamp.
'RateLimit-Reset': String(secondsUntilReset)
};
}

/** Test-only: clears all bucket state between test cases. */
export function __resetRateLimitBucketsForTests(): void {
buckets.clear();
}

/** Test-only: exposes the current bucket count to verify pruning. */
export function __debugBucketCount(): number {
return buckets.size;
}
37 changes: 32 additions & 5 deletions packages/starpod/src/pages/api/contact.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,33 @@
import type { APIRoute } from 'astro';

import { jsonError } from '../../lib/api-errors';
import {
checkRateLimit,
clientKey,
rateLimitHeaders
} from '../../lib/rate-limit';

export const prerender = false;

// The contact form is the only mutating, costly endpoint on this site (it
// calls out to a Discord webhook), so it's the only one worth rate limiting.
const CONTACT_RATE_LIMIT = { limit: 5, windowMs: 60_000 };

export const POST: APIRoute = async ({ request }) => {
const rate = checkRateLimit(clientKey(request), CONTACT_RATE_LIMIT);
const headers = rateLimitHeaders(rate);

if (!rate.allowed) {
const retryAfter = Math.max(0, rate.resetAt - Math.floor(Date.now() / 1000));
return jsonError(
429,
'rate_limited',
'Too many contact form submissions from this client',
'Wait a minute before trying again.',
{ ...headers, 'Retry-After': String(retryAfter) }
);
}

let data: FormData;
try {
data = await request.formData();
Expand All @@ -13,7 +36,8 @@ export const POST: APIRoute = async ({ request }) => {
400,
'invalid_body',
'Request body could not be parsed as form data',
'Send a multipart/form-data or application/x-www-form-urlencoded body with name, email, and message fields.'
'Send a multipart/form-data or application/x-www-form-urlencoded body with name, email, and message fields.',
headers
);
}

Expand All @@ -32,7 +56,8 @@ export const POST: APIRoute = async ({ request }) => {
400,
'missing_fields',
`Missing required fields: ${missing.join(', ')}`,
'Provide name, email, and message form fields.'
'Provide name, email, and message form fields.',
headers
);
}

Expand All @@ -41,7 +66,8 @@ export const POST: APIRoute = async ({ request }) => {
500,
'not_configured',
'The contact form is not configured on this deployment',
'Set the DISCORD_WEBHOOK environment variable, or reach the hosts via the links on /contact.'
'Set the DISCORD_WEBHOOK environment variable, or reach the hosts via the links on /contact.',
headers
);
}

Expand Down Expand Up @@ -87,7 +113,8 @@ export const POST: APIRoute = async ({ request }) => {
502,
'delivery_failed',
'Your message could not be delivered',
'Try again in a few minutes, or reach the hosts via the links on /contact.'
'Try again in a few minutes, or reach the hosts via the links on /contact.',
headers
);
}

Expand All @@ -98,7 +125,7 @@ export const POST: APIRoute = async ({ request }) => {
}),
{
status: 200,
headers: { 'Content-Type': 'application/json; charset=utf-8' }
headers: { 'Content-Type': 'application/json; charset=utf-8', ...headers }
}
);
};
Expand Down
68 changes: 66 additions & 2 deletions tests/unit/contact-api.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { ALL, POST } from 'starpod/src/pages/api/contact';
import { __resetRateLimitBucketsForTests } from 'starpod/src/lib/rate-limit';

type ApiContext = Parameters<typeof POST>[0];

function postContext(body: FormData | string): ApiContext {
function postContext(
body: FormData | string,
headers?: Record<string, string>
): ApiContext {
const request = new Request('http://localhost/api/contact', {
method: 'POST',
body
body,
headers
});
return { request } as ApiContext;
}
Expand All @@ -24,6 +29,7 @@ describe('contact API', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
__resetRateLimitBucketsForTests();
});

it('returns structured JSON 400 when fields are missing', async () => {
Expand Down Expand Up @@ -90,4 +96,62 @@ describe('contact API', () => {
const body = await response.json();
expect(body.error.code).toBe('method_not_allowed');
});

it('attaches RateLimit-* headers to a successful response', async () => {
vi.stubEnv('DISCORD_WEBHOOK', 'https://discord.example.com/webhook');
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response('ok', { status: 200 }))
);

const response = await POST(
postContext(validForm(), { 'x-forwarded-for': '203.0.113.10' })
);

expect(response.status).toBe(200);
expect(response.headers.get('RateLimit-Limit')).toBe('5');
expect(response.headers.get('RateLimit-Remaining')).toBe('4');
expect(response.headers.get('RateLimit-Reset')).toBeTruthy();
});

it('returns structured JSON 429 with Retry-After once the limit is exceeded', async () => {
vi.stubEnv('DISCORD_WEBHOOK', 'https://discord.example.com/webhook');
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response('ok', { status: 200 }))
);

const ip = { 'x-forwarded-for': '203.0.113.11' };
for (let i = 0; i < 5; i++) {
const response = await POST(postContext(validForm(), ip));
expect(response.status).toBe(200);
}

const limited = await POST(postContext(validForm(), ip));

expect(limited.status).toBe(429);
expect(limited.headers.get('RateLimit-Remaining')).toBe('0');
expect(limited.headers.get('Retry-After')).toBeTruthy();
const body = await limited.json();
expect(body.error.code).toBe('rate_limited');
});

it('tracks rate limits per client independently', async () => {
vi.stubEnv('DISCORD_WEBHOOK', 'https://discord.example.com/webhook');
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response('ok', { status: 200 }))
);

const clientOne = { 'x-forwarded-for': '203.0.113.12' };
const clientTwo = { 'x-forwarded-for': '203.0.113.13' };

for (let i = 0; i < 5; i++) {
await POST(postContext(validForm(), clientOne));
}

const stillAllowed = await POST(postContext(validForm(), clientTwo));

expect(stillAllowed.status).toBe(200);
});
});
Loading
Loading