mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 14:59:13 +08:00

`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.
189 lines
4.9 KiB
JavaScript
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, "<$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, "&")
|
|
.replace(/'/g, "'")
|
|
.replace(/ \/>/g, ">");
|
|
}
|