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: { build: {
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks: (id) => { manualChunks: (id: string) => {
// Bundle highlight.js and lowlight together // Bundle highlight.js and lowlight together
if (id.includes("highlight.js") || id.includes("lowlight")) { if (id.includes("highlight.js") || id.includes("lowlight")) {
return "highlight"; return "highlight";

View File

@@ -1,5 +1,42 @@
import { onMount } from "solid-js"; 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() { export default function MermaidRenderer() {
onMount(async () => { onMount(async () => {
const mermaidPres = document.querySelectorAll('pre[data-type="mermaid"]'); const mermaidPres = document.querySelectorAll('pre[data-type="mermaid"]');
@@ -12,7 +49,7 @@ export default function MermaidRenderer() {
mermaid.initialize({ mermaid.initialize({
startOnLoad: false, startOnLoad: false,
theme: "dark", theme: "dark",
securityLevel: "loose", securityLevel: "strict",
fontFamily: "monospace", fontFamily: "monospace",
themeVariables: { themeVariables: {
darkMode: true, darkMode: true,
@@ -38,7 +75,7 @@ export default function MermaidRenderer() {
const wrapper = document.createElement("div"); const wrapper = document.createElement("div");
wrapper.className = "mermaid-rendered"; wrapper.className = "mermaid-rendered";
wrapper.innerHTML = svg; wrapper.innerHTML = sanitizeMermaidSvg(svg);
pre.replaceWith(wrapper); pre.replaceWith(wrapper);
} catch (err) { } catch (err) {
console.error("Failed to render mermaid diagram:", 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")); 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 { export interface PostBodyClientProps {
body: string; body: string;
hasCodeBlock: boolean; hasCodeBlock: boolean;
@@ -21,10 +61,10 @@ export default function PostBodyClient(props: PostBodyClientProps) {
const processCodeBlocks = () => { const processCodeBlocks = () => {
if (!contentRef) return; if (!contentRef) return;
const codeBlocks = contentRef.querySelectorAll("pre code"); const codeBlocks = contentRef.querySelectorAll<HTMLElement>("pre code");
codeBlocks.forEach((codeBlock) => { codeBlocks.forEach((codeBlock) => {
const pre = codeBlock.parentElement; const pre = codeBlock.parentElement as HTMLPreElement | null;
if (!pre) return; if (!pre) return;
if (pre.dataset.type === "mermaid") return; if (pre.dataset.type === "mermaid") return;
@@ -228,7 +268,7 @@ export default function PostBodyClient(props: PostBodyClientProps) {
const referencesHeadingText = const referencesHeadingText =
marker?.getAttribute("data-heading") || "References"; marker?.getAttribute("data-heading") || "References";
const headings = contentRef.querySelectorAll("h2"); const headings = contentRef.querySelectorAll<HTMLElement>("h2");
let referencesSection: HTMLElement | null = null; let referencesSection: HTMLElement | null = null;
headings.forEach((heading) => { headings.forEach((heading) => {
@@ -401,7 +441,7 @@ export default function PostBodyClient(props: PostBodyClientProps) {
id="post-content-body" id="post-content-body"
ref={contentRef} ref={contentRef}
class="text-text prose dark:prose-invert max-w-none" class="text-text prose dark:prose-invert max-w-none"
innerHTML={props.body} innerHTML={sanitizeHtml(props.body)}
/> />
<Show when={props.hasMermaid}> <Show when={props.hasMermaid}>
<MermaidRenderer /> <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> { export interface InputProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
label?: string; label?: string;
error?: string; error?: string;
helperText?: string; helperText?: string;
ref?: HTMLInputElement | ((el: HTMLInputElement) => void); ref?: HTMLInputElement | ((el: HTMLInputElement) => void);
containerClass?: string;
} }
export default function Input(props: InputProps) { export default function Input(props: InputProps) {
@@ -12,11 +13,16 @@ export default function Input(props: InputProps) {
"label", "label",
"error", "error",
"helperText", "helperText",
"ref" "ref",
"containerClass"
]); ]);
const containerClasses = ["input-group", local.containerClass]
.filter(Boolean)
.join(" ");
return ( return (
<div class="input-group"> <div class={containerClasses}>
<input <input
{...others} {...others}
ref={local.ref} 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"; import { z } from "zod";
const serverEnvSchema = z.object({ const serverEnvSchema = z.object({
@@ -34,6 +57,7 @@ const serverEnvSchema = z.object({
NESSA_DB_URL: z.string().min(1), NESSA_DB_URL: z.string().min(1),
NESSA_DB_TOKEN: z.string().min(1), NESSA_DB_TOKEN: z.string().min(1),
NESSA_JWT_SECRET: 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), VITE_TURNSTILE_SITE_KEY: z.string().min(1),
TURNSTILE_SECRET_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 { createSignal, onMount, createEffect, Show } from "solid-js";
import { import { useSearchParams, query, createAsync } from "@solidjs/router";
useSearchParams,
useNavigate,
useLocation,
query,
createAsync
} from "@solidjs/router";
import { A } from "@solidjs/router"; import { A } from "@solidjs/router";
import { action, redirect } from "@solidjs/router"; import { action, redirect } from "@solidjs/router";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { api } from "~/lib/api"; import { api } from "~/lib/api";
import { getClientCookie, setClientCookie } from "~/lib/cookies.client"; import { getClientCookie } from "~/lib/cookies.client";
import CountdownCircleTimer from "~/components/CountdownCircleTimer"; import CountdownCircleTimer from "~/components/CountdownCircleTimer";
import RevealDropDown from "~/components/RevealDropDown"; import RevealDropDown from "~/components/RevealDropDown";
import Input from "~/components/ui/Input"; import Input from "~/components/ui/Input";
@@ -19,7 +13,6 @@ import { useCountdown } from "~/lib/useCountdown";
import type { UserProfile } from "~/types/user"; import type { UserProfile } from "~/types/user";
import { getCookie, setCookie } from "vinxi/http"; import { getCookie, setCookie } from "vinxi/http";
import { z } from "zod"; import { z } from "zod";
import { env } from "~/env/server";
import { env as clientEnv } from "~/env/client"; import { env as clientEnv } from "~/env/client";
import { import {
fetchWithTimeout, fetchWithTimeout,
@@ -84,7 +77,9 @@ const sendContactEmail = action(async (formData: FormData) => {
); );
if (!turnstileValid) { 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"); const contactExp = getCookie("contactRequestSent");
@@ -162,8 +157,6 @@ const sendContactEmail = action(async (formData: FormData) => {
export default function ContactPage() { export default function ContactPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const location = useLocation();
const navigate = useNavigate();
const viewer = () => searchParams.viewer ?? "default"; const viewer = () => searchParams.viewer ?? "default";
// Load server data using createAsync // Load server data using createAsync
@@ -175,36 +168,45 @@ export default function ContactPage() {
searchParams.success === "true" searchParams.success === "true"
); );
const [error, setError] = createSignal<string>( const [error, setError] = createSignal<string>(
searchParams.error ? decodeURIComponent(searchParams.error) : "" searchParams.error ? decodeURIComponent(String(searchParams.error)) : ""
); );
const [loading, setLoading] = createSignal<boolean>(false); const [loading, setLoading] = createSignal<boolean>(false);
const [user, setUser] = createSignal<UserProfile | null>(null); const [user, setUser] = createSignal<UserProfile | null>(null);
const [jsEnabled, setJsEnabled] = createSignal<boolean>(false); const [jsEnabled, setJsEnabled] = createSignal<boolean>(false);
const [turnstileToken, setTurnstileToken] = createSignal<string>(""); const [turnstileToken, setTurnstileToken] = createSignal<string>("");
const [turnstileWidgetId, setTurnstileWidgetId] = createSignal<string | null>(
null
);
const { remainingTime, startCountdown, setRemainingTime } = useCountdown(); const { remainingTime, startCountdown, setRemainingTime } = useCountdown();
onMount(() => { onMount(() => {
setJsEnabled(true); setJsEnabled(true);
// Load Cloudflare Turnstile script // Load Cloudflare Turnstile script with explicit rendering
const script = document.createElement("script"); const script = document.createElement("script");
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js"; script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
script.async = true; script.async = true;
script.defer = true; script.defer = true;
document.head.appendChild(script); script.onload = () => {
// Get Turnstile token after widget renders
setTimeout(() => {
if (typeof window !== "undefined" && (window as any).turnstile) { if (typeof window !== "undefined" && (window as any).turnstile) {
const widgetEl = document.getElementById("turnstile-widget-1"); const container = document.getElementById("turnstile-widget-1");
if (widgetEl) { if (container) {
const widgetId = widgetEl.getAttribute("data-widget-id"); const id = (window as any).turnstile.render(container, {
const token = (window as any).turnstile.getResponse(widgetId || ""); sitekey: clientEnv.VITE_TURNSTILE_SITE_KEY,
if (token) setTurnstileToken(token); theme: "dark",
callback: (token: string) => {
setTurnstileToken(token);
},
"expired-callback": () => {
setTurnstileToken("");
}
});
setTurnstileWidgetId(id);
} }
} }
}, 1000); };
document.head.appendChild(script);
api.user.getProfile api.user.getProfile
.query() .query()
@@ -252,10 +254,15 @@ export default function ContactPage() {
if (name && email && message) { if (name && email && message) {
// Get fresh Turnstile token // Get fresh Turnstile token
let currentToken = turnstileToken(); let currentToken = turnstileToken();
if (!currentToken && typeof window !== "undefined" && (window as any).turnstile) { if (
const widgetEl = document.querySelector(".turnstile-widget"); !currentToken &&
typeof window !== "undefined" &&
(window as any).turnstile
) {
const widgetEl = document.getElementById("turnstile-widget-1");
if (widgetEl) { 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) { if (typeof window !== "undefined" && (window as any).turnstile) {
const widgetEl = document.getElementById("turnstile-widget-1"); const widgetEl = document.getElementById("turnstile-widget-1");
if (widgetEl) { if (widgetEl) {
(window as any).turnstile.reset(widgetEl.getAttribute("data-widget-id") || ""); const id = turnstileWidgetId();
(window as any).turnstile.reset(id || widgetEl);
} }
} }
setTurnstileToken(""); setTurnstileToken("");
@@ -416,16 +424,6 @@ export default function ContactPage() {
action={sendContactEmail} action={sendContactEmail}
class="w-full" 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="flex w-full flex-col justify-evenly">
<div class="mx-auto w-full justify-evenly md:flex md:flex-row"> <div class="mx-auto w-full justify-evenly md:flex md:flex-row">
<Input <Input
@@ -464,7 +462,8 @@ export default function ContactPage() {
<label class="underlinedInputLabel">Message</label> <label class="underlinedInputLabel">Message</label>
</div> </div>
</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 <Show
when={ when={
remainingTime() > 0 || remainingTime() > 0 ||

View File

@@ -1173,10 +1173,10 @@ export const authRouter = createTRPCRouter({
}); });
} }
// Generate 6-digit code // Generate cryptographically secure 6-digit code (p8-010)
const loginCode = Math.floor( const randomBytes = new Uint32Array(1);
100000 + Math.random() * 900000 crypto.getRandomValues(randomBytes);
).toString(); const loginCode = (100000 + (randomBytes[0] % 900000)).toString();
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const token = await new SignJWT({ const token = await new SignJWT({

View File

@@ -372,7 +372,7 @@ export const databaseRouter = createTRPCRouter({
body: z.string().nullable(), body: z.string().nullable(),
banner_photo: z.string().nullable(), banner_photo: z.string().nullable(),
published: z.boolean(), published: z.boolean(),
tags: z.array(z.string()).nullable(), tags: z.array(z.string().max(50).trim()).nullable(),
author_id: z.string() author_id: z.string()
}) })
) )
@@ -405,12 +405,13 @@ export const databaseRouter = createTRPCRouter({
const results = await conn.execute({ sql: query, args: params }); const results = await conn.execute({ sql: query, args: params });
if (input.tags && input.tags.length > 0) { if (input.tags && input.tags.length > 0) {
let tagQuery = "INSERT INTO Tag (value, post_id) VALUES "; const validTags = input.tags.filter((t) => t.length > 0);
let values = input.tags.map( for (const tag of validTags) {
(tag) => `("${tag}", ${results.lastInsertRowid})` await conn.execute({
); sql: "INSERT INTO Tag (value, post_id) VALUES (?, ?)",
tagQuery += values.join(", "); args: [tag, results.lastInsertRowid]
await conn.execute(tagQuery); });
}
} }
await cache.deleteByPrefix("blog-"); await cache.deleteByPrefix("blog-");
@@ -434,7 +435,7 @@ export const databaseRouter = createTRPCRouter({
body: z.string().nullable().optional(), body: z.string().nullable().optional(),
banner_photo: z.string().nullable().optional(), banner_photo: z.string().nullable().optional(),
published: z.boolean().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() author_id: z.string()
}) })
) )
@@ -523,10 +524,13 @@ export const databaseRouter = createTRPCRouter({
}); });
if (input.tags && input.tags.length > 0) { if (input.tags && input.tags.length > 0) {
let tagQuery = "INSERT INTO Tag (value, post_id) VALUES "; const validTags = input.tags.filter((t) => t.length > 0);
let values = input.tags.map((tag) => `("${tag}", ${input.id})`); for (const tag of validTags) {
tagQuery += values.join(", "); await conn.execute({
await conn.execute(tagQuery); sql: "INSERT INTO Tag (value, post_id) VALUES (?, ?)",
args: [tag, input.id]
});
}
} }
await cache.deleteByPrefix("blog-"); await cache.deleteByPrefix("blog-");

View File

@@ -10,7 +10,7 @@ import {
} from "~/server/utils"; } from "~/server/utils";
import { env } from "~/env/server"; import { env } from "~/env/server";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { SignJWT, jwtVerify } from "jose"; import { SignJWT, jwtVerify, importJWK } from "jose";
import { LibsqlError } from "@libsql/client/web"; import { LibsqlError } from "@libsql/client/web";
import { createClient as createAPIClient } from "@tursodatabase/api"; import { createClient as createAPIClient } from "@tursodatabase/api";
@@ -354,11 +354,68 @@ export const lineageAuthRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
email: z.string().email().optional(), email: z.string().email().optional(),
userString: z.string(), idToken: z.string(),
}) })
) )
.mutation(async ({ input }) => { .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 dbName;
let dbToken; let dbToken;

View File

@@ -6,8 +6,6 @@ import {
} from "~/server/utils"; } from "~/server/utils";
import { env } from "~/env/server"; import { env } from "~/env/server";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { OAuth2Client } from "google-auth-library";
import { jwtVerify } from "jose";
import { createTRPCRouter, publicProcedure } from "~/server/api/utils"; import { createTRPCRouter, publicProcedure } from "~/server/api/utils";
import { import {
fetchWithTimeout, fetchWithTimeout,
@@ -18,84 +16,8 @@ import {
} from "~/server/fetch-utils"; } from "~/server/fetch-utils";
export const lineageDatabaseRouter = createTRPCRouter({ export const lineageDatabaseRouter = createTRPCRouter({
credentials: publicProcedure // credentials endpoint removed (p8-008): was exposing persistent DB tokens to clients.
.input( // Database access should be proxied through tRPC server-side procedures.
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"
});
}
}),
deletionInit: publicProcedure deletionInit: publicProcedure
.input( .input(
@@ -155,6 +77,14 @@ export const lineageDatabaseRouter = createTRPCRouter({
if (skip_cron) { if (skip_cron) {
if (send_dump_target) { 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({ const dumpRes = await dumpAndSendDB({
dbName: db_name, dbName: db_name,
dbToken: db_token, dbToken: db_token,

View File

@@ -189,7 +189,7 @@ export const miscRouter = createTRPCRouter({
z.object({ z.object({
key: z.string(), key: z.string(),
newAttachmentString: z.string(), newAttachmentString: z.string(),
type: z.string(), type: z.enum(["Post", "Comment", "User"]),
id: z.number() id: z.number()
}) })
) )
@@ -214,6 +214,7 @@ export const miscRouter = createTRPCRouter({
const res = await client.send(command); const res = await client.send(command);
const conn = ConnectionFactory(); 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 = ?`; const query = `UPDATE ${input.type} SET attachments = ? WHERE id = ?`;
await conn.execute({ await conn.execute({
sql: query, sql: query,
@@ -344,13 +345,22 @@ export const miscRouter = createTRPCRouter({
const apiKey = env.SENDINBLUE_KEY; const apiKey = env.SENDINBLUE_KEY;
const apiUrl = "https://api.sendinblue.com/v3/smtp/email"; 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 = { const sendinblueData = {
sender: { sender: {
name: "freno.me", name: "freno.me",
email: "michael@freno.me" email: "michael@freno.me"
}, },
to: [{ 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" subject: "freno.me Contact Request"
}; };

View File

@@ -1,6 +1,7 @@
import { createTRPCRouter, nessaProcedure, publicProcedure } from "../utils"; import { createTRPCRouter, nessaProcedure, publicProcedure } from "../utils";
import { z } from "zod"; import { z } from "zod";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { jwtVerify, importJWK } from "jose";
import { NessaConnectionFactory } from "~/server/database"; import { NessaConnectionFactory } from "~/server/database";
import { cache } from "~/server/cache"; import { cache } from "~/server/cache";
import { hashPassword, checkPasswordSafe } from "~/server/utils"; import { hashPassword, checkPasswordSafe } from "~/server/utils";
@@ -628,45 +629,30 @@ export const nessaDbRouter = createTRPCRouter({
const header = JSON.parse(headerJson) as { kid: string; alg: string }; const header = JSON.parse(headerJson) as { kid: string; alg: string };
// Find the matching key // Find the matching key
const key = appleKeys.keys.find((k) => k.kid === header.kid); const jwk = appleKeys.keys.find((k) => k.kid === header.kid);
if (!key) { if (!jwk) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Apple public key not found" message: "Apple public key not found"
}); });
} }
// For simplicity, we'll decode the payload and verify basic claims // Import the Apple JWK key for signature verification
// In production, you should use a proper JWT library like jose to verify the signature const publicKey = await importJWK(jwk, "RS256");
const [, payloadB64] = input.idToken.split(".");
if (!payloadB64) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid Apple ID token format"
});
}
const payloadJson = Buffer.from(payloadB64, "base64url").toString( // Verify the Apple ID token signature and claims using jose
"utf8" 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 // Apple user ID from token should match the one provided
if (tokenPayload.sub !== input.appleUserId) { 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 // 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 email = tokenPayload.email ?? input.email;
const firstName = input.firstName ?? "Apple"; 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 { export function getClientIP(event: H3Event): string {
const forwarded = getHeaderValue(event, "x-forwarded-for"); // In production on Vercel, X-Forwarded-For is set by the edge network
if (forwarded) { // and cannot be spoofed by clients. In dev/test, ignore it.
return forwarded.split(",")[0].trim(); 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"); // Fallback: try socket remote address
if (realIP) { try {
return realIP; 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"; return "unknown";