security cleanup, fix turnstile

This commit is contained in:
2026-05-28 16:48:06 -04:00
parent b7187721db
commit d48bbc0fc3
14 changed files with 318 additions and 189 deletions

View File

@@ -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";

View File

@@ -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);

View File

@@ -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<HTMLElement>("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<HTMLElement>("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)}
/>
<Show when={props.hasMermaid}>
<MermaidRenderer />

View File

@@ -1,10 +1,11 @@
import { JSX, splitProps } from "solid-js";
import { type JSX, splitProps } from "solid-js";
export interface InputProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
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 (
<div class="input-group">
<div class={containerClasses}>
<input
{...others}
ref={local.ref}

24
src/env/server.ts vendored
View File

@@ -1,3 +1,26 @@
// ──────────────────────────────────────────────
// 🔒 GUARD: This module MUST NEVER be imported client-side.
// It contains secrets: DB tokens, JWT keys, AWS credentials, API tokens.
// ──────────────────────────────────────────────
// Build-time guard — Vite dead-code eliminates everything below when SSR=true.
// If this module is bundled client-side, it throws before any secrets are parsed.
if (!import.meta.env.SSR) {
throw new Error(
"[SERVER-ONLY] src/env/server.ts was imported client-side! " +
"This module contains server-only secrets (database credentials, JWT keys, API tokens) " +
"and must never be bundled in client code."
);
}
// Runtime defense-in-depth — throws if somehow evaluated in a browser context.
if (typeof window !== "undefined") {
throw new Error(
"[SERVER-ONLY] src/env/server.ts was loaded in a browser context. " +
"This is a critical security violation."
);
}
import { z } from "zod";
const serverEnvSchema = z.object({
@@ -34,6 +57,7 @@ const serverEnvSchema = z.object({
NESSA_DB_URL: z.string().min(1),
NESSA_DB_TOKEN: z.string().min(1),
NESSA_JWT_SECRET: z.string().min(1),
APPLE_CLIENT_ID: z.string().min(1).optional(),
VITE_TURNSTILE_SITE_KEY: z.string().min(1),
TURNSTILE_SECRET_KEY: z.string().min(1)
});

View File

@@ -1,16 +1,10 @@
import { createSignal, onMount, createEffect, Show } from "solid-js";
import {
useSearchParams,
useNavigate,
useLocation,
query,
createAsync
} from "@solidjs/router";
import { useSearchParams, query, createAsync } from "@solidjs/router";
import { A } from "@solidjs/router";
import { action, redirect } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import { api } from "~/lib/api";
import { getClientCookie, setClientCookie } from "~/lib/cookies.client";
import { getClientCookie } from "~/lib/cookies.client";
import CountdownCircleTimer from "~/components/CountdownCircleTimer";
import RevealDropDown from "~/components/RevealDropDown";
import Input from "~/components/ui/Input";
@@ -19,7 +13,6 @@ import { useCountdown } from "~/lib/useCountdown";
import type { UserProfile } from "~/types/user";
import { getCookie, setCookie } from "vinxi/http";
import { z } from "zod";
import { env } from "~/env/server";
import { env as clientEnv } from "~/env/client";
import {
fetchWithTimeout,
@@ -84,7 +77,9 @@ const sendContactEmail = action(async (formData: FormData) => {
);
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<string>(
searchParams.error ? decodeURIComponent(searchParams.error) : ""
searchParams.error ? decodeURIComponent(String(searchParams.error)) : ""
);
const [loading, setLoading] = createSignal<boolean>(false);
const [user, setUser] = createSignal<UserProfile | null>(null);
const [jsEnabled, setJsEnabled] = createSignal<boolean>(false);
const [turnstileToken, setTurnstileToken] = createSignal<string>("");
const [turnstileWidgetId, setTurnstileWidgetId] = createSignal<string | null>(
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 */}
<div class="mb-4 flex justify-center">
<div
class="turnstile-widget"
data-sitekey={clientEnv.VITE_TURNSTILE_SITE_KEY}
data-theme="dark"
id="turnstile-widget-1"
></div>
</div>
<div class="flex w-full flex-col justify-evenly">
<div class="mx-auto w-full justify-evenly md:flex md:flex-row">
<Input
@@ -464,7 +462,8 @@ export default function ContactPage() {
<label class="underlinedInputLabel">Message</label>
</div>
</div>
<div class="mx-auto flex w-full justify-end pt-4">
<div class="mx-auto flex w-full justify-between pt-4">
<div id="turnstile-widget-1"></div>
<Show
when={
remainingTime() > 0 ||

View File

@@ -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({

View File

@@ -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-");

View File

@@ -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<typeof jwtVerify>[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;

View File

@@ -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,

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
const sendinblueData = {
sender: {
name: "freno.me",
email: "michael@freno.me"
},
to: [{ email: "michael@freno.me" }],
htmlContent: `<html><head></head><body><div>Request Name: ${input.name}</div><div>Request Email: ${input.email}</div><div>Request Message: ${input.message}</div></body></html>`,
htmlContent: `<html><head></head><body><div>Request Name: ${escapeHtml(input.name)}</div><div>Request Email: ${escapeHtml(input.email)}</div><div>Request Message: ${escapeHtml(input.message)}</div></body></html>`,
subject: "freno.me Contact Request"
};

View File

@@ -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<typeof jwtVerify>[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";

View File

@@ -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;
});
});

View File

@@ -252,17 +252,34 @@ async function cleanupExpiredRateLimits(): Promise<void> {
}
/**
* 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";