/** * Strip dangerous CSS constructs from bot-supplied stylesheets. * Not a full CSS parser — best-effort for MVP scoped injection. */ export function sanitizeRoomAppCss(css: string): string { let out = css; out = out.replace(/@import\b[^;{]*;?/gi, ''); out = out.replace(/url\s*\(\s*['"]?\s*javascript:[^)]*\)/gi, 'url(about:blank)'); out = out.replace(/expression\s*\([^)]*\)/gi, 'initial'); out = out.replace(/-moz-binding\s*:[^;]+;?/gi, ''); out = out.replace(/behavior\s*:[^;]+;?/gi, ''); out = out.replace(/@charset\b[^;]*;?/gi, ''); return out; } /** * Prefix plain selectors with a scope so bot CSS cannot escape the room app root. * At-rules (@media, @supports, @keyframes) are kept with nested rules re-scoped where possible. */ export function scopeRoomAppCss(css: string, scopeSelector: string): string { const sanitized = sanitizeRoomAppCss(css).trim(); if (!sanitized) return ''; const scopeRule = (selectors: string, body: string): string => { const scoped = selectors .split(',') .map((s) => { const sel = s.trim(); if (!sel) return ''; if (sel.startsWith(scopeSelector)) return sel; return `${scopeSelector} ${sel}`; }) .filter(Boolean) .join(', '); return `${scoped}{${body}}`; }; const rewriteBlock = (block: string): string => { const trimmed = block.trim(); if (!trimmed) return ''; if (trimmed.startsWith('@')) { const open = trimmed.indexOf('{'); if (open === -1) return `${trimmed};`; const header = trimmed.slice(0, open).trim(); const inner = trimmed.slice(open + 1); // @keyframes / @font-face: keep as-is (namespaced risk is low for MVP) if (/^@(keyframes|font-face)\b/i.test(header)) { return `${header}{${inner}}`; } // @media / @supports: re-scope nested rules const nested = rewriteCssChunk(inner); return `${header}{${nested}}`; } const open = trimmed.indexOf('{'); if (open === -1) return ''; const selectors = trimmed.slice(0, open); const body = trimmed.slice(open + 1); return scopeRule(selectors, body); }; const rewriteCssChunk = (chunk: string): string => { const parts: string[] = []; let depth = 0; let start = 0; for (let i = 0; i < chunk.length; i += 1) { const ch = chunk[i]; if (ch === '{') depth += 1; else if (ch === '}') { depth -= 1; if (depth === 0) { parts.push(rewriteBlock(chunk.slice(start, i))); start = i + 1; } } } return parts.filter(Boolean).join('\n'); }; return rewriteCssChunk(sanitized); } export function isAllowedRoomAppImageSrc(src: string): boolean { const trimmed = src.trim(); return ( trimmed.startsWith('mxc://') || trimmed.startsWith('https://') || trimmed.startsWith('http://') ); }