Skip to content

Commit f1c78d0

Browse files
committed
audit
1 parent 6152bcb commit f1c78d0

File tree

18 files changed

+250
-46
lines changed

18 files changed

+250
-46
lines changed

apps/web/src/convex/http.ts

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { Id } from './_generated/dataModel.js';
88
import { httpAction, type ActionCtx } from './_generated/server.js';
99
import { AnalyticsEvents } from './analyticsEvents.js';
1010
import { instances } from './apiHelpers.js';
11+
import { assertSafeServerUrl } from './urlSafety.js';
1112

1213
const usageActions = api.usage;
1314
const instanceActions = instances.actions;
@@ -19,6 +20,7 @@ const http = httpRouter();
1920
const corsAllowedMethods = 'GET, POST, OPTIONS';
2021
const corsMaxAgeSeconds = 60 * 60 * 24;
2122
const defaultAllowedHeaders = 'Content-Type, Authorization, X-Requested-With';
23+
const svixMaxSkewSeconds = 5 * 60;
2224

2325
const buildAllowedOrigins = (): Set<string> => {
2426
const origins = (process.env.CLIENT_ORIGIN ?? '')
@@ -338,7 +340,7 @@ const chatStream = httpAction(async (ctx, request) => {
338340

339341
const serverUrl = await ensureServerUrl(ctx, instance, sendEvent);
340342

341-
const response = await fetch(`${serverUrl}/question/stream`, {
343+
const response = await fetch(new URL('/question/stream', serverUrl), {
342344
method: 'POST',
343345
headers: {
344346
'Content-Type': 'application/json'
@@ -563,7 +565,7 @@ const chatStream = httpAction(async (ctx, request) => {
563565
const response = new Response(stream, {
564566
headers: {
565567
'Content-Type': 'text/event-stream',
566-
'Cache-Control': 'no-cache',
568+
'Cache-Control': 'no-store',
567569
Connection: 'keep-alive'
568570
}
569571
});
@@ -590,6 +592,16 @@ const clerkWebhook = httpAction(async (ctx, request) => {
590592
return withCors(request, response);
591593
}
592594

595+
if (!isSvixTimestampFresh(headers['svix-timestamp'])) {
596+
await ctx.scheduler.runAfter(0, internal.analytics.trackEvent, {
597+
distinctId: 'webhook_system',
598+
event: AnalyticsEvents.WEBHOOK_VERIFICATION_FAILED,
599+
properties: { webhookType: 'clerk', reason: 'stale_timestamp' }
600+
});
601+
const response = jsonResponse({ error: 'Stale webhook timestamp' }, { status: 400 });
602+
return withCors(request, response);
603+
}
604+
593605
const verifiedPayload = await verifySvixSignature(payload, headers, secret);
594606
if (!verifiedPayload) {
595607
await ctx.scheduler.runAfter(0, internal.analytics.trackEvent, {
@@ -667,6 +679,15 @@ const daytonaWebhook = httpAction(async (ctx, request) => {
667679
return jsonResponse({ error: 'Missing Svix headers' }, { status: 400 });
668680
}
669681

682+
if (!isSvixTimestampFresh(headers['svix-timestamp'])) {
683+
await ctx.scheduler.runAfter(0, internal.analytics.trackEvent, {
684+
distinctId: 'webhook_system',
685+
event: AnalyticsEvents.WEBHOOK_VERIFICATION_FAILED,
686+
properties: { webhookType: 'daytona', reason: 'stale_timestamp' }
687+
});
688+
return jsonResponse({ error: 'Stale webhook timestamp' }, { status: 400 });
689+
}
690+
670691
const verifiedPayload = await verifySvixSignature(payload, headers, secret);
671692
if (!verifiedPayload) {
672693
await ctx.scheduler.runAfter(0, internal.analytics.trackEvent, {
@@ -720,6 +741,30 @@ function getSvixHeaders(request: Request): SvixHeaders | null {
720741
};
721742
}
722743

744+
const parseSvixTimestampSeconds = (raw: string) => {
745+
const ts = Number(raw);
746+
if (!Number.isFinite(ts)) return null;
747+
return Math.floor(ts > 1e12 ? ts / 1000 : ts);
748+
};
749+
750+
const isSvixTimestampFresh = (raw: string, maxSkewSeconds = svixMaxSkewSeconds) => {
751+
const ts = parseSvixTimestampSeconds(raw);
752+
if (!ts) return false;
753+
const now = Math.floor(Date.now() / 1000);
754+
return Math.abs(now - ts) <= maxSkewSeconds;
755+
};
756+
757+
const timingSafeEqual = (a: string, b: string) => {
758+
const len = Math.max(a.length, b.length);
759+
let result = 0;
760+
for (let i = 0; i < len; i++) {
761+
const ca = a.charCodeAt(i) || 0;
762+
const cb = b.charCodeAt(i) || 0;
763+
result |= ca ^ cb;
764+
}
765+
return result === 0 && a.length === b.length;
766+
};
767+
723768
async function verifySvixSignature(
724769
payload: string,
725770
headers: SvixHeaders,
@@ -760,8 +805,8 @@ async function verifySvixSignature(
760805
.filter((value): value is string => Boolean(value));
761806

762807
const normalizedSignature = signatureBase64.replace(/=+$/, '');
763-
const matches = candidates.some(
764-
(candidate) => candidate.replace(/=+$/, '') === normalizedSignature
808+
const matches = candidates.some((candidate) =>
809+
timingSafeEqual(candidate.replace(/=+$/, ''), normalizedSignature)
765810
);
766811
if (!matches) {
767812
return null;
@@ -854,7 +899,7 @@ async function ensureServerUrl(
854899

855900
if (instance.state === 'running' && instance.serverUrl) {
856901
sendEvent({ type: 'status', status: 'ready' });
857-
return instance.serverUrl;
902+
return assertSafeServerUrl(instance.serverUrl);
858903
}
859904

860905
if (!instance.sandboxId) {
@@ -872,5 +917,5 @@ async function ensureServerUrl(
872917
}
873918

874919
sendEvent({ type: 'status', status: 'ready' });
875-
return serverUrl;
920+
return assertSafeServerUrl(serverUrl);
876921
}

apps/web/src/convex/instances/actions.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { Doc, Id } from '../_generated/dataModel';
99
import { action, internalAction, type ActionCtx } from '../_generated/server';
1010
import { AnalyticsEvents } from '../analyticsEvents';
1111
import { instances } from '../apiHelpers';
12+
import { assertSafeServerUrl } from '../urlSafety';
1213

1314
const instanceQueries = instances.queries;
1415
const instanceMutations = instances.mutations;
@@ -920,10 +921,13 @@ export const syncResources = internalAction({
920921
await uploadBtcaConfig(sandbox, resources);
921922

922923
// Tell the btca server to reload its config
923-
const reloadResponse = await fetch(`${instance.serverUrl}/reload-config`, {
924-
method: 'POST',
925-
headers: { 'Content-Type': 'application/json' }
926-
});
924+
const reloadResponse = await fetch(
925+
new URL('/reload-config', assertSafeServerUrl(instance.serverUrl)),
926+
{
927+
method: 'POST',
928+
headers: { 'Content-Type': 'application/json' }
929+
}
930+
);
927931

928932
if (!reloadResponse.ok) {
929933
console.error('Failed to reload config:', await reloadResponse.text());

apps/web/src/convex/mcp.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { action } from './_generated/server';
88
import { AnalyticsEvents } from './analyticsEvents';
99
import { instances } from './apiHelpers';
1010
import type { ApiKeyValidationResult } from './clerkApiKeys';
11+
import { assertSafeServerUrl } from './urlSafety';
1112

1213
const instanceActions = instances.actions;
1314
const instanceMutations = instances.mutations;
@@ -239,7 +240,8 @@ export const ask = action({
239240
}
240241

241242
const startedAt = Date.now();
242-
const response = await fetch(`${serverUrl}/question`, {
243+
const safeServerUrl = assertSafeServerUrl(serverUrl);
244+
const response = await fetch(new URL('/question', safeServerUrl), {
243245
method: 'POST',
244246
headers: { 'Content-Type': 'application/json' },
245247
body: JSON.stringify({

apps/web/src/convex/urlSafety.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
const isIpv4 = (host: string) => /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
2+
3+
const parseIpv4 = (host: string) => {
4+
const parts = host.split('.').map((p) => Number.parseInt(p, 10));
5+
if (parts.length !== 4 || parts.some((p) => !Number.isFinite(p) || p < 0 || p > 255)) return null;
6+
return parts as [number, number, number, number];
7+
};
8+
9+
const isPrivateIpv4 = (host: string) => {
10+
const ip = parseIpv4(host);
11+
if (!ip) return false;
12+
const [a, b] = ip;
13+
14+
if (a === 0) return true;
15+
if (a === 10) return true;
16+
if (a === 127) return true;
17+
if (a === 169 && b === 254) return true;
18+
if (a === 172 && b >= 16 && b <= 31) return true;
19+
if (a === 192 && b === 168) return true;
20+
21+
return false;
22+
};
23+
24+
const isPrivateIpv6 = (host: string) => {
25+
const normalized = host.toLowerCase().split('%')[0] ?? '';
26+
if (!normalized) return false;
27+
if (normalized === '::1') return true; // loopback
28+
if (normalized.startsWith('fe80:')) return true; // link-local
29+
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true; // unique local (fc00::/7)
30+
return false;
31+
};
32+
33+
const isPrivateHostname = (host: string) => {
34+
const hostname = host.toLowerCase();
35+
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true;
36+
if (hostname.endsWith('.local')) return true;
37+
if (isIpv4(hostname) && isPrivateIpv4(hostname)) return true;
38+
if (hostname.includes(':') && isPrivateIpv6(hostname)) return true;
39+
return false;
40+
};
41+
42+
export const assertSafeServerUrl = (raw: string) => {
43+
let url: URL;
44+
try {
45+
url = new URL(raw);
46+
} catch {
47+
throw new Error('Invalid server URL');
48+
}
49+
50+
if (url.username || url.password) {
51+
throw new Error('Server URL must not include credentials');
52+
}
53+
54+
const protocol = url.protocol;
55+
const isProd = (process.env.NODE_ENV ?? 'production') === 'production';
56+
if (protocol !== 'https:' && !(protocol === 'http:' && !isProd)) {
57+
throw new Error('Insecure server URL protocol');
58+
}
59+
60+
if (isPrivateHostname(url.hostname)) {
61+
throw new Error('Unsafe server URL hostname');
62+
}
63+
64+
return url.origin;
65+
};

apps/web/src/hooks.server.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { Handle } from '@sveltejs/kit';
2+
3+
const appendVary = (headers: Headers, value: string) => {
4+
const existing = headers.get('Vary');
5+
if (!existing) {
6+
headers.set('Vary', value);
7+
return;
8+
}
9+
10+
const values = existing
11+
.split(',')
12+
.map((v) => v.trim())
13+
.filter(Boolean);
14+
15+
if (!values.includes(value)) {
16+
headers.set('Vary', [...values, value].join(', '));
17+
}
18+
};
19+
20+
export const handle: Handle = async ({ event, resolve }) => {
21+
const response = await resolve(event);
22+
const headers = response.headers;
23+
24+
// Minimal, low-risk security headers. Avoid CSP here since the app embeds third-party scripts (Clerk, PostHog).
25+
headers.set('X-Content-Type-Options', 'nosniff');
26+
headers.set('X-Frame-Options', 'DENY');
27+
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
28+
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
29+
30+
// API responses should never be cached, especially since they depend on Authorization.
31+
if (event.url.pathname.startsWith('/api/')) {
32+
headers.set('Cache-Control', 'no-store');
33+
appendVary(headers, 'Authorization');
34+
}
35+
36+
return response;
37+
};

apps/web/src/lib/components/ChatMessages.svelte

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@
104104
let scrollContainer = $state<HTMLDivElement | null>(null);
105105
let isAtBottom = $state(true);
106106
107+
let markdownClickRoot = $state<HTMLDivElement | null>(null);
108+
107109
function handleScroll() {
108110
if (!scrollContainer) return;
109111
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
@@ -175,26 +177,37 @@
175177
return langMap[lang] ?? 'text';
176178
}
177179
180+
const escapeHtml = (value: string) =>
181+
value
182+
.replace(/&/g, '&amp;')
183+
.replace(/</g, '&lt;')
184+
.replace(/>/g, '&gt;')
185+
.replace(/"/g, '&quot;')
186+
.replace(/'/g, '&#39;');
187+
178188
async function renderMarkdownWithShiki(text: string): Promise<string> {
179189
const content = stripHistory(text);
180190
const highlighter = await shikiHighlighter;
181191
182192
const renderer = new marked.Renderer();
193+
// Disallow raw HTML passthrough from markdown.
194+
renderer.html = ({ text }: { text: string }) => escapeHtml(text);
183195
renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
184196
const normalized = normalizeCodeLang(lang);
185197
const codeId = nanoid(8);
198+
const safeLangLabel = escapeHtml((lang || 'text').trim() || 'text');
186199
const highlighted = highlighter.codeToHtml(text, {
187200
lang: normalized,
188201
themes: { light: 'light-plus', dark: 'dark-plus' },
189202
defaultColor: false
190203
});
191-
return `<div class="code-block-wrapper" data-code-id="${codeId}"><div class="code-block-header"><span class="code-lang">${lang || 'text'}</span><button class="copy-btn" data-copy-target="${codeId}" onclick="window.copyCode('${codeId}')"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Copy</button></div><div class="code-content" id="code-${codeId}">${highlighted}</div><pre style="display:none" id="code-raw-${codeId}">${text.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</pre></div>`;
204+
return `<div class="code-block-wrapper" data-code-id="${codeId}"><div class="code-block-header"><span class="code-lang">${safeLangLabel}</span><button type="button" class="copy-btn" data-copy-target="${codeId}">Copy</button></div><div class="code-content" id="code-${codeId}">${highlighted}</div><pre style="display:none" id="code-raw-${codeId}">${escapeHtml(text)}</pre></div>`;
192205
};
193206
194207
const html = (await marked.parse(content, { async: true, renderer })) as string;
195208
return DOMPurify.sanitize(html, {
196209
ADD_TAGS: ['pre', 'code'],
197-
ADD_ATTR: ['data-code-id', 'data-copy-target', 'onclick', 'class', 'id', 'style']
210+
ADD_ATTR: ['data-code-id', 'data-copy-target', 'class', 'id', 'style', 'type']
198211
});
199212
}
200213
@@ -213,28 +226,45 @@
213226
});
214227
}
215228
216-
const html = marked.parse(content, { async: false }) as string;
229+
const renderer = new marked.Renderer();
230+
renderer.html = ({ text }: { text: string }) => escapeHtml(text);
231+
232+
const html = marked.parse(content, { async: false, renderer }) as string;
217233
return DOMPurify.sanitize(html, {
218234
ADD_TAGS: ['pre', 'code'],
219235
ADD_ATTR: ['class']
220236
});
221237
}
222238
223-
// Global copy function
224-
if (typeof window !== 'undefined') {
225-
(window as unknown as { copyCode: (id: string) => void }).copyCode = async (id: string) => {
226-
const rawEl = document.getElementById(`code-raw-${id}`);
227-
if (rawEl) {
228-
const text = rawEl.textContent ?? '';
229-
await navigator.clipboard.writeText(text);
230-
copiedId = id;
231-
setTimeout(() => {
232-
copiedId = null;
233-
}, 2000);
234-
}
235-
};
239+
async function handleMarkdownClick(event: MouseEvent) {
240+
const target = event.target as Element | null;
241+
const button = target?.closest?.('button.copy-btn') as HTMLButtonElement | null;
242+
if (!button) return;
243+
244+
const copyTarget = button.dataset.copyTarget;
245+
if (!copyTarget) return;
246+
247+
const rawEl = document.getElementById(`code-raw-${copyTarget}`);
248+
const text = rawEl?.textContent ?? '';
249+
if (!text) return;
250+
251+
try {
252+
await navigator.clipboard.writeText(text);
253+
} catch {
254+
// ignore
255+
}
236256
}
237257
258+
$effect(() => {
259+
const root = markdownClickRoot;
260+
if (!root) return;
261+
const handler = (event: Event) => {
262+
void handleMarkdownClick(event as MouseEvent);
263+
};
264+
root.addEventListener('click', handler);
265+
return () => root.removeEventListener('click', handler);
266+
});
267+
238268
async function copyFullAnswer(messageId: string, chunks: BtcaChunk[]) {
239269
const text = chunks
240270
.filter((c): c is BtcaChunk & { type: 'text' } => c.type === 'text')
@@ -301,7 +331,7 @@
301331
onscroll={handleScroll}
302332
class="absolute inset-0 overflow-y-auto bc-chatPattern"
303333
>
304-
<div class="mx-auto flex w-full max-w-5xl flex-col gap-4 p-5">
334+
<div bind:this={markdownClickRoot} class="mx-auto flex w-full max-w-5xl flex-col gap-4 p-5">
305335
{#each messages as message, index (message.id)}
306336
{#if message.role === 'user'}
307337
<div>

0 commit comments

Comments
 (0)