From d48bbc0fc30df20ee0416cc5eba979ce413793a7 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Thu, 28 May 2026 16:48:06 -0400 Subject: [PATCH] security cleanup, fix turnstile --- app.config.ts | 2 +- src/components/blog/MermaidRenderer.tsx | 41 +++++++++- src/components/blog/PostBodyClient.tsx | 48 +++++++++++- src/components/ui/Input.tsx | 12 ++- src/env/server.ts | 24 ++++++ src/routes/contact.tsx | 77 +++++++++--------- src/server/api/routers/auth.ts | 8 +- src/server/api/routers/database.ts | 28 ++++--- src/server/api/routers/lineage/auth.ts | 63 ++++++++++++++- src/server/api/routers/lineage/database.ts | 90 +++------------------- src/server/api/routers/misc.ts | 14 +++- src/server/api/routers/nessa.ts | 50 +++++------- src/server/middleare/security-headers.ts | 19 +++++ src/server/security.ts | 31 ++++++-- 14 files changed, 318 insertions(+), 189 deletions(-) create mode 100644 src/server/middleare/security-headers.ts diff --git a/app.config.ts b/app.config.ts index 1188e92..be22a3e 100644 --- a/app.config.ts +++ b/app.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ build: { rollupOptions: { output: { - manualChunks: (id) => { + manualChunks: (id: string) => { // Bundle highlight.js and lowlight together if (id.includes("highlight.js") || id.includes("lowlight")) { return "highlight"; diff --git a/src/components/blog/MermaidRenderer.tsx b/src/components/blog/MermaidRenderer.tsx index 4cdfcb2..7efced7 100644 --- a/src/components/blog/MermaidRenderer.tsx +++ b/src/components/blog/MermaidRenderer.tsx @@ -1,5 +1,42 @@ import { onMount } from "solid-js"; +/** + * Sanitize Mermaid SVG output by removing dangerous elements and attributes. + * Prevents stored XSS via malicious Mermaid diagram code. + */ +function sanitizeMermaidSvg(svgString: string): string { + const parser = new DOMParser(); + const doc = parser.parseFromString(svgString, "text/html"); + + // Remove dangerous elements + doc.querySelectorAll("script, iframe, object, embed, form, link, meta, base").forEach((el) => { + el.remove(); + }); + + // Remove event handlers and dangerous attributes from all elements + doc.querySelectorAll("[on*], [href*='javascript:'], [style*='expression(']").forEach((el) => { + const attrs = Array.from(el.attributes); + attrs.forEach((attr) => { + if ( + attr.name.startsWith("on") || + attr.name === "href" || + attr.name === "style" + ) { + const value = attr.value; + if ( + attr.name.startsWith("on") || + value.includes("javascript:") || + value.includes("expression(") + ) { + el.removeAttribute(attr.name); + } + } + }); + }); + + return doc.body.innerHTML; +} + export default function MermaidRenderer() { onMount(async () => { const mermaidPres = document.querySelectorAll('pre[data-type="mermaid"]'); @@ -12,7 +49,7 @@ export default function MermaidRenderer() { mermaid.initialize({ startOnLoad: false, theme: "dark", - securityLevel: "loose", + securityLevel: "strict", fontFamily: "monospace", themeVariables: { darkMode: true, @@ -38,7 +75,7 @@ export default function MermaidRenderer() { const wrapper = document.createElement("div"); wrapper.className = "mermaid-rendered"; - wrapper.innerHTML = svg; + wrapper.innerHTML = sanitizeMermaidSvg(svg); pre.replaceWith(wrapper); } catch (err) { console.error("Failed to render mermaid diagram:", err); diff --git a/src/components/blog/PostBodyClient.tsx b/src/components/blog/PostBodyClient.tsx index efb8690..5ce8449 100644 --- a/src/components/blog/PostBodyClient.tsx +++ b/src/components/blog/PostBodyClient.tsx @@ -3,6 +3,46 @@ import type { HLJSApi } from "highlight.js"; const MermaidRenderer = lazy(() => import("./MermaidRenderer")); +/** + * Sanitize HTML content to prevent XSS when rendering user-generated blog content. + * Removes dangerous elements (script, iframe, object, etc.) and event handlers. + */ +function sanitizeHtml(html: string): string { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + + // Remove dangerous elements + doc + .querySelectorAll( + "script, iframe, object, embed, form, link, meta, base, svg script" + ) + .forEach((el) => el.remove()); + + // Remove event handler attributes and dangerous URLs from all elements + doc.querySelectorAll("[on*], [href], [style], [action]").forEach((el) => { + const attrs = Array.from(el.attributes); + attrs.forEach((attr) => { + const name = attr.name; + const value = attr.value; + if ( + name.startsWith("on") || + (name === "href" && + (value.startsWith("javascript:") || + value.startsWith("data:text/html"))) || + (name === "style" && + (value.includes("expression(") || + value.includes("url(") || + value.includes("javascript:"))) || + (name === "action" && value.startsWith("javascript:")) + ) { + el.removeAttribute(name); + } + }); + }); + + return doc.body.innerHTML; +} + export interface PostBodyClientProps { body: string; hasCodeBlock: boolean; @@ -21,10 +61,10 @@ export default function PostBodyClient(props: PostBodyClientProps) { const processCodeBlocks = () => { if (!contentRef) return; - const codeBlocks = contentRef.querySelectorAll("pre code"); + const codeBlocks = contentRef.querySelectorAll("pre code"); codeBlocks.forEach((codeBlock) => { - const pre = codeBlock.parentElement; + const pre = codeBlock.parentElement as HTMLPreElement | null; if (!pre) return; if (pre.dataset.type === "mermaid") return; @@ -228,7 +268,7 @@ export default function PostBodyClient(props: PostBodyClientProps) { const referencesHeadingText = marker?.getAttribute("data-heading") || "References"; - const headings = contentRef.querySelectorAll("h2"); + const headings = contentRef.querySelectorAll("h2"); let referencesSection: HTMLElement | null = null; headings.forEach((heading) => { @@ -401,7 +441,7 @@ export default function PostBodyClient(props: PostBodyClientProps) { id="post-content-body" ref={contentRef} class="text-text prose dark:prose-invert max-w-none" - innerHTML={props.body} + innerHTML={sanitizeHtml(props.body)} /> diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx index 3f908dc..11d120b 100644 --- a/src/components/ui/Input.tsx +++ b/src/components/ui/Input.tsx @@ -1,10 +1,11 @@ -import { JSX, splitProps } from "solid-js"; +import { type JSX, splitProps } from "solid-js"; export interface InputProps extends JSX.InputHTMLAttributes { label?: string; error?: string; helperText?: string; ref?: HTMLInputElement | ((el: HTMLInputElement) => void); + containerClass?: string; } export default function Input(props: InputProps) { @@ -12,11 +13,16 @@ export default function Input(props: InputProps) { "label", "error", "helperText", - "ref" + "ref", + "containerClass" ]); + const containerClasses = ["input-group", local.containerClass] + .filter(Boolean) + .join(" "); + return ( -
+
{ ); if (!turnstileValid) { - return redirect("/contact?error=Security verification failed. Please refresh and try again."); + return redirect( + "/contact?error=Security verification failed. Please refresh and try again." + ); } const contactExp = getCookie("contactRequestSent"); @@ -162,8 +157,6 @@ const sendContactEmail = action(async (formData: FormData) => { export default function ContactPage() { const [searchParams] = useSearchParams(); - const location = useLocation(); - const navigate = useNavigate(); const viewer = () => searchParams.viewer ?? "default"; // Load server data using createAsync @@ -175,36 +168,45 @@ export default function ContactPage() { searchParams.success === "true" ); const [error, setError] = createSignal( - searchParams.error ? decodeURIComponent(searchParams.error) : "" + searchParams.error ? decodeURIComponent(String(searchParams.error)) : "" ); const [loading, setLoading] = createSignal(false); const [user, setUser] = createSignal(null); const [jsEnabled, setJsEnabled] = createSignal(false); const [turnstileToken, setTurnstileToken] = createSignal(""); + const [turnstileWidgetId, setTurnstileWidgetId] = createSignal( + null + ); const { remainingTime, startCountdown, setRemainingTime } = useCountdown(); onMount(() => { setJsEnabled(true); - // Load Cloudflare Turnstile script + // Load Cloudflare Turnstile script with explicit rendering const script = document.createElement("script"); script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js"; script.async = true; script.defer = true; - document.head.appendChild(script); - - // Get Turnstile token after widget renders - setTimeout(() => { + script.onload = () => { if (typeof window !== "undefined" && (window as any).turnstile) { - const widgetEl = document.getElementById("turnstile-widget-1"); - if (widgetEl) { - const widgetId = widgetEl.getAttribute("data-widget-id"); - const token = (window as any).turnstile.getResponse(widgetId || ""); - if (token) setTurnstileToken(token); + const container = document.getElementById("turnstile-widget-1"); + if (container) { + const id = (window as any).turnstile.render(container, { + sitekey: clientEnv.VITE_TURNSTILE_SITE_KEY, + theme: "dark", + callback: (token: string) => { + setTurnstileToken(token); + }, + "expired-callback": () => { + setTurnstileToken(""); + } + }); + setTurnstileWidgetId(id); } } - }, 1000); + }; + document.head.appendChild(script); api.user.getProfile .query() @@ -252,10 +254,15 @@ export default function ContactPage() { if (name && email && message) { // Get fresh Turnstile token let currentToken = turnstileToken(); - if (!currentToken && typeof window !== "undefined" && (window as any).turnstile) { - const widgetEl = document.querySelector(".turnstile-widget"); + if ( + !currentToken && + typeof window !== "undefined" && + (window as any).turnstile + ) { + const widgetEl = document.getElementById("turnstile-widget-1"); if (widgetEl) { - currentToken = (window as any).turnstile.getResponse(""); + const id = turnstileWidgetId(); + currentToken = (window as any).turnstile.getResponse(id || widgetEl); } } @@ -286,7 +293,8 @@ export default function ContactPage() { if (typeof window !== "undefined" && (window as any).turnstile) { const widgetEl = document.getElementById("turnstile-widget-1"); if (widgetEl) { - (window as any).turnstile.reset(widgetEl.getAttribute("data-widget-id") || ""); + const id = turnstileWidgetId(); + (window as any).turnstile.reset(id || widgetEl); } } setTurnstileToken(""); @@ -416,16 +424,6 @@ export default function ContactPage() { action={sendContactEmail} class="w-full" > - {/* Cloudflare Turnstile Widget */} -
-
-
-
Message
-
+
+
0 || diff --git a/src/server/api/routers/auth.ts b/src/server/api/routers/auth.ts index d38902c..7ba22bc 100644 --- a/src/server/api/routers/auth.ts +++ b/src/server/api/routers/auth.ts @@ -1173,10 +1173,10 @@ export const authRouter = createTRPCRouter({ }); } - // Generate 6-digit code - const loginCode = Math.floor( - 100000 + Math.random() * 900000 - ).toString(); + // Generate cryptographically secure 6-digit code (p8-010) + const randomBytes = new Uint32Array(1); + crypto.getRandomValues(randomBytes); + const loginCode = (100000 + (randomBytes[0] % 900000)).toString(); const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); const token = await new SignJWT({ diff --git a/src/server/api/routers/database.ts b/src/server/api/routers/database.ts index ae88798..8aec663 100644 --- a/src/server/api/routers/database.ts +++ b/src/server/api/routers/database.ts @@ -372,7 +372,7 @@ export const databaseRouter = createTRPCRouter({ body: z.string().nullable(), banner_photo: z.string().nullable(), published: z.boolean(), - tags: z.array(z.string()).nullable(), + tags: z.array(z.string().max(50).trim()).nullable(), author_id: z.string() }) ) @@ -405,12 +405,13 @@ export const databaseRouter = createTRPCRouter({ const results = await conn.execute({ sql: query, args: params }); if (input.tags && input.tags.length > 0) { - let tagQuery = "INSERT INTO Tag (value, post_id) VALUES "; - let values = input.tags.map( - (tag) => `("${tag}", ${results.lastInsertRowid})` - ); - tagQuery += values.join(", "); - await conn.execute(tagQuery); + const validTags = input.tags.filter((t) => t.length > 0); + for (const tag of validTags) { + await conn.execute({ + sql: "INSERT INTO Tag (value, post_id) VALUES (?, ?)", + args: [tag, results.lastInsertRowid] + }); + } } await cache.deleteByPrefix("blog-"); @@ -434,7 +435,7 @@ export const databaseRouter = createTRPCRouter({ body: z.string().nullable().optional(), banner_photo: z.string().nullable().optional(), published: z.boolean().nullable().optional(), - tags: z.array(z.string()).nullable().optional(), + tags: z.array(z.string().max(50).trim()).nullable().optional(), author_id: z.string() }) ) @@ -523,10 +524,13 @@ export const databaseRouter = createTRPCRouter({ }); if (input.tags && input.tags.length > 0) { - let tagQuery = "INSERT INTO Tag (value, post_id) VALUES "; - let values = input.tags.map((tag) => `("${tag}", ${input.id})`); - tagQuery += values.join(", "); - await conn.execute(tagQuery); + const validTags = input.tags.filter((t) => t.length > 0); + for (const tag of validTags) { + await conn.execute({ + sql: "INSERT INTO Tag (value, post_id) VALUES (?, ?)", + args: [tag, input.id] + }); + } } await cache.deleteByPrefix("blog-"); diff --git a/src/server/api/routers/lineage/auth.ts b/src/server/api/routers/lineage/auth.ts index 32b5e7a..683d2e9 100644 --- a/src/server/api/routers/lineage/auth.ts +++ b/src/server/api/routers/lineage/auth.ts @@ -10,7 +10,7 @@ import { } from "~/server/utils"; import { env } from "~/env/server"; import { TRPCError } from "@trpc/server"; -import { SignJWT, jwtVerify } from "jose"; +import { SignJWT, jwtVerify, importJWK } from "jose"; import { LibsqlError } from "@libsql/client/web"; import { createClient as createAPIClient } from "@tursodatabase/api"; @@ -354,11 +354,68 @@ export const lineageAuthRouter = createTRPCRouter({ .input( z.object({ email: z.string().email().optional(), - userString: z.string(), + idToken: z.string(), }) ) .mutation(async ({ input }) => { - const { email, userString } = input; + const { email } = input; + + // Verify Apple ID token signature using JWKS + const appleKeysResponse = await fetch( + "https://appleid.apple.com/auth/keys" + ); + if (!appleKeysResponse.ok) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to fetch Apple public keys", + }); + } + + const appleKeys = (await appleKeysResponse.json()) as { + keys: Array<{ + kty: string; + kid: string; + use: string; + alg: string; + n: string; + e: string; + }>; + }; + + // Decode JWT header to find matching key + const [headerB64] = input.idToken.split("."); + if (!headerB64) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Invalid Apple ID token format", + }); + } + const headerJson = Buffer.from(headerB64, "base64url").toString("utf8"); + const header = JSON.parse(headerJson) as { kid: string }; + const jwk = appleKeys.keys.find((k) => k.kid === header.kid); + if (!jwk) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Apple public key not found", + }); + } + + const publicKey = await importJWK(jwk, "RS256"); + const jwtOptions: Parameters[2] = { + algorithms: ["RS256"], + issuer: "https://appleid.apple.com", + }; + if (env.APPLE_CLIENT_ID) { + jwtOptions.audience = env.APPLE_CLIENT_ID; + } + const { payload: tokenPayload } = await jwtVerify( + input.idToken, + publicKey, + jwtOptions + ); + + // Use verified Apple user ID from token (not from user input) + const userString = tokenPayload.sub as string; let dbName; let dbToken; diff --git a/src/server/api/routers/lineage/database.ts b/src/server/api/routers/lineage/database.ts index 09bde90..d90ef81 100644 --- a/src/server/api/routers/lineage/database.ts +++ b/src/server/api/routers/lineage/database.ts @@ -6,8 +6,6 @@ import { } from "~/server/utils"; import { env } from "~/env/server"; import { TRPCError } from "@trpc/server"; -import { OAuth2Client } from "google-auth-library"; -import { jwtVerify } from "jose"; import { createTRPCRouter, publicProcedure } from "~/server/api/utils"; import { fetchWithTimeout, @@ -18,84 +16,8 @@ import { } from "~/server/fetch-utils"; export const lineageDatabaseRouter = createTRPCRouter({ - credentials: publicProcedure - .input( - z.object({ - email: z.string().email(), - provider: z.enum(["email", "google", "apple"]), - authToken: z.string() - }) - ) - .mutation(async ({ input }) => { - const { email, provider, authToken } = input; - - try { - let valid_request = false; - - if (provider === "email") { - const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); - const { payload } = await jwtVerify(authToken, secret); - if (payload.email === email) { - valid_request = true; - } - } else if (provider === "google") { - const CLIENT_ID = env.VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE; - if (!CLIENT_ID) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Google client ID not configured" - }); - } - const client = new OAuth2Client(CLIENT_ID); - const ticket = await client.verifyIdToken({ - idToken: authToken, - audience: CLIENT_ID - }); - if (ticket.getPayload()?.email === email) { - valid_request = true; - } - } else { - const conn = LineageConnectionFactory(); - const query = "SELECT * FROM User WHERE apple_user_string = ?"; - const res = await conn.execute({ sql: query, args: [authToken] }); - if (res.rows.length > 0 && res.rows[0].email === email) { - valid_request = true; - } - } - - if (valid_request) { - const conn = LineageConnectionFactory(); - const query = "SELECT * FROM User WHERE email = ? LIMIT 1"; - const params = [email]; - const res = await conn.execute({ sql: query, args: params }); - - if (res.rows.length === 1) { - const user = res.rows[0]; - return { - success: true, - db_name: user.database_name as string, - db_token: user.database_token as string - }; - } - - throw new TRPCError({ - code: "NOT_FOUND", - message: "No user found" - }); - } else { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Invalid credentials" - }); - } - } catch (error) { - if (error instanceof TRPCError) throw error; - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Authentication failed" - }); - } - }), + // credentials endpoint removed (p8-008): was exposing persistent DB tokens to clients. + // Database access should be proxied through tRPC server-side procedures. deletionInit: publicProcedure .input( @@ -155,6 +77,14 @@ export const lineageDatabaseRouter = createTRPCRouter({ if (skip_cron) { if (send_dump_target) { + // Validate dump target matches the authenticated user's email (p8-005) + if (send_dump_target !== email) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Dump target must match account email" + }); + } + const dumpRes = await dumpAndSendDB({ dbName: db_name, dbToken: db_token, diff --git a/src/server/api/routers/misc.ts b/src/server/api/routers/misc.ts index 3bf6c68..2b984c1 100644 --- a/src/server/api/routers/misc.ts +++ b/src/server/api/routers/misc.ts @@ -189,7 +189,7 @@ export const miscRouter = createTRPCRouter({ z.object({ key: z.string(), newAttachmentString: z.string(), - type: z.string(), + type: z.enum(["Post", "Comment", "User"]), id: z.number() }) ) @@ -214,6 +214,7 @@ export const miscRouter = createTRPCRouter({ const res = await client.send(command); const conn = ConnectionFactory(); + // input.type is validated by z.enum allowlist above — safe for identifier use const query = `UPDATE ${input.type} SET attachments = ? WHERE id = ?`; await conn.execute({ sql: query, @@ -344,13 +345,22 @@ export const miscRouter = createTRPCRouter({ const apiKey = env.SENDINBLUE_KEY; const apiUrl = "https://api.sendinblue.com/v3/smtp/email"; + // HTML-escape user input to prevent HTML injection in email (p8-006) + const escapeHtml = (str: string) => + str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + const sendinblueData = { sender: { name: "freno.me", email: "michael@freno.me" }, to: [{ email: "michael@freno.me" }], - htmlContent: `
Request Name: ${input.name}
Request Email: ${input.email}
Request Message: ${input.message}
`, + htmlContent: `
Request Name: ${escapeHtml(input.name)}
Request Email: ${escapeHtml(input.email)}
Request Message: ${escapeHtml(input.message)}
`, subject: "freno.me Contact Request" }; diff --git a/src/server/api/routers/nessa.ts b/src/server/api/routers/nessa.ts index eaf75a3..67fef37 100644 --- a/src/server/api/routers/nessa.ts +++ b/src/server/api/routers/nessa.ts @@ -1,6 +1,7 @@ import { createTRPCRouter, nessaProcedure, publicProcedure } from "../utils"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; +import { jwtVerify, importJWK } from "jose"; import { NessaConnectionFactory } from "~/server/database"; import { cache } from "~/server/cache"; import { hashPassword, checkPasswordSafe } from "~/server/utils"; @@ -628,45 +629,30 @@ export const nessaDbRouter = createTRPCRouter({ const header = JSON.parse(headerJson) as { kid: string; alg: string }; // Find the matching key - const key = appleKeys.keys.find((k) => k.kid === header.kid); - if (!key) { + const jwk = appleKeys.keys.find((k) => k.kid === header.kid); + if (!jwk) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Apple public key not found" }); } - // For simplicity, we'll decode the payload and verify basic claims - // In production, you should use a proper JWT library like jose to verify the signature - const [, payloadB64] = input.idToken.split("."); - if (!payloadB64) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Invalid Apple ID token format" - }); - } + // Import the Apple JWK key for signature verification + const publicKey = await importJWK(jwk, "RS256"); - const payloadJson = Buffer.from(payloadB64, "base64url").toString( - "utf8" + // Verify the Apple ID token signature and claims using jose + const jwtOptions: Parameters[2] = { + algorithms: ["RS256"], + issuer: "https://appleid.apple.com" + }; + if (env.APPLE_CLIENT_ID) { + jwtOptions.audience = env.APPLE_CLIENT_ID; + } + const { payload: tokenPayload } = await jwtVerify( + input.idToken, + publicKey, + jwtOptions ); - const tokenPayload = JSON.parse(payloadJson) as AppleTokenPayload; - - // Validate the token payload - if (tokenPayload.iss !== "https://appleid.apple.com") { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Invalid token issuer" - }); - } - - // Check if token is expired - const now = Math.floor(Date.now() / 1000); - if (tokenPayload.exp < now) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Token has expired" - }); - } // Apple user ID from token should match the one provided if (tokenPayload.sub !== input.appleUserId) { @@ -676,7 +662,7 @@ export const nessaDbRouter = createTRPCRouter({ }); } - const appleUserId = tokenPayload.sub; + const appleUserId = tokenPayload.sub as string; // Apple only sends email on first sign-in, so use input.email if token doesn't have it const email = tokenPayload.email ?? input.email; const firstName = input.firstName ?? "Apple"; diff --git a/src/server/middleare/security-headers.ts b/src/server/middleare/security-headers.ts new file mode 100644 index 0000000..fb9b5eb --- /dev/null +++ b/src/server/middleare/security-headers.ts @@ -0,0 +1,19 @@ +import { defineMiddleware } from "vinxi/http"; + +// Security headers middleware — sets CSP and hardening headers on all responses +export default defineMiddleware((_event, next) => { + return next().then((response) => { + response.headers.set( + "Content-Security-Policy", + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" + ); + response.headers.set("X-Content-Type-Options", "nosniff"); + response.headers.set("X-Frame-Options", "DENY"); + response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); + response.headers.set( + "Permissions-Policy", + "camera=(), microphone=(), geolocation=()" + ); + return response; + }); +}); diff --git a/src/server/security.ts b/src/server/security.ts index e61ab9d..f7c7638 100644 --- a/src/server/security.ts +++ b/src/server/security.ts @@ -252,17 +252,34 @@ async function cleanupExpiredRateLimits(): Promise { } /** - * Get client IP address from request headers + * Get client IP address from request headers. + * Only trusts X-Forwarded-For in production (set by Vercel edge network). + * In development/test, uses socket address to prevent header spoofing. */ export function getClientIP(event: H3Event): string { - const forwarded = getHeaderValue(event, "x-forwarded-for"); - if (forwarded) { - return forwarded.split(",")[0].trim(); + // In production on Vercel, X-Forwarded-For is set by the edge network + // and cannot be spoofed by clients. In dev/test, ignore it. + if (env.NODE_ENV === "production") { + const forwarded = getHeaderValue(event, "x-forwarded-for"); + if (forwarded) { + return forwarded.split(",")[0].trim(); + } + const realIP = getHeaderValue(event, "x-real-ip"); + if (realIP) { + return realIP; + } } - const realIP = getHeaderValue(event, "x-real-ip"); - if (realIP) { - return realIP; + // Fallback: try socket remote address + try { + const nodeReq = event.node.req; + if (nodeReq?.socket?.remoteAddress) { + const addr = nodeReq.socket.remoteAddress; + // Clean up IPv6-mapped IPv4 addresses + return addr.replace(/^::ffff:/, ""); + } + } catch { + // socket access failed } return "unknown";