import MarkdownIt from "markdown-it"; import type { MentionMap } from "./mentions"; import type { MarkdownIt as MarkdownRenderer, StateCore } from "markdown-it"; import { highlight } from "sugar-high"; import { FilterXSS, friendlyAttrValue, safeAttrValue } from "xss"; import { linkTextReferences } from "./references"; import { camoUrl, isOwnHost } from "./camo-sign"; import { userContentOriginFor } from "./usercontent-origin"; import { env } from "cloudflare:workers"; export type MarkdownContext = { owner: string; repo: string; ref: string; /** Directory of the document inside the repo, used to resolve relative links. */ basePath: string; }; const ATTACHMENT_PREFIX = "/attachments/"; const ABSOLUTE_URL = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i; /** GitHub's prefix for user-supplied ids/anchors, so content cannot clobber `window.` or app anchors. */ const USER_CONTENT_PREFIX = "user-content-"; const LOCAL_BASES = ["https://local.invalid/", "http://local.invalid/"]; /** * Parsed like a browser would against an https and an http page, so `//host`, `http:host` (on https) and * `https:host` (on http) all count as leaving the page's origin. */ function isExternalUrl(value: string): boolean { return LOCAL_BASES.some((base) => { try { return new URL(value.trim(), base).hostname !== "local.invalid"; } catch { return false; } }); } function externalHref(value: string): string { for (const base of LOCAL_BASES) { const url = new URL(value.trim(), base); if (url.hostname !== "local.invalid") return url.href; } return value; } const DATA_IMAGE = /^data:image\/(png|gif|jpe?g|webp);/i; /** External images go through the signed same-origin proxy; relative, data: and attachment URLs stay as they are. */ function imageSource(url: string): string { if (DATA_IMAGE.test(url) || !isExternalUrl(url)) return url; const absolute = externalHref(url); if (isOwnHost(new URL(absolute).hostname)) return absolute; const path = camoUrl(absolute); if (!path) return ""; const origin = userContentOriginFor(new URL(env.PUBLIC_URL)); return origin ? `${origin}${path}` : path; } function userContentId(value: string): string { const cleaned = value.trim(); return cleaned.length === 0 || cleaned.startsWith(USER_CONTENT_PREFIX) ? cleaned : `${USER_CONTENT_PREFIX}${cleaned}`; } /** Same-document anchors must point at the prefixed ids this renderer emits. */ function anchorHref(url: string): string { if (!url.startsWith("#") || url.length === 1) return url; return `#${userContentId(url.slice(1))}`; } function createRenderer(): MarkdownRenderer { const md = new MarkdownIt({ html: true, linkify: true, typographer: false, highlight: (code) => `
${highlight(code)}
`, }); md.validateLink = (url) => !/^(javascript|vbscript|data):/i.test(url.trim()) || /^data:image\/(png|gif|jpe?g|webp);/i.test(url.trim()); return md; } const renderer = createRenderer(); const ALIGNED = ["align"]; const CELL = ["align", "colspan", "rowspan"]; const HEADING = ["align", "id"]; /** The raw HTML subset GitHub permits in markdown; everything else (scripts, styles, handlers) is stripped. */ const ALLOWED_HTML: Record = { a: ["href", "title", "name", "id"], img: ["src", "alt", "title", "width", "height", "align"], picture: [], source: ["media", "type", "width", "height"], p: ALIGNED, div: ALIGNED, center: [], span: [], br: [], hr: [], h1: HEADING, h2: HEADING, h3: HEADING, h4: HEADING, h5: HEADING, h6: HEADING, details: ["open"], summary: [], blockquote: [], pre: [], code: [], kbd: [], samp: [], var: [], sup: [], sub: [], b: [], i: [], strong: [], em: [], u: [], s: [], del: [], ins: [], strike: [], small: [], mark: [], abbr: ["title"], q: [], dl: [], dt: [], dd: [], ul: [], ol: ["start", "type"], li: [], table: ["align"], thead: [], tbody: [], tfoot: [], tr: ALIGNED, th: CELL, td: CELL, caption: [], }; const sanitizer = new FilterXSS({ whiteList: ALLOWED_HTML, stripIgnoreTag: true, stripIgnoreTagBody: ["script", "style", "iframe", "object", "embed", "template", "noscript"], css: false, safeAttrValue(tag, name, value, cssFilter) { if (name === "id" || name === "name") return safeAttrValue(tag, name, userContentId(value), cssFilter); if (name === "href") return safeAttrValue(tag, name, anchorHref(rewriteRelative(activeContext, value, "link")), cssFilter); if (name === "src") { // Decode entities first, as the sanitizer will, so `//host` can't look relative and skip camo. const resolved = rewriteRelative(activeContext, friendlyAttrValue(value), "image"); if (/^data:/i.test(resolved)) return DATA_IMAGE.test(resolved) ? safeAttrValue(tag, name, resolved, cssFilter) : ""; return safeAttrValue(tag, name, imageSource(resolved), cssFilter); } return safeAttrValue(tag, name, value, cssFilter); }, }); const ANCHOR = /]*)>/gi; const HREF_ATTR = /(?:^|\s)href="([^"]*)"/i; /** The sanitizer never keeps `rel`, so external anchors always get ours; the href went through `safeAttrValue`. */ function addExternalRel(html: string): string { return html.replace(ANCHOR, (match, attrs: string) => { const href = HREF_ATTR.exec(attrs)?.[1]; return href && isExternalUrl(href.replaceAll("&", "&")) ? `` : match; }); } renderer.renderer.rules.html_block = (tokens, index) => addExternalRel(sanitizer.process(tokens[index].content)); renderer.renderer.rules.html_inline = (tokens, index) => addExternalRel(sanitizer.process(tokens[index].content)); function resolveRepoPath(basePath: string, target: string): string { const parts = (target.startsWith("/") ? [] : basePath.split("/").filter(Boolean)).concat(target.split("/")); const out: string[] = []; for (const part of parts) { if (!part || part === ".") continue; if (part === "..") out.pop(); else out.push(part); } return out.join("/"); } function rewriteRelative(env: MarkdownContext | undefined, url: string, kind: "link" | "image"): string { if (!env || !url || ABSOLUTE_URL.test(url) || url.startsWith(ATTACHMENT_PREFIX)) return url; const [pathPart, hash] = url.split("#", 2); const resolved = resolveRepoPath(env.basePath, pathPart); const mode = kind === "image" ? "raw" : "blob"; return `/${env.owner}/${env.repo}/${mode}/${env.ref}/${resolved}${hash ? `#${hash}` : ""}`; } let activeContext: MarkdownContext | undefined; renderer.core.ruler.push("gitfor_links", (state: StateCore) => { const context = activeContext; for (const token of state.tokens) { for (const child of token.children ?? []) { if (child.type === "link_open") { const raw = child.attrGet("href"); const href = raw === null ? null : String(raw); if (href) child.attrSet("href", anchorHref(rewriteRelative(context, href, "link"))); if (href && isExternalUrl(href)) child.attrSet("rel", "nofollow noopener noreferrer"); } if (child.type === "image") { const src = child.attrGet("src"); if (src !== null) { const resolved = rewriteRelative(context, String(src), "image"); child.attrSet("src", DATA_IMAGE.test(resolved) ? resolved : imageSource(resolved)); } } } } }); const ALERT_MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/i; const ALERT_TITLES: Record = { note: "Note", tip: "Tip", important: "Important", warning: "Warning", caution: "Caution", }; /** GitHub alert blockquotes: `> [!NOTE]` becomes a titled, styled callout. */ renderer.core.ruler.push("gitfor_alerts", (state: StateCore) => { const tokens = state.tokens; for (let index = 0; index < tokens.length - 2; index++) { if (tokens[index].type !== "blockquote_open" || tokens[index + 1].type !== "paragraph_open") continue; const inline = tokens[index + 2]; const first = inline.children?.[0]; const match = first?.type === "text" ? ALERT_MARKER.exec(first.content) : null; if (!inline.children || !first || !match) continue; const kind = match[1].toLowerCase(); first.content = first.content.slice(match[0].length); if (first.content === "" && inline.children[1]?.type === "softbreak") inline.children.splice(0, 2); tokens[index].attrJoin("class", `markdown-alert markdown-alert-${kind}`); const title = new state.Token("gitfor_alert_title", "", 0); title.meta = { kind }; tokens.splice(index + 1, 0, title); } }); renderer.renderer.rules.gitfor_alert_title = (tokens, index) => { const kind = String(tokens[index].meta?.kind ?? "note"); return `

${ALERT_TITLES[kind] ?? "Note"}

\n`; }; const SKIP_TAGS = /^<(\/?)(code|pre|a)\b/i; function linkReferences( html: string, context: Pick | null, mentions: MentionMap | null, ): string { let skipDepth = 0; return html .split(/(<[^>]+>)/) .map((segment) => { if (segment.startsWith("<")) { const tag = SKIP_TAGS.exec(segment); if (tag) skipDepth = Math.max(0, skipDepth + (tag[1] ? -1 : 1)); return segment; } if (skipDepth > 0) return segment; return linkTextReferences(segment, context, mentions); }) .join(""); } /** * Renders untrusted markdown to HTML, sanitizing raw HTML to GitHub's allowed subset, with repo-aware links. * Pass `mentions` from `resolveMentions` for user-written text so handles follow renames. */ export function renderMarkdown( source: string, context: MarkdownContext | null, mentions: MentionMap | null = null, ): string { activeContext = context ?? undefined; try { return linkReferences(renderer.render(source), context, mentions); } finally { activeContext = undefined; } }