diff --git a/apps/web/src/convex/http.ts b/apps/web/src/convex/http.ts index c5dd8d73..79e56085 100644 --- a/apps/web/src/convex/http.ts +++ b/apps/web/src/convex/http.ts @@ -8,6 +8,7 @@ import type { Id } from './_generated/dataModel.js'; import { httpAction, type ActionCtx } from './_generated/server.js'; import { AnalyticsEvents } from './analyticsEvents.js'; import { instances } from './apiHelpers.js'; +import { assertSafeServerUrl } from './urlSafety.js'; const usageActions = api.usage; const instanceActions = instances.actions; @@ -19,6 +20,7 @@ const http = httpRouter(); const corsAllowedMethods = 'GET, POST, OPTIONS'; const corsMaxAgeSeconds = 60 * 60 * 24; const defaultAllowedHeaders = 'Content-Type, Authorization, X-Requested-With'; +const svixMaxSkewSeconds = 5 * 60; const buildAllowedOrigins = (): Set => { const origins = (process.env.CLIENT_ORIGIN ?? '') @@ -338,7 +340,7 @@ const chatStream = httpAction(async (ctx, request) => { const serverUrl = await ensureServerUrl(ctx, instance, sendEvent); - const response = await fetch(`${serverUrl}/question/stream`, { + const response = await fetch(new URL('/question/stream', serverUrl), { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -563,7 +565,7 @@ const chatStream = httpAction(async (ctx, request) => { const response = new Response(stream, { headers: { 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', + 'Cache-Control': 'no-store', Connection: 'keep-alive' } }); @@ -590,6 +592,16 @@ const clerkWebhook = httpAction(async (ctx, request) => { return withCors(request, response); } + if (!isSvixTimestampFresh(headers['svix-timestamp'])) { + await ctx.scheduler.runAfter(0, internal.analytics.trackEvent, { + distinctId: 'webhook_system', + event: AnalyticsEvents.WEBHOOK_VERIFICATION_FAILED, + properties: { webhookType: 'clerk', reason: 'stale_timestamp' } + }); + const response = jsonResponse({ error: 'Stale webhook timestamp' }, { status: 400 }); + return withCors(request, response); + } + const verifiedPayload = await verifySvixSignature(payload, headers, secret); if (!verifiedPayload) { await ctx.scheduler.runAfter(0, internal.analytics.trackEvent, { @@ -667,6 +679,15 @@ const daytonaWebhook = httpAction(async (ctx, request) => { return jsonResponse({ error: 'Missing Svix headers' }, { status: 400 }); } + if (!isSvixTimestampFresh(headers['svix-timestamp'])) { + await ctx.scheduler.runAfter(0, internal.analytics.trackEvent, { + distinctId: 'webhook_system', + event: AnalyticsEvents.WEBHOOK_VERIFICATION_FAILED, + properties: { webhookType: 'daytona', reason: 'stale_timestamp' } + }); + return jsonResponse({ error: 'Stale webhook timestamp' }, { status: 400 }); + } + const verifiedPayload = await verifySvixSignature(payload, headers, secret); if (!verifiedPayload) { await ctx.scheduler.runAfter(0, internal.analytics.trackEvent, { @@ -720,6 +741,30 @@ function getSvixHeaders(request: Request): SvixHeaders | null { }; } +const parseSvixTimestampSeconds = (raw: string) => { + const ts = Number(raw); + if (!Number.isFinite(ts)) return null; + return Math.floor(ts > 1e12 ? ts / 1000 : ts); +}; + +const isSvixTimestampFresh = (raw: string, maxSkewSeconds = svixMaxSkewSeconds) => { + const ts = parseSvixTimestampSeconds(raw); + if (!ts) return false; + const now = Math.floor(Date.now() / 1000); + return Math.abs(now - ts) <= maxSkewSeconds; +}; + +const timingSafeEqual = (a: string, b: string) => { + const len = Math.max(a.length, b.length); + let result = 0; + for (let i = 0; i < len; i++) { + const ca = a.charCodeAt(i) || 0; + const cb = b.charCodeAt(i) || 0; + result |= ca ^ cb; + } + return result === 0 && a.length === b.length; +}; + async function verifySvixSignature( payload: string, headers: SvixHeaders, @@ -760,8 +805,8 @@ async function verifySvixSignature( .filter((value): value is string => Boolean(value)); const normalizedSignature = signatureBase64.replace(/=+$/, ''); - const matches = candidates.some( - (candidate) => candidate.replace(/=+$/, '') === normalizedSignature + const matches = candidates.some((candidate) => + timingSafeEqual(candidate.replace(/=+$/, ''), normalizedSignature) ); if (!matches) { return null; @@ -854,7 +899,7 @@ async function ensureServerUrl( if (instance.state === 'running' && instance.serverUrl) { sendEvent({ type: 'status', status: 'ready' }); - return instance.serverUrl; + return assertSafeServerUrl(instance.serverUrl); } if (!instance.sandboxId) { @@ -872,5 +917,5 @@ async function ensureServerUrl( } sendEvent({ type: 'status', status: 'ready' }); - return serverUrl; + return assertSafeServerUrl(serverUrl); } diff --git a/apps/web/src/convex/instances/actions.ts b/apps/web/src/convex/instances/actions.ts index 804b4c57..6005aceb 100644 --- a/apps/web/src/convex/instances/actions.ts +++ b/apps/web/src/convex/instances/actions.ts @@ -9,6 +9,7 @@ import type { Doc, Id } from '../_generated/dataModel'; import { action, internalAction, type ActionCtx } from '../_generated/server'; import { AnalyticsEvents } from '../analyticsEvents'; import { instances } from '../apiHelpers'; +import { assertSafeServerUrl } from '../urlSafety'; const instanceQueries = instances.queries; const instanceMutations = instances.mutations; @@ -920,10 +921,13 @@ export const syncResources = internalAction({ await uploadBtcaConfig(sandbox, resources); // Tell the btca server to reload its config - const reloadResponse = await fetch(`${instance.serverUrl}/reload-config`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }); + const reloadResponse = await fetch( + new URL('/reload-config', assertSafeServerUrl(instance.serverUrl)), + { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + } + ); if (!reloadResponse.ok) { console.error('Failed to reload config:', await reloadResponse.text()); diff --git a/apps/web/src/convex/mcp.ts b/apps/web/src/convex/mcp.ts index 45a70d28..700f78e1 100644 --- a/apps/web/src/convex/mcp.ts +++ b/apps/web/src/convex/mcp.ts @@ -8,6 +8,7 @@ import { action } from './_generated/server'; import { AnalyticsEvents } from './analyticsEvents'; import { instances } from './apiHelpers'; import type { ApiKeyValidationResult } from './clerkApiKeys'; +import { assertSafeServerUrl } from './urlSafety'; const instanceActions = instances.actions; const instanceMutations = instances.mutations; @@ -239,7 +240,8 @@ export const ask = action({ } const startedAt = Date.now(); - const response = await fetch(`${serverUrl}/question`, { + const safeServerUrl = assertSafeServerUrl(serverUrl); + const response = await fetch(new URL('/question', safeServerUrl), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/apps/web/src/convex/urlSafety.ts b/apps/web/src/convex/urlSafety.ts new file mode 100644 index 00000000..a8329b5a --- /dev/null +++ b/apps/web/src/convex/urlSafety.ts @@ -0,0 +1,65 @@ +const isIpv4 = (host: string) => /^\d{1,3}(\.\d{1,3}){3}$/.test(host); + +const parseIpv4 = (host: string) => { + const parts = host.split('.').map((p) => Number.parseInt(p, 10)); + if (parts.length !== 4 || parts.some((p) => !Number.isFinite(p) || p < 0 || p > 255)) return null; + return parts as [number, number, number, number]; +}; + +const isPrivateIpv4 = (host: string) => { + const ip = parseIpv4(host); + if (!ip) return false; + const [a, b] = ip; + + if (a === 0) return true; + if (a === 10) return true; + if (a === 127) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + + return false; +}; + +const isPrivateIpv6 = (host: string) => { + const normalized = host.toLowerCase().split('%')[0] ?? ''; + if (!normalized) return false; + if (normalized === '::1') return true; // loopback + if (normalized.startsWith('fe80:')) return true; // link-local + if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true; // unique local (fc00::/7) + return false; +}; + +const isPrivateHostname = (host: string) => { + const hostname = host.toLowerCase(); + if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true; + if (hostname.endsWith('.local')) return true; + if (isIpv4(hostname) && isPrivateIpv4(hostname)) return true; + if (hostname.includes(':') && isPrivateIpv6(hostname)) return true; + return false; +}; + +export const assertSafeServerUrl = (raw: string) => { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new Error('Invalid server URL'); + } + + if (url.username || url.password) { + throw new Error('Server URL must not include credentials'); + } + + const protocol = url.protocol; + const isProd = (process.env.NODE_ENV ?? 'production') === 'production'; + if (protocol !== 'https:' && !(protocol === 'http:' && !isProd)) { + throw new Error('Insecure server URL protocol'); + } + + if (isPrivateHostname(url.hostname)) { + throw new Error('Unsafe server URL hostname'); + } + + return url.origin; +}; diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts new file mode 100644 index 00000000..12d92c29 --- /dev/null +++ b/apps/web/src/hooks.server.ts @@ -0,0 +1,37 @@ +import type { Handle } from '@sveltejs/kit'; + +const appendVary = (headers: Headers, value: string) => { + const existing = headers.get('Vary'); + if (!existing) { + headers.set('Vary', value); + return; + } + + const values = existing + .split(',') + .map((v) => v.trim()) + .filter(Boolean); + + if (!values.includes(value)) { + headers.set('Vary', [...values, value].join(', ')); + } +}; + +export const handle: Handle = async ({ event, resolve }) => { + const response = await resolve(event); + const headers = response.headers; + + // Minimal, low-risk security headers. Avoid CSP here since the app embeds third-party scripts (Clerk, PostHog). + headers.set('X-Content-Type-Options', 'nosniff'); + headers.set('X-Frame-Options', 'DENY'); + headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); + headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + + // API responses should never be cached, especially since they depend on Authorization. + if (event.url.pathname.startsWith('/api/')) { + headers.set('Cache-Control', 'no-store'); + appendVary(headers, 'Authorization'); + } + + return response; +}; diff --git a/apps/web/src/lib/components/ChatMessages.svelte b/apps/web/src/lib/components/ChatMessages.svelte index ab9fe7d3..5c052b22 100644 --- a/apps/web/src/lib/components/ChatMessages.svelte +++ b/apps/web/src/lib/components/ChatMessages.svelte @@ -104,6 +104,8 @@ let scrollContainer = $state(null); let isAtBottom = $state(true); + let markdownClickRoot = $state(null); + function handleScroll() { if (!scrollContainer) return; const { scrollTop, scrollHeight, clientHeight } = scrollContainer; @@ -175,26 +177,37 @@ return langMap[lang] ?? 'text'; } + const escapeHtml = (value: string) => + value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + async function renderMarkdownWithShiki(text: string): Promise { const content = stripHistory(text); const highlighter = await shikiHighlighter; const renderer = new marked.Renderer(); + // Disallow raw HTML passthrough from markdown. + renderer.html = ({ text }: { text: string }) => escapeHtml(text); renderer.code = ({ text, lang }: { text: string; lang?: string }) => { const normalized = normalizeCodeLang(lang); const codeId = nanoid(8); + const safeLangLabel = escapeHtml((lang || 'text').trim() || 'text'); const highlighted = highlighter.codeToHtml(text, { lang: normalized, themes: { light: 'light-plus', dark: 'dark-plus' }, defaultColor: false }); - return `
${lang || 'text'}
${highlighted}
`; + return `
${safeLangLabel}
${highlighted}
`; }; const html = (await marked.parse(content, { async: true, renderer })) as string; return DOMPurify.sanitize(html, { ADD_TAGS: ['pre', 'code'], - ADD_ATTR: ['data-code-id', 'data-copy-target', 'onclick', 'class', 'id', 'style'] + ADD_ATTR: ['data-code-id', 'data-copy-target', 'class', 'id', 'style', 'type'] }); } @@ -213,28 +226,45 @@ }); } - const html = marked.parse(content, { async: false }) as string; + const renderer = new marked.Renderer(); + renderer.html = ({ text }: { text: string }) => escapeHtml(text); + + const html = marked.parse(content, { async: false, renderer }) as string; return DOMPurify.sanitize(html, { ADD_TAGS: ['pre', 'code'], ADD_ATTR: ['class'] }); } - // Global copy function - if (typeof window !== 'undefined') { - (window as unknown as { copyCode: (id: string) => void }).copyCode = async (id: string) => { - const rawEl = document.getElementById(`code-raw-${id}`); - if (rawEl) { - const text = rawEl.textContent ?? ''; - await navigator.clipboard.writeText(text); - copiedId = id; - setTimeout(() => { - copiedId = null; - }, 2000); - } - }; + async function handleMarkdownClick(event: MouseEvent) { + const target = event.target as Element | null; + const button = target?.closest?.('button.copy-btn') as HTMLButtonElement | null; + if (!button) return; + + const copyTarget = button.dataset.copyTarget; + if (!copyTarget) return; + + const rawEl = document.getElementById(`code-raw-${copyTarget}`); + const text = rawEl?.textContent ?? ''; + if (!text) return; + + try { + await navigator.clipboard.writeText(text); + } catch { + // ignore + } } + $effect(() => { + const root = markdownClickRoot; + if (!root) return; + const handler = (event: Event) => { + void handleMarkdownClick(event as MouseEvent); + }; + root.addEventListener('click', handler); + return () => root.removeEventListener('click', handler); + }); + async function copyFullAnswer(messageId: string, chunks: BtcaChunk[]) { const text = chunks .filter((c): c is BtcaChunk & { type: 'text' } => c.type === 'text') @@ -301,7 +331,7 @@ onscroll={handleScroll} class="absolute inset-0 overflow-y-auto bc-chatPattern" > -
+
{#each messages as message, index (message.id)} {#if message.role === 'user'}
diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index 906afb81..07960588 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -108,7 +108,12 @@