/** * Render a third-party template description (from a Portainer catalog) for * display via {@html}. * * The input is untrusted, so: HTML tags are stripped, and markdown links are * rebuilt only for safe http(s)/relative URLs — javascript:/data:/etc. are * dropped (keeping just the link text) to prevent a `[x](javascript:...)` XSS. */ function escapeHtml(s: string): string { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } // Only http(s) and relative URLs are safe to put in href; reject javascript:, // data:, etc. export function isSafeLinkUrl(url: string): boolean { // Browsers ignore ASCII whitespace/control chars when parsing the scheme, so // strip them before checking (defeats java\tscript: style bypasses). const cleaned = url.replace(/[\x00-\x20]/g, '').toLowerCase(); if (cleaned.startsWith('http://') || cleaned.startsWith('https://')) return true; // No scheme (relative path/anchor) is safe; any other scheme is not. return !/^[a-z][a-z0-9+.-]*:/.test(cleaned); } // Convert markdown links [text](url) to HTML tags, strip other HTML. export function renderDescription(text: string): string { return text .replace(/]*>([^<]+)<\/a>/gi, '[$2]($1)') // normalize HTML links to markdown first .replace(/<[^>]+>/g, '') // strip remaining HTML tags .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label: string, url: string) => { const safeText = escapeHtml(label); if (!isSafeLinkUrl(url)) return safeText; // drop unsafe scheme, keep the text return `${safeText}`; }) .trim(); }