mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-06-22 04:11:55 +10:00
better rate limiting, verbose logging for username/slug path
This commit is contained in:
Vendored
+1
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"biome.enabled": false,
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.oxc": "explicit"
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)}`,
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user