From d6287dbd65a2ea444869d63d3228eac5e60ac271 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Wed, 29 Apr 2026 18:44:07 +0200 Subject: [PATCH] better rate limiting, verbose logging for username/slug path --- .vscode/settings.json | 1 + src/integrations/auth/config.ts | 28 ++------- src/integrations/orpc/rate-limit.ts | 78 +++++++++++++++++++++++-- src/integrations/orpc/router/jobs.ts | 3 + src/integrations/orpc/router/resume.ts | 11 +++- src/integrations/orpc/router/storage.ts | 3 + src/integrations/rate-limit/config.ts | 42 +++++++++++++ src/routes/$username/$slug.tsx | 3 + src/routes/uploads/$userId.$.tsx | 1 - src/server.ts | 6 +- 10 files changed, 143 insertions(+), 33 deletions(-) create mode 100644 src/integrations/rate-limit/config.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 56e657e48..f7cad65e1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { + "biome.enabled": false, "editor.defaultFormatter": "oxc.oxc-vscode", "editor.codeActionsOnSave": { "source.fixAll.oxc": "explicit" diff --git a/src/integrations/auth/config.ts b/src/integrations/auth/config.ts index 4341e3a76..ebd1a6c1a 100644 --- a/src/integrations/auth/config.ts +++ b/src/integrations/auth/config.ts @@ -19,6 +19,7 @@ import { env } from "@/utils/env"; import { hashPassword, verifyPassword } from "@/utils/password"; import { generateId, toUsername } from "@/utils/string"; import { isAllowedOAuthRedirectUri, parseAllowedHostList } from "@/utils/url-security"; +import { rateLimitConfig } from "@/integrations/rate-limit/config"; import { schema } from "../drizzle"; import { db } from "../drizzle/client"; @@ -240,21 +241,7 @@ const getAuthConfig = () => { telemetry: { enabled: false }, trustedOrigins: TRUSTED_ORIGINS, - rateLimit: { - enabled: true, - window: 60, - max: 60, - customRules: { - "/sign-in/email": { window: 60, max: 5 }, - "/sign-up/email": { window: 60, max: 3 }, - "/request-password-reset": { window: 600, max: 3 }, - "/send-verification-email": { window: 600, max: 3 }, - "/two-factor/verify-otp": { window: 600, max: 5 }, - "/two-factor/verify-totp": { window: 600, max: 5 }, - "/two-factor/verify-backup-code": { window: 600, max: 5 }, - "/is-username-available": { window: 60, max: 20 }, - }, - }, + rateLimit: rateLimitConfig.betterAuth.global, hooks: { before: createAuthMiddleware(async (ctx) => { @@ -385,7 +372,7 @@ const getAuthConfig = () => { passkey(), genericOAuth({ config: authConfigs }), twoFactor({ issuer: "Reactive Resume" }), - apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: true } }), + apiKey({ enableSessionForAPIKeys: true, rateLimit: rateLimitConfig.betterAuth.apiKey }), dash({ apiKey: env.BETTER_AUTH_API_KEY, activityTracking: { enabled: true } }), oauthProvider({ loginPage: "/auth/oauth", @@ -395,14 +382,7 @@ const getAuthConfig = () => { // Required for MCP client onboarding (RFC 7591). Phishing vector is closed by the // redirect_uri allowlist in the hooks.before middleware above and in src/routes/api/auth.$.ts. allowUnauthenticatedClientRegistration: true, - rateLimit: { - register: { window: 60, max: 5 }, - authorize: { window: 60, max: 30 }, - token: { window: 60, max: 20 }, - introspect: { window: 60, max: 60 }, - revoke: { window: 60, max: 30 }, - userinfo: { window: 60, max: 60 }, - }, + rateLimit: rateLimitConfig.betterAuth.oauthProvider, silenceWarnings: { oauthAuthServerConfig: true }, }), username({ diff --git a/src/integrations/orpc/rate-limit.ts b/src/integrations/orpc/rate-limit.ts index 3ad884154..24a55c2ec 100644 --- a/src/integrations/orpc/rate-limit.ts +++ b/src/integrations/orpc/rate-limit.ts @@ -1,15 +1,35 @@ import { createRatelimitMiddleware } from "@orpc/experimental-ratelimit"; import { MemoryRatelimiter } from "@orpc/experimental-ratelimit/memory"; +import { rateLimitConfig } from "@/integrations/rate-limit/config"; + type ContextWithHeaders = { reqHeaders?: Headers; user?: { id: string } | null; }; +const TRUSTED_IP_HEADERS = ["cf-connecting-ip", "x-forwarded-for", "x-real-ip", "true-client-ip"] as const; + +function getTrustedIp(headers?: Headers): string | null { + if (!headers) return null; + + for (const headerName of TRUSTED_IP_HEADERS) { + const raw = headers.get(headerName)?.trim(); + if (!raw) continue; + + // Some proxies provide a comma-delimited chain; first item is the original client. + const ip = raw.split(",")[0]?.trim(); + if (!ip) continue; + + return ip; + } + + return null; +} + function getClientKey(headers?: Headers): string { - const cfIp = headers?.get("cf-connecting-ip")?.trim(); - const cfRay = headers?.get("cf-ray"); - if (cfIp && cfRay) return `cf:${cfIp}`; + const trustedIp = getTrustedIp(headers); + if (trustedIp) return `ip:${trustedIp}`; const userAgent = headers?.get("user-agent")?.trim() ?? "unknown"; const language = headers?.get("accept-language")?.split(",")[0]?.trim() ?? "none"; @@ -21,9 +41,30 @@ function getUserKey(context: ContextWithHeaders): string { return context.user?.id ?? "anon"; } -const resumePasswordLimiter = new MemoryRatelimiter({ maxRequests: 5, window: 10 * 60 * 1000 }); -const pdfLimiter = new MemoryRatelimiter({ maxRequests: 5, window: 60 * 1000 }); -const aiLimiter = new MemoryRatelimiter({ maxRequests: 20, window: 60 * 1000 }); +function getInputKeyPart(input: unknown): string { + if (!input || typeof input !== "object") return "no-input"; + + const inputRecord = input as Record; + const id = inputRecord.id; + + if (typeof id === "string" && id.trim()) return id; + + const username = inputRecord.username; + const slug = inputRecord.slug; + + if (typeof username === "string" && typeof slug === "string") return `${username}:${slug}`; + + return "no-id"; +} + +const resumePasswordLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.resumePassword); +const pdfLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.pdfExport); +const aiLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.aiRequest); +const jobsSearchLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.jobsSearch); +const jobsTestConnectionLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.jobsTestConnection); +const storageUploadLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.storageUpload); +const storageDeleteLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.storageDelete); +const resumeMutationLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.resumeMutations); export const resumePasswordRateLimit = createRatelimitMiddleware< ContextWithHeaders, @@ -42,3 +83,28 @@ export const aiRequestRateLimit = createRatelimitMiddleware `ai-request:${getUserKey(context)}:${input.provider}`, }); + +export const jobsSearchRateLimit = createRatelimitMiddleware({ + limiter: jobsSearchLimiter, + key: ({ context }, input) => `jobs-search:${getUserKey(context)}:${input.params.query.trim().toLowerCase()}`, +}); + +export const jobsTestConnectionRateLimit = createRatelimitMiddleware({ + limiter: jobsTestConnectionLimiter, + key: ({ context }) => `jobs-test-connection:${getUserKey(context)}`, +}); + +export const storageUploadRateLimit = createRatelimitMiddleware({ + limiter: storageUploadLimiter, + key: ({ context }) => `storage-upload:${getUserKey(context)}`, +}); + +export const storageDeleteRateLimit = createRatelimitMiddleware({ + limiter: storageDeleteLimiter, + key: ({ context }, input) => `storage-delete:${getUserKey(context)}:${input.filename}`, +}); + +export const resumeMutationRateLimit = createRatelimitMiddleware({ + limiter: resumeMutationLimiter, + key: ({ context }, input) => `resume-mutation:${getUserKey(context)}:${getInputKeyPart(input)}`, +}); diff --git a/src/integrations/orpc/router/jobs.ts b/src/integrations/orpc/router/jobs.ts index fce3c80a1..598befc95 100644 --- a/src/integrations/orpc/router/jobs.ts +++ b/src/integrations/orpc/router/jobs.ts @@ -4,6 +4,7 @@ import z from "zod"; import { postFilterOptionsSchema, searchParamsSchema } from "@/schema/jobs"; import { protectedProcedure } from "../context"; +import { jobsSearchRateLimit, jobsTestConnectionRateLimit } from "../rate-limit"; import { jobsService } from "../services/jobs"; export const jobsRouter = { @@ -19,6 +20,7 @@ export const jobsRouter = { successDescription: "The RapidAPI key is valid and JSearch is reachable.", }) .input(z.object({ apiKey: z.string().min(1) })) + .use(jobsTestConnectionRateLimit) .errors({ BAD_GATEWAY: { message: "The JSearch API returned an error or is unreachable.", @@ -54,6 +56,7 @@ export const jobsRouter = { filters: postFilterOptionsSchema.optional(), }), ) + .use(jobsSearchRateLimit) .errors({ BAD_GATEWAY: { message: "The JSearch API returned an error or is unreachable.", diff --git a/src/integrations/orpc/router/resume.ts b/src/integrations/orpc/router/resume.ts index 36a804182..8236b3e05 100644 --- a/src/integrations/orpc/router/resume.ts +++ b/src/integrations/orpc/router/resume.ts @@ -6,7 +6,7 @@ import { generateRandomName, slugify } from "@/utils/string"; import { protectedProcedure, publicProcedure } from "../context"; import { resumeDto } from "../dto/resume"; -import { resumePasswordRateLimit } from "../rate-limit"; +import { resumeMutationRateLimit, resumePasswordRateLimit } from "../rate-limit"; import { resumeService } from "../services/resume"; const tagsRouter = { @@ -145,6 +145,7 @@ export const resumeRouter = { successDescription: "The ID of the newly created resume.", }) .input(resumeDto.create.input) + .use(resumeMutationRateLimit) .output(resumeDto.create.output) .errors({ RESUME_SLUG_ALREADY_EXISTS: { @@ -175,6 +176,7 @@ export const resumeRouter = { successDescription: "The ID of the imported resume.", }) .input(resumeDto.import.input) + .use(resumeMutationRateLimit) .output(resumeDto.import.output) .errors({ RESUME_SLUG_ALREADY_EXISTS: { @@ -208,6 +210,7 @@ export const resumeRouter = { successDescription: "The updated resume with its full data.", }) .input(resumeDto.update.input) + .use(resumeMutationRateLimit) .output(resumeDto.update.output) .errors({ RESUME_SLUG_ALREADY_EXISTS: { @@ -239,6 +242,7 @@ export const resumeRouter = { successDescription: "The patched resume with its full data.", }) .input(resumeDto.patch.input) + .use(resumeMutationRateLimit) .output(resumeDto.patch.output) .errors({ INVALID_PATCH_OPERATIONS: { @@ -266,6 +270,7 @@ export const resumeRouter = { successDescription: "The resume lock status was updated successfully.", }) .input(resumeDto.setLocked.input) + .use(resumeMutationRateLimit) .output(resumeDto.setLocked.output) .handler(async ({ context, input }) => { return resumeService.setLocked({ @@ -287,6 +292,7 @@ export const resumeRouter = { successDescription: "The resume password was set successfully.", }) .input(resumeDto.setPassword.input) + .use(resumeMutationRateLimit) .output(resumeDto.setPassword.output) .handler(async ({ context, input }) => { return resumeService.setPassword({ @@ -336,6 +342,7 @@ export const resumeRouter = { successDescription: "The resume password was removed successfully.", }) .input(resumeDto.removePassword.input) + .use(resumeMutationRateLimit) .output(resumeDto.removePassword.output) .handler(async ({ context, input }) => { return resumeService.removePassword({ @@ -356,6 +363,7 @@ export const resumeRouter = { successDescription: "The ID of the duplicated resume.", }) .input(resumeDto.duplicate.input) + .use(resumeMutationRateLimit) .output(resumeDto.duplicate.output) .handler(async ({ context, input }) => { const original = await resumeService.getById({ id: input.id, userId: context.user.id }); @@ -382,6 +390,7 @@ export const resumeRouter = { successDescription: "The resume and its associated files were deleted successfully.", }) .input(resumeDto.delete.input) + .use(resumeMutationRateLimit) .output(resumeDto.delete.output) .handler(async ({ context, input }) => { return resumeService.delete({ id: input.id, userId: context.user.id }); diff --git a/src/integrations/orpc/router/storage.ts b/src/integrations/orpc/router/storage.ts index de1219d3c..b34b79b06 100644 --- a/src/integrations/orpc/router/storage.ts +++ b/src/integrations/orpc/router/storage.ts @@ -2,6 +2,7 @@ import { ORPCError } from "@orpc/server"; import z from "zod"; import { protectedProcedure } from "../context"; +import { storageDeleteRateLimit, storageUploadRateLimit } from "../rate-limit"; import { getStorageService, isImageFile, processImageForUpload, uploadFile } from "../services/storage"; const storageService = getStorageService(); @@ -31,6 +32,7 @@ export const storageRouter = { successDescription: "The file was uploaded successfully.", }) .input(fileSchema) + .use(storageUploadRateLimit) .output( z.object({ url: z.string().describe("The public URL to access the uploaded file."), @@ -79,6 +81,7 @@ export const storageRouter = { successDescription: "The file was deleted successfully.", }) .input(filenameSchema) + .use(storageDeleteRateLimit) .output(z.void()) .errors({ NOT_FOUND: { diff --git a/src/integrations/rate-limit/config.ts b/src/integrations/rate-limit/config.ts new file mode 100644 index 000000000..cbcb111ee --- /dev/null +++ b/src/integrations/rate-limit/config.ts @@ -0,0 +1,42 @@ +export const rateLimitConfig = { + betterAuth: { + global: { + enabled: true, + window: 60, + max: 60, + customRules: { + "/sign-in/email": { window: 60, max: 5 }, + "/sign-up/email": { window: 60, max: 3 }, + "/request-password-reset": { window: 600, max: 3 }, + "/send-verification-email": { window: 600, max: 3 }, + "/two-factor/verify-otp": { window: 600, max: 5 }, + "/two-factor/verify-totp": { window: 600, max: 5 }, + "/two-factor/verify-backup-code": { window: 600, max: 5 }, + "/is-username-available": { window: 60, max: 20 }, + }, + }, + oauthProvider: { + register: { window: 60, max: 5 }, + authorize: { window: 60, max: 30 }, + token: { window: 60, max: 20 }, + introspect: { window: 60, max: 60 }, + revoke: { window: 60, max: 30 }, + userinfo: { window: 60, max: 60 }, + }, + apiKey: { + enabled: true, + timeWindow: 60 * 60 * 1000, + maxRequests: 1000, + }, + }, + orpc: { + resumePassword: { maxRequests: 5, window: 10 * 60 * 1000 }, + pdfExport: { maxRequests: 5, window: 60 * 1000 }, + aiRequest: { maxRequests: 20, window: 60 * 1000 }, + jobsSearch: { maxRequests: 30, window: 60 * 1000 }, + jobsTestConnection: { maxRequests: 10, window: 60 * 1000 }, + storageUpload: { maxRequests: 20, window: 60 * 1000 }, + storageDelete: { maxRequests: 30, window: 60 * 1000 }, + resumeMutations: { maxRequests: 60, window: 60 * 1000 }, + }, +} as const; diff --git a/src/routes/$username/$slug.tsx b/src/routes/$username/$slug.tsx index f7db7fa85..09dd4104a 100644 --- a/src/routes/$username/$slug.tsx +++ b/src/routes/$username/$slug.tsx @@ -15,6 +15,9 @@ type LoaderData = Omit & { data: Re export const Route = createFileRoute("/$username/$slug")({ component: RouteComponent, + beforeLoad: async ({ location }) => { + console.log("/$username/$slug was invoked with pathname: ", location.pathname); + }, loader: async ({ context, params }) => { const { username, slug } = params; const resume = await context.queryClient.ensureQueryData( diff --git a/src/routes/uploads/$userId.$.tsx b/src/routes/uploads/$userId.$.tsx index 8c64e8e77..de6510c16 100644 --- a/src/routes/uploads/$userId.$.tsx +++ b/src/routes/uploads/$userId.$.tsx @@ -143,7 +143,6 @@ function buildResponseHeaders({ headers.set("X-Robots-Tag", "noindex, nofollow"); headers.set("Cross-Origin-Resource-Policy", "same-site"); headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); - headers.set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox;"); headers.set("X-Frame-Options", "DENY"); headers.set("X-Download-Options", "noopen"); headers.set("Access-Control-Allow-Origin", env.APP_URL); diff --git a/src/server.ts b/src/server.ts index a2269f3d8..106b5fe73 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,9 +4,12 @@ import { FastResponse } from "srvx"; globalThis.Response = FastResponse; const fontSrc = "'self' https://cdn.jsdelivr.net https://fonts.gstatic.com"; -const scriptSrc = "'self' 'unsafe-inline' https://cdn.jsdelivr.net"; +const scriptSrc = "'self' 'unsafe-eval' 'unsafe-inline' https://cdn.jsdelivr.net"; const styleSrc = "'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net"; +/** + * Sets a header if not already present. + */ function setIfAbsent(headers: Headers, key: string, value: string) { if (!headers.has(key)) headers.set(key, value); } @@ -17,6 +20,7 @@ export default createServerEntry({ const headers = new Headers(response.headers); const contentType = headers.get("content-type") ?? ""; + // Policy for PDF printer routes if (request.url.includes("/printer/")) { headers.set( "Content-Security-Policy-Report-Only",