41 lines
1.7 KiB
TypeScript
41 lines
1.7 KiB
TypeScript
/**
|
|
* 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, '>')
|
|
.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 <a> tags, strip other HTML.
|
|
export function renderDescription(text: string): string {
|
|
return text
|
|
.replace(/<a\s+href="([^"]+)"[^>]*>([^<]+)<\/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 `<a href="${escapeHtml(url)}" target="_blank" rel="noopener" class="text-primary hover:underline">${safeText}</a>`;
|
|
})
|
|
.trim();
|
|
}
|