better rate limiting, verbose logging for username/slug path

This commit is contained in:
Amruth Pillai
2026-04-29 18:44:07 +02:00
parent 47abce351b
commit d6287dbd65
10 changed files with 143 additions and 33 deletions
+1
View File
@@ -1,4 +1,5 @@
{
"biome.enabled": false,
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "explicit"
+4 -24
View File
@@ -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({
+72 -6
View File
@@ -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<string, unknown>;
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<ContextWithHeaders,
limiter: aiLimiter,
key: ({ context }, input) => `ai-request:${getUserKey(context)}:${input.provider}`,
});
export const jobsSearchRateLimit = createRatelimitMiddleware<ContextWithHeaders, { params: { query: string } }>({
limiter: jobsSearchLimiter,
key: ({ context }, input) => `jobs-search:${getUserKey(context)}:${input.params.query.trim().toLowerCase()}`,
});
export const jobsTestConnectionRateLimit = createRatelimitMiddleware<ContextWithHeaders, { apiKey: string }>({
limiter: jobsTestConnectionLimiter,
key: ({ context }) => `jobs-test-connection:${getUserKey(context)}`,
});
export const storageUploadRateLimit = createRatelimitMiddleware<ContextWithHeaders, unknown>({
limiter: storageUploadLimiter,
key: ({ context }) => `storage-upload:${getUserKey(context)}`,
});
export const storageDeleteRateLimit = createRatelimitMiddleware<ContextWithHeaders, { filename: string }>({
limiter: storageDeleteLimiter,
key: ({ context }, input) => `storage-delete:${getUserKey(context)}:${input.filename}`,
});
export const resumeMutationRateLimit = createRatelimitMiddleware<ContextWithHeaders, unknown>({
limiter: resumeMutationLimiter,
key: ({ context }, input) => `resume-mutation:${getUserKey(context)}:${getInputKeyPart(input)}`,
});
+3
View File
@@ -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.",
+10 -1
View File
@@ -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 });
+3
View File
@@ -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: {
+42
View File
@@ -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;
+3
View File
@@ -15,6 +15,9 @@ type LoaderData = Omit<RouterOutput["resume"]["getBySlug"], "data"> & { 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(
-1
View File
@@ -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);
+5 -1
View File
@@ -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",