security cleanup, fix turnstile
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
24
src/env/server.ts
vendored
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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-");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
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"
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
19
src/server/middleare/security-headers.ts
Normal file
19
src/server/middleare/security-headers.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user