security cleanup, fix turnstile
This commit is contained in:
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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
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";
|
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)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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-");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
|
||||||
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"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
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 {
|
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";
|
||||||
|
|||||||
Reference in New Issue
Block a user