David Taylor 0ed4b09527
DEV: Move discourse-common/(utils|lib) to discourse/lib (#30733)
`discourse-common` was created in the past to share logic between the
'wizard' app and the main 'discourse' app. Since then, the wizard has
been consolidated into the main app, so the separation of
`discourse-common` is no longer useful.

This commit moves `discourse-common/(lib|utils)/*` into
`discourse/lib/*`, adds shims for the imports, and updates existing
uses in core.
2025-01-13 13:02:49 +00:00

189 lines
4.9 KiB
JavaScript

import xss from "xss";
import escape from "discourse/lib/escape";
// Should match any <iframe> without a src attribute
const IFRAME_REGEXP =
/<iframe(?![^>]*\s+src\s*=)[^>]*>[\s\S]*?(<\/iframe\s*>|$)/gi;
function attr(name, value) {
if (value) {
return `${name}="${xss.escapeAttrValue(value)}"`;
}
return name;
}
export { escape };
export function hrefAllowed(href, extraHrefMatchers) {
// escape single quotes
href = href.replace(/'/g, "%27");
// absolute urls
if (/^(https?:)?\/\/[\w\.\-]+/i.test(href)) {
return href;
}
// relative urls
if (/^\/[\w\.\-]+/i.test(href)) {
return href;
}
// anchors
if (/^#[\w\.\-]+/i.test(href)) {
return href;
}
// mailtos
if (/^mailto:[\w\.\-@]+/i.test(href)) {
return href;
}
if (extraHrefMatchers && extraHrefMatchers.length > 0) {
for (let i = 0; i < extraHrefMatchers.length; i++) {
if (extraHrefMatchers[i].test(href)) {
return href;
}
}
}
}
function sanitizeMediaSrc(tag, attrName, value, extraHrefMatchers) {
const srcAttrs = {
img: ["src"],
source: ["src", "srcset"],
track: ["src"],
};
if (!srcAttrs[tag]?.includes(attrName)) {
return;
}
if (value.startsWith("data:image")) {
return attr(attrName, value);
}
if (attrName === "srcset") {
const srcset = value.split(",").map((v) => v.split(" ", 2));
const sanitizedValue = srcset
.map((src) => {
const allowedSrc = hrefAllowed(src[0], extraHrefMatchers);
if (allowedSrc) {
return src[1] ? `${allowedSrc} ${src[1]}` : allowedSrc;
}
})
.join(",");
return attr(attrName, sanitizedValue);
} else {
const returnVal = hrefAllowed(value, extraHrefMatchers);
return attr(attrName, returnVal);
}
}
function testDataAttribute(forTag, name, value) {
return Object.keys(forTag).find((k) => {
const nameWithMatcher = `^${k.replace(/\*$/, "\\w+?")}`;
const validValues = forTag[k];
return (
new RegExp(nameWithMatcher).test(name) &&
(validValues.includes("*") ? true : validValues.includes(value))
);
});
}
export function sanitize(text, allowLister) {
if (!text) {
return "";
}
// Allow things like <3 and <_<
text = text.replace(/<([^A-Za-z\/\!]|$)/g, "&lt;$1");
const allowList = allowLister.getAllowList(),
allowedHrefSchemes = allowLister.getAllowedHrefSchemes(),
allowedIframes = allowLister.getAllowedIframes();
let extraHrefMatchers = null;
if (allowedHrefSchemes && allowedHrefSchemes.length > 0) {
extraHrefMatchers = [
new RegExp("^(" + allowedHrefSchemes.join("|") + ")://[\\w\\.\\-]+", "i"),
];
if (allowedHrefSchemes.includes("tel")) {
extraHrefMatchers.push(new RegExp("^tel://\\+?[\\w\\.\\-]+", "i"));
}
}
let result = xss(text, {
allowList: allowList.tagList,
stripIgnoreTag: true,
stripIgnoreTagBody: ["script", "table"],
onIgnoreTagAttr(tag, name, value) {
const forTag = allowList.attrList[tag];
if (forTag) {
const forAttr = forTag[name];
if (
(forAttr && (forAttr.includes("*") || forAttr.includes(value))) ||
(!name.includes("data-html-") &&
name.startsWith("data-") &&
(forTag["data-*"] || testDataAttribute(forTag, name, value))) ||
(tag === "a" &&
name === "href" &&
hrefAllowed(value, extraHrefMatchers)) ||
(tag === "iframe" &&
name === "src" &&
!value.match(/\/\.+\//) &&
allowedIframes.some((i) => {
return value.toLowerCase().startsWith((i || "").toLowerCase());
}))
) {
return attr(name, value);
}
const sanitizedMediaSrc = sanitizeMediaSrc(
tag,
name,
value,
extraHrefMatchers
);
if (sanitizedMediaSrc) {
return sanitizedMediaSrc;
}
if (tag === "iframe" && name === "src") {
// This iframe is not allowed
return "";
}
if (tag === "video" && name === "autoplay") {
// This might give us duplicate 'muted' attributes
// but they will be deduped by later processing
return "autoplay muted";
}
// Heading ids must begin with `heading--`
if (
["h1", "h2", "h3", "h4", "h5", "h6"].includes(tag) &&
value.match(/^heading\-\-[a-zA-Z0-9\-\_]+$/)
) {
return attr(name, value);
}
const custom = allowLister.getCustom();
for (let i = 0; i < custom.length; i++) {
const fn = custom[i];
if (fn(tag, name, value)) {
return attr(name, value);
}
}
}
},
});
return result
.replace(/\[removed\]/g, "")
.replace(IFRAME_REGEXP, "")
.replace(/&(?![#\w]+;)/g, "&amp;")
.replace(/&#39;/g, "'")
.replace(/ \/>/g, ">");
}