From 4c1301b5278a340fac015a544e438054e45b791e Mon Sep 17 00:00:00 2001 From: Matt Hesketh Date: Wed, 11 Feb 2026 19:38:58 +0000 Subject: [PATCH 1/5] security: extract all regexes to constants, harden MCP handler, validate inputs - Extract every inline regex across all 10 packages to named exported constants with descriptive names and JSDoc comments - MCP handler: make CORS origin configurable via MCPHandlerOptions, sanitize error responses to prevent information leakage - Router: validate hash fragments against VALID_DOM_ID_RE before passing to getElementById - Form validation: add RFC 5321 max email length check (254 chars) before regex test to mitigate ReDoS on long inputs - Deduplicate shared patterns (HTML_TAG_RE, escape regexes) - Reset lastIndex on shared global regexes to prevent stale state - All 590 tests pass, lint clean, format clean, build clean --- packages/ai/src/adapters/ollama.ts | 5 +- packages/ai/src/mcp/handler.ts | 17 ++- packages/ai/src/mcp/server.ts | 11 +- packages/compiler/src/a11y.ts | 7 +- packages/compiler/src/parser.ts | 16 ++- packages/compiler/src/style-compiler.ts | 20 +++- packages/compiler/src/template-compiler.ts | 56 ++++++++-- packages/create-utopia/src/index.ts | 31 +++++- packages/email/src/css-inliner.ts | 122 ++++++++++++++++----- packages/email/src/email-document.ts | 13 ++- packages/email/src/html-to-text.ts | 97 ++++++++++++---- packages/router/src/components.ts | 16 ++- packages/router/src/matcher.ts | 66 ++++++++--- packages/router/src/router.ts | 19 +++- packages/runtime/src/dom.ts | 14 ++- packages/runtime/src/form.ts | 15 ++- packages/server/src/handler.ts | 3 +- packages/server/src/render-to-stream.ts | 23 +++- packages/server/src/render-to-string.ts | 20 +++- packages/server/src/ssr-runtime.ts | 4 +- 20 files changed, 448 insertions(+), 127 deletions(-) diff --git a/packages/ai/src/adapters/ollama.ts b/packages/ai/src/adapters/ollama.ts index da3a7be..be16bad 100644 --- a/packages/ai/src/adapters/ollama.ts +++ b/packages/ai/src/adapters/ollama.ts @@ -51,6 +51,9 @@ interface OllamaChatResponse { eval_count?: number; } +/** Matches a trailing slash for URL normalization. */ +export const TRAILING_SLASH_RE = /\/$/; + /** Monotonic counter for generating unique tool call IDs. */ let ollamaToolCallCounter = 0; @@ -67,7 +70,7 @@ let ollamaToolCallCounter = 0; * ``` */ export function ollamaAdapter(config: OllamaConfig = {}): AIAdapter { - const baseURL = (config.baseURL ?? 'http://localhost:11434').replace(/\/$/, ''); + const baseURL = (config.baseURL ?? 'http://localhost:11434').replace(TRAILING_SLASH_RE, ''); return { async chat(request: ChatRequest): Promise { diff --git a/packages/ai/src/mcp/handler.ts b/packages/ai/src/mcp/handler.ts index f261fbb..52cc35e 100644 --- a/packages/ai/src/mcp/handler.ts +++ b/packages/ai/src/mcp/handler.ts @@ -6,6 +6,10 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import type { MCPServer } from './server.js'; import type { JsonRpcRequest } from './types.js'; +export interface MCPHandlerOptions { + corsOrigin?: string; +} + /** * Create a Node.js HTTP handler for an MCP server. * @@ -20,7 +24,7 @@ import type { JsonRpcRequest } from './types.js'; * import { createMCPHandler } from '@matthesketh/utopia-ai/mcp'; * * const mcp = createMCPServer({ name: 'my-app', tools: [...] }); - * const handler = createMCPHandler(mcp); + * const handler = createMCPHandler(mcp, { corsOrigin: 'https://example.com' }); * * http.createServer(handler).listen(3001); * // or use as middleware: app.use('/mcp', handler); @@ -28,10 +32,13 @@ import type { JsonRpcRequest } from './types.js'; */ export function createMCPHandler( server: MCPServer, + options?: MCPHandlerOptions, ): (req: IncomingMessage, res: ServerResponse) => void { + const corsOrigin = options?.corsOrigin ?? '*'; + return async (req: IncomingMessage, res: ServerResponse) => { // CORS headers - res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Origin', corsOrigin); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); @@ -74,6 +81,10 @@ async function handlePost( res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(response)); } catch (err: unknown) { + // Only expose error details for JSON parse errors (SyntaxError). + // All other errors get a generic message to prevent information leakage. + const data = err instanceof SyntaxError ? err.message : 'Invalid request'; + res.writeHead(400, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ @@ -82,7 +93,7 @@ async function handlePost( error: { code: -32700, message: 'Parse error', - data: err instanceof Error ? err.message : String(err), + data, }, }), ); diff --git a/packages/ai/src/mcp/server.ts b/packages/ai/src/mcp/server.ts index c8a2bd5..45deffc 100644 --- a/packages/ai/src/mcp/server.ts +++ b/packages/ai/src/mcp/server.ts @@ -180,9 +180,16 @@ export function createMCPServer(config: MCPServerConfig): MCPServer { // Helpers // --------------------------------------------------------------------------- +/** Matches regex special characters that need escaping. */ +export const REGEX_SPECIAL_CHARS_RE = /[.*+?^${}()|[\]\\]/g; + +/** Matches escaped template placeholders like `\{id\}` after regex-escaping. */ +export const TEMPLATE_PLACEHOLDER_RE = /\\\{[^\\}]+\\\}/g; + function matchesTemplate(pattern: string, uri: string): boolean { - const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const regex = escaped.replace(/\\\{[^\\}]+\\\}/g, '([^/]+)'); + const escaped = pattern.replace(REGEX_SPECIAL_CHARS_RE, '\\$&'); + const regex = escaped.replace(TEMPLATE_PLACEHOLDER_RE, '([^/]+)'); + // Dynamic RegExp built from sanitized (escaped) input — safe to construct inline. return new RegExp(`^${regex}$`).test(uri); } diff --git a/packages/compiler/src/a11y.ts b/packages/compiler/src/a11y.ts index 601cc50..ac361a3 100644 --- a/packages/compiler/src/a11y.ts +++ b/packages/compiler/src/a11y.ts @@ -21,6 +21,11 @@ import type { TemplateNode, ElementNode, Attribute, Directive } from './template-compiler'; import { NodeType } from './template-compiler'; +// ---- Regex Constants -------------------------------------------------------- + +/** Matches heading tags h1 through h6 and captures the level number. */ +export const HEADING_LEVEL_RE = /^h([1-6])$/; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -251,7 +256,7 @@ const rules: Record = { }, 'heading-order'(node, warnings, ctx) { - const match = node.tag.match(/^h([1-6])$/); + const match = node.tag.match(HEADING_LEVEL_RE); if (!match) return; const level = parseInt(match[1], 10); if (ctx.lastHeadingLevel > 0 && level > ctx.lastHeadingLevel + 1) { diff --git a/packages/compiler/src/parser.ts b/packages/compiler/src/parser.ts index 9e723a1..2b01d85 100644 --- a/packages/compiler/src/parser.ts +++ b/packages/compiler/src/parser.ts @@ -27,6 +27,14 @@ export interface SFCDescriptor { filename: string; } +// ---- Regex Constants -------------------------------------------------------- + +/** Matches opening tags for the three known SFC block types. */ +export const BLOCK_RE = /<(template|script|style)([\s][^>]*)?\s*>/g; + +/** Matches a single attribute in an opening tag (name, optional quoted/unquoted value). */ +export const ATTR_RE = /([a-zA-Z_][\w-]*)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g; + // ---- Implementation -------------------------------------------------------- /** @@ -46,11 +54,11 @@ export function parse(source: string, filename: string = 'anonymous.utopia'): SF // We use a regex to locate opening tags for the three known block types. // This is safe because we only need to find *top-level* tags — they are // never nested inside one another. - const blockRe = /<(template|script|style)([\s][^>]*)?\s*>/g; + BLOCK_RE.lastIndex = 0; let match: RegExpExecArray | null; - while ((match = blockRe.exec(source)) !== null) { + while ((match = BLOCK_RE.exec(source)) !== null) { const tagName = match[1] as 'template' | 'script' | 'style'; const attrString = match[2] || ''; const openTagStart = match.index; @@ -93,7 +101,7 @@ export function parse(source: string, filename: string = 'anonymous.utopia'): SF // Advance past the closing tag so the regex doesn't match inside the // block content (e.g. a nested