import sanitizeHtml from "sanitize-html"; import { isRepoRelative, mediaSrcset, mediaUrl, treeUrl } from "./paths"; import type { MarkupContext } from "./paths"; const HEADINGS = ["h1", "h2", "h3", "h4", "h5", "h6"]; // mirrors appview/pages/markup/sanitizer, which is bluemonday's UGC policy plus // the elements our own extensions emit. markdown renders with raw html enabled, // so anything hand written in a readme lands here too const ALLOWED_TAGS = [ ...HEADINGS, "p", "br", "hr", "div", "span", "section", "blockquote", "pre", "code", "kbd", "samp", "var", "tt", "b", "strong", "i", "em", "u", "s", "strike", "del", "ins", "sub", "sup", "small", "mark", "a", "img", "picture", "source", "video", "ul", "ol", "li", "dl", "dt", "dd", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col", "details", "summary", "figure", "figcaption", "abbr", "bdo", "cite", "dfn", "q", "ruby", "rt", "rp", "time", "wbr", "center", "input", "label" ]; // bluemonday's standard attributes const GLOBAL_ATTRIBUTES = ["id", "title", "dir", "lang", "align"]; const ALLOWED_ATTRIBUTES: sanitizeHtml.IOptions["allowedAttributes"] = { "*": GLOBAL_ATTRIBUTES, a: ["href", "name", "rel", "aria-hidden"], img: ["src", "srcset", "alt", "width", "height", "loading"], source: ["src", "srcset", "type", "media"], video: ["src", "poster", "controls", "autoplay", "loop", "muted", "width", "height"], // the tasklist plugin renders disabled checkboxes tied to their labels input: ["type", "checked", "disabled"], label: ["for"], th: ["colspan", "rowspan", "scope"], td: ["colspan", "rowspan"], col: ["span", "width"], colgroup: ["span"], ol: ["start", "type", "reversed"], details: ["open"], time: ["datetime"], abbr: ["title"] }; // classes are allowlisted per tag, so a readme cannot reach the app's own styles const ALLOWED_CLASSES: sanitizeHtml.IOptions["allowedClasses"] = { a: ["anchor", "mention", "footnote-ref", "footnote-backref", "footnote-anchor"], sup: ["footnote-ref"], hr: ["footnotes-sep"], section: ["footnotes"], ol: ["footnotes-list", "task-list-container"], ul: ["task-list-container"], li: ["footnote-item", "task-list-item"], input: ["task-list-item-checkbox"], label: ["task-list-item-label"], div: ["markdown-alert", "markdown-alert-*"], p: ["markdown-alert-title"], code: ["language-*"] }; const externalRel = (href: string): string | undefined => isRepoRelative(href) || href.startsWith("#") ? undefined : "nofollow noopener noreferrer"; const optionsFor = (ctx: MarkupContext): sanitizeHtml.IOptions => ({ allowedTags: ALLOWED_TAGS, allowedAttributes: ALLOWED_ATTRIBUTES, allowedClasses: ALLOWED_CLASSES, allowedSchemes: ["http", "https", "mailto"], allowedSchemesAppliedToAttributes: ["href", "src", "srcset", "poster"], // resolving urls here rather than in a renderer rule catches the ones // written as raw html too transformTags: { a: (tagName, attribs) => { const href = attribs.href ?? ""; // a mention already points at a profile const rewritten = isRepoRelative(href) && attribs.class !== "mention" ? treeUrl(href, ctx) : href; const rel = externalRel(rewritten); return { tagName, attribs: { ...attribs, href: rewritten, ...(rel ? { rel } : {}) } }; }, img: (tagName, attribs) => ({ tagName, attribs: resolveMedia(attribs, ctx) }), source: (tagName, attribs) => ({ tagName, attribs: resolveMedia(attribs, ctx) }), video: (tagName, attribs) => ({ tagName, attribs: resolveMedia(attribs, ctx) }) }, // the tasklist checkboxes are the only inputs we render exclusiveFilter: (frame) => frame.tag === "input" && frame.attribs.type !== "checkbox" }); const resolveMedia = ( attribs: Record, ctx: MarkupContext ): Record => { const resolved = { ...attribs }; for (const key of ["src", "poster"]) { const value = resolved[key]; if (value) resolved[key] = mediaUrl(value, ctx); } if (resolved.srcset) resolved.srcset = mediaSrcset(resolved.srcset, ctx); return resolved; }; export const sanitizeMarkup = (html: string, ctx: MarkupContext): string => sanitizeHtml(html, optionsFor(ctx));