Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 51 additions & 6 deletions apps/web/src/convex/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string> => {
const origins = (process.env.CLIENT_ORIGIN ?? '')
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
}
});
Expand All @@ -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, {
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -872,5 +917,5 @@ async function ensureServerUrl(
}

sendEvent({ type: 'status', status: 'ready' });
return serverUrl;
return assertSafeServerUrl(serverUrl);
}
12 changes: 8 additions & 4 deletions apps/web/src/convex/instances/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/convex/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand Down
65 changes: 65 additions & 0 deletions apps/web/src/convex/urlSafety.ts
Original file line number Diff line number Diff line change
@@ -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;
};
37 changes: 37 additions & 0 deletions apps/web/src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -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;
};
64 changes: 47 additions & 17 deletions apps/web/src/lib/components/ChatMessages.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@
let scrollContainer = $state<HTMLDivElement | null>(null);
let isAtBottom = $state(true);

let markdownClickRoot = $state<HTMLDivElement | null>(null);

function handleScroll() {
if (!scrollContainer) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
Expand Down Expand Up @@ -175,26 +177,37 @@
return langMap[lang] ?? 'text';
}

const escapeHtml = (value: string) =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');

async function renderMarkdownWithShiki(text: string): Promise<string> {
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 `<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>`;
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>`;
};

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']
});
}

Expand All @@ -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')
Expand Down Expand Up @@ -301,7 +331,7 @@
onscroll={handleScroll}
class="absolute inset-0 overflow-y-auto bc-chatPattern"
>
<div class="mx-auto flex w-full max-w-5xl flex-col gap-4 p-5">
<div bind:this={markdownClickRoot} class="mx-auto flex w-full max-w-5xl flex-col gap-4 p-5">
{#each messages as message, index (message.id)}
{#if message.role === 'user'}
<div>
Expand Down
Loading