feat(security): harden auth, oauth, and printer endpoints

Add stricter URL and redirect validation, endpoint rate limiting, safer defaults for printer and compose config, and CSP protections across server and API surfaces.

Made-with: Cursor
This commit is contained in:
Amruth Pillai
2026-04-25 15:31:06 +02:00
parent d3102565e4
commit a42dbcd452
27 changed files with 1041 additions and 376 deletions
+12
View File
@@ -0,0 +1,12 @@
import { createFileRoute } from "@tanstack/react-router";
const okResponse = () => new Response("OK", { status: 200 });
export const Route = createFileRoute("/.well-known/$")({
server: {
handlers: {
GET: () => okResponse(),
HEAD: () => okResponse(),
},
},
});
+61 -15
View File
@@ -1,6 +1,40 @@
import { createFileRoute } from "@tanstack/react-router";
import { auth } from "@/integrations/auth/config";
import { env } from "@/utils/env";
import { isPrivateOrLoopbackHost, parseAllowedHostList, parseUrl } from "@/utils/url-security";
const oauthDynamicClientRedirectHosts = parseAllowedHostList(env.OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS);
const oauthAuthorizeSanitizedParams = [
"prompt",
"redirect_uri",
"client_id",
"code_challenge",
"code_challenge_method",
"response_type",
"scope",
"state",
"resource",
] as const;
function isAllowedDynamicClientRedirectUri(value: string) {
const parsed = parseUrl(value);
if (!parsed) return false;
if (parsed.protocol !== "https:") return false;
if (parsed.username || parsed.password) return false;
if (parsed.hash) return false;
if (isPrivateOrLoopbackHost(parsed.hostname)) return false;
const appOrigin = new URL(env.APP_URL).origin.toLowerCase();
const origin = parsed.origin.toLowerCase();
const hostname = parsed.hostname.toLowerCase();
if (origin === appOrigin) return true;
if (oauthDynamicClientRedirectHosts.has(origin)) return true;
if (oauthDynamicClientRedirectHosts.has(hostname)) return true;
return false;
}
function sanitizeOAuthAuthorizeRequest(request: Request): Request {
if (request.method !== "GET") return request;
@@ -19,15 +53,7 @@ function sanitizeOAuthAuthorizeRequest(request: Request): Request {
url.searchParams.set(key, sanitizeValue(value));
};
sanitizeParam("prompt");
sanitizeParam("redirect_uri");
sanitizeParam("client_id");
sanitizeParam("code_challenge");
sanitizeParam("code_challenge_method");
sanitizeParam("response_type");
sanitizeParam("scope");
sanitizeParam("state");
sanitizeParam("resource");
for (const key of oauthAuthorizeSanitizedParams) sanitizeParam(key);
const redirectUri = url.searchParams.get("redirect_uri");
if (redirectUri && !URL.canParse(redirectUri)) {
@@ -74,16 +100,36 @@ async function defaultPublicClientRegistration(request: Request): Promise<Reques
});
}
async function validateDynamicClientRegistrationRequest(request: Request): Promise<Response | undefined> {
if (request.method !== "POST") return;
const url = new URL(request.url);
if (!url.pathname.endsWith("/oauth2/register")) return;
const cloned = request.clone();
let body: Record<string, unknown>;
try {
body = await cloned.json();
} catch {
return Response.json({ message: "Invalid registration payload" }, { status: 400 });
}
const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
for (const redirectUri of redirectUris) {
if (typeof redirectUri !== "string" || !isAllowedDynamicClientRedirectUri(redirectUri)) {
return Response.json({ message: "redirect_uri is not allowed" }, { status: 400 });
}
}
}
async function handler({ request }: { request: Request }) {
const registrationValidationError = await validateDynamicClientRegistrationRequest(request);
if (registrationValidationError) return registrationValidationError;
const sanitizedRequest = sanitizeOAuthAuthorizeRequest(request);
const finalRequest = await defaultPublicClientRegistration(sanitizedRequest);
if (request.method === "GET" && request.url.endsWith("/spec.json")) {
const spec = await auth.api.generateOpenAPISchema();
return Response.json(spec);
}
return auth.handler(finalRequest);
}
+24 -7
View File
@@ -1,3 +1,4 @@
import { t } from "@lingui/core/macro";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { useEffect } from "react";
@@ -14,6 +15,12 @@ const searchSchema = z.object({
token: z.string().catch(""),
});
function assertValidPrinterToken(token: string, resumeId: string): void {
const tokenResumeId = verifyPrinterToken(token);
if (tokenResumeId === resumeId) return;
throw new Error("Printer token does not match resume ID");
}
export const Route = createFileRoute("/printer/$resumeId")({
component: RouteComponent,
validateSearch: zodValidator(searchSchema),
@@ -21,22 +28,32 @@ export const Route = createFileRoute("/printer/$resumeId")({
if (env.FLAG_DEBUG_PRINTER) return;
try {
// Verify the token and ensure it matches the resume ID
const tokenResumeId = verifyPrinterToken(search.token);
if (tokenResumeId !== params.resumeId) throw new Error();
assertValidPrinterToken(search.token, params.resumeId);
} catch {
// Invalid or missing token - throw error to be caught by error handler
throw redirect({ to: "/", search: {}, throw: true });
}
},
loader: async ({ params }) => {
loaderDeps: ({ search }) => ({ token: search.token }),
loader: async ({ params, deps }) => {
const client = getORPCClient();
const resume = await client.resume.getByIdForPrinter({ id: params.resumeId });
const resume = await client.resume.getByIdForPrinter({ id: params.resumeId, token: deps.token });
return { resume };
},
head: ({ loaderData }) => ({
meta: [{ title: loaderData ? `${loaderData.resume.data.basics.name} - Resume` : "Resume" }],
meta: [
{
title: loaderData
? `${loaderData.resume.data.basics.name} - ${t({
comment: "Browser tab suffix for printable resume pages",
message: "Resume",
})}`
: t({
comment: "Browser tab title before printable resume data finishes loading",
message: "Resume",
}),
},
],
}),
});