142 lines
4.9 KiB
TypeScript
142 lines
4.9 KiB
TypeScript
export const ALLOWED_MARKDOWN_LINK_SCHEMES = Object.freeze(["http", "https", "mailto", "tel"] as const);
|
|
export type AllowedMarkdownLinkScheme = (typeof ALLOWED_MARKDOWN_LINK_SCHEMES)[number];
|
|
|
|
function decodeEntityCodePoint(raw: string, value: string, radix: number): string {
|
|
const codePoint = Number.parseInt(value, radix);
|
|
return Number.isFinite(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff
|
|
? String.fromCodePoint(codePoint)
|
|
: raw;
|
|
}
|
|
|
|
function decodeUrlCharacterEntities(value: string): string {
|
|
return value
|
|
.replace(/&#x([0-9a-f]+);?/gi, (raw, hex) => decodeEntityCodePoint(raw, hex, 16))
|
|
.replace(/&#([0-9]+);?/g, (raw, decimal) => decodeEntityCodePoint(raw, decimal, 10))
|
|
.replace(/:/gi, ":")
|
|
.replace(/&tab;/gi, "\t")
|
|
.replace(/&newline;/gi, "\n");
|
|
}
|
|
|
|
function isAllowedUrlScheme(rawDestination: string): boolean {
|
|
const trimmed = rawDestination.trim();
|
|
if (!trimmed) return false;
|
|
|
|
// HTML attributes decode character references, and URL parsers ignore ASCII
|
|
// whitespace/control characters in schemes. Normalize before allowlisting.
|
|
const schemeProbe = decodeUrlCharacterEntities(trimmed).replace(/[\u0000-\u0020\u007f]/g, "");
|
|
const schemeMatch = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(schemeProbe);
|
|
if (!schemeMatch) return true; // relative/hash/protocol-relative URL
|
|
const scheme = schemeMatch[1].toLowerCase() as AllowedMarkdownLinkScheme;
|
|
return ALLOWED_MARKDOWN_LINK_SCHEMES.includes(scheme);
|
|
}
|
|
|
|
function splitMarkdownDestination(raw: string): { destination: string; title: string } {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) return { destination: "", title: "" };
|
|
|
|
if (trimmed.startsWith("<")) {
|
|
const close = trimmed.indexOf(">");
|
|
if (close < 0) return { destination: trimmed, title: "" };
|
|
const destination = trimmed.slice(1, close).trim();
|
|
const title = trimmed.slice(close + 1).trim();
|
|
return { destination, title };
|
|
}
|
|
|
|
const firstWhitespace = [...trimmed].findIndex((char) => char === " " || char === "\t" || char === "\n" || char === "\r");
|
|
if (firstWhitespace < 0) {
|
|
return { destination: trimmed, title: "" };
|
|
}
|
|
|
|
return { destination: trimmed.slice(0, firstWhitespace).trim(), title: trimmed.slice(firstWhitespace).trim() };
|
|
}
|
|
|
|
function sanitizeMarkdownLinkDestination(raw: string): string {
|
|
const { destination } = splitMarkdownDestination(raw);
|
|
// Check both the full raw destination (to catch whitespace-obfuscated schemes)
|
|
// and the parsed destination (to preserve optional markdown link titles).
|
|
if (!isAllowedUrlScheme(raw) || (destination && !isAllowedUrlScheme(destination))) {
|
|
return "#";
|
|
}
|
|
return raw.trim();
|
|
}
|
|
|
|
function isMarkdownAutolinkCandidate(markdown: string): boolean {
|
|
const trimmed = markdown.trim();
|
|
return trimmed.startsWith("<") && trimmed.endsWith(">") && trimmed.includes(":");
|
|
}
|
|
|
|
/**
|
|
* Return markdown text where markdown link destinations use an explicit allowlist
|
|
* of URL schemes. This protects against `javascript:`, `data:`, etc.
|
|
*/
|
|
export function sanitizeMarkdownLinks(markdown: string): string {
|
|
if (!markdown.includes("[") && !markdown.includes("<")) return markdown;
|
|
|
|
let sanitized = "";
|
|
let index = 0;
|
|
|
|
while (index < markdown.length) {
|
|
const char = markdown[index];
|
|
|
|
const autolinkEnd = markdown.indexOf(">", index + 1);
|
|
if (char === "<" && autolinkEnd >= 0) {
|
|
const candidate = markdown.slice(index + 1, autolinkEnd);
|
|
const token = markdown.slice(index, autolinkEnd + 1);
|
|
if (isMarkdownAutolinkCandidate(token)) {
|
|
sanitized += isAllowedUrlScheme(candidate) ? token : `\`${candidate}\``;
|
|
index = autolinkEnd + 1;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const linkTextStart = char === "[" || (char === "!" && markdown[index + 1] === "[");
|
|
if (!linkTextStart) {
|
|
sanitized += char;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
const open = markdown[index] === "!" ? index + 1 : index;
|
|
const labelEnd = markdown.indexOf("](", open);
|
|
if (labelEnd < 0) {
|
|
sanitized += char;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
const destinationStart = labelEnd + 2;
|
|
let destinationEnd = destinationStart;
|
|
let depth = 0;
|
|
while (destinationEnd < markdown.length) {
|
|
const next = markdown[destinationEnd];
|
|
if (next === "\\") {
|
|
destinationEnd += 2;
|
|
continue;
|
|
}
|
|
if (next === "(") {
|
|
depth += 1;
|
|
} else if (next === ")") {
|
|
if (depth === 0) break;
|
|
depth -= 1;
|
|
}
|
|
destinationEnd += 1;
|
|
}
|
|
|
|
if (destinationEnd >= markdown.length) {
|
|
sanitized += markdown.slice(index);
|
|
break;
|
|
}
|
|
|
|
const prefix = markdown.slice(index, destinationStart);
|
|
const destination = markdown.slice(destinationStart, destinationEnd);
|
|
sanitized += `${prefix}${sanitizeMarkdownLinkDestination(destination)})`;
|
|
index = destinationEnd + 1;
|
|
}
|
|
|
|
return sanitized;
|
|
}
|
|
|
|
export function isSafeMarkdownLink(rawDestination: string): boolean {
|
|
return isAllowedUrlScheme(rawDestination);
|
|
}
|