Files
cinny/src/app/features/room-app/sanitizeRoomAppCss.ts
litruv 8a68a1e30a
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Add bot-driven room app UI via im.paarrot.ui state.
Bots can publish a declarative panel UI that takes over a room; user
actions are sent as im.paarrot.ui.action timeline events.
2026-08-09 13:16:43 +10:00

92 lines
2.8 KiB
TypeScript

/**
* 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://')
);
}