This repository has no description
1import sanitizeHtml from "sanitize-html";
2import { isRepoRelative, rawSrcset, rawUrl, treeUrl } from "./paths";
3import type { MarkupContext } from "./paths";
4
5const HEADINGS = ["h1", "h2", "h3", "h4", "h5", "h6"];
6
7// mirrors appview/pages/markup/sanitizer, which is bluemonday's UGC policy plus
8// the elements our own extensions emit. markdown renders with raw html enabled,
9// so anything hand written in a readme lands here too
10const ALLOWED_TAGS = [
11 ...HEADINGS,
12 "p",
13 "br",
14 "hr",
15 "div",
16 "span",
17 "section",
18 "blockquote",
19 "pre",
20 "code",
21 "kbd",
22 "samp",
23 "var",
24 "tt",
25 "b",
26 "strong",
27 "i",
28 "em",
29 "u",
30 "s",
31 "strike",
32 "del",
33 "ins",
34 "sub",
35 "sup",
36 "small",
37 "mark",
38 "a",
39 "img",
40 "picture",
41 "source",
42 "video",
43 "ul",
44 "ol",
45 "li",
46 "dl",
47 "dt",
48 "dd",
49 "table",
50 "thead",
51 "tbody",
52 "tfoot",
53 "tr",
54 "th",
55 "td",
56 "caption",
57 "colgroup",
58 "col",
59 "details",
60 "summary",
61 "figure",
62 "figcaption",
63 "abbr",
64 "bdo",
65 "cite",
66 "dfn",
67 "q",
68 "ruby",
69 "rt",
70 "rp",
71 "time",
72 "wbr",
73 "center",
74 "input",
75 "label"
76];
77
78// bluemonday's standard attributes
79const GLOBAL_ATTRIBUTES = ["id", "title", "dir", "lang", "align"];
80
81const ALLOWED_ATTRIBUTES: sanitizeHtml.IOptions["allowedAttributes"] = {
82 "*": GLOBAL_ATTRIBUTES,
83 a: ["href", "name", "rel", "aria-hidden"],
84 img: ["src", "srcset", "alt", "width", "height", "loading"],
85 source: ["src", "srcset", "type", "media"],
86 video: ["src", "poster", "controls", "autoplay", "loop", "muted", "width", "height"],
87 // the tasklist plugin renders disabled checkboxes tied to their labels
88 input: ["type", "checked", "disabled"],
89 label: ["for"],
90 th: ["colspan", "rowspan", "scope"],
91 td: ["colspan", "rowspan"],
92 col: ["span", "width"],
93 colgroup: ["span"],
94 ol: ["start", "type", "reversed"],
95 details: ["open"],
96 time: ["datetime"],
97 abbr: ["title"]
98};
99
100// classes are allowlisted per tag, so a readme cannot reach the app's own styles
101const ALLOWED_CLASSES: sanitizeHtml.IOptions["allowedClasses"] = {
102 a: ["anchor", "mention", "footnote-ref", "footnote-backref", "footnote-anchor"],
103 sup: ["footnote-ref"],
104 hr: ["footnotes-sep"],
105 section: ["footnotes"],
106 ol: ["footnotes-list", "task-list-container"],
107 ul: ["task-list-container"],
108 li: ["footnote-item", "task-list-item"],
109 input: ["task-list-item-checkbox"],
110 label: ["task-list-item-label"],
111 div: ["markdown-alert", "markdown-alert-*"],
112 p: ["markdown-alert-title"],
113 code: ["language-*"]
114};
115
116const externalRel = (href: string): string | undefined =>
117 isRepoRelative(href) || href.startsWith("#") ? undefined : "nofollow noopener noreferrer";
118
119const optionsFor = (ctx: MarkupContext): sanitizeHtml.IOptions => ({
120 allowedTags: ALLOWED_TAGS,
121 allowedAttributes: ALLOWED_ATTRIBUTES,
122 allowedClasses: ALLOWED_CLASSES,
123 allowedSchemes: ["http", "https", "mailto"],
124 allowedSchemesAppliedToAttributes: ["href", "src", "srcset", "poster"],
125 // resolving urls here rather than in a renderer rule catches the ones
126 // written as raw html too
127 transformTags: {
128 a: (tagName, attribs) => {
129 const href = attribs.href ?? "";
130 // a mention already points at a profile
131 const rewritten =
132 isRepoRelative(href) && attribs.class !== "mention" ? treeUrl(href, ctx) : href;
133 const rel = externalRel(rewritten);
134 return { tagName, attribs: { ...attribs, href: rewritten, ...(rel ? { rel } : {}) } };
135 },
136 img: (tagName, attribs) => ({ tagName, attribs: resolveMedia(attribs, ctx) }),
137 source: (tagName, attribs) => ({ tagName, attribs: resolveMedia(attribs, ctx) }),
138 video: (tagName, attribs) => ({ tagName, attribs: resolveMedia(attribs, ctx) })
139 },
140 // the tasklist checkboxes are the only inputs we render
141 exclusiveFilter: (frame) => frame.tag === "input" && frame.attribs.type !== "checkbox"
142});
143
144// todo: external images should go through camo like the appview does, which needs
145// the shared secret in web's config and moves rendering server side
146const resolveMedia = (
147 attribs: Record<string, string>,
148 ctx: MarkupContext
149): Record<string, string> => {
150 const resolved = { ...attribs };
151 for (const key of ["src", "poster"]) {
152 const value = resolved[key];
153 if (value && isRepoRelative(value)) resolved[key] = rawUrl(value, ctx);
154 }
155 if (resolved.srcset) resolved.srcset = rawSrcset(resolved.srcset, ctx);
156 return resolved;
157};
158
159export const sanitizeMarkup = (html: string, ctx: MarkupContext): string =>
160 sanitizeHtml(html, optionsFor(ctx));