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