mirror of
https://github.com/discourse/discourse.git
synced 2024-11-25 09:42:07 +08:00
8ebd5edd1e
This commit renames all secure_media related settings to secure_uploads_* along with the associated functionality. This is being done because "media" does not really cover it, we aren't just doing this for images and videos etc. but for all uploads in the site. Additionally, in future we want to secure more types of uploads, and enable a kind of "mixed mode" where some uploads are secure and some are not, so keeping media in the name is just confusing. This also keeps compatibility with the `secure-media-uploads` path, and changes new secure URLs to be `secure-uploads`. Deprecated settings: * secure_media -> secure_uploads * secure_media_allow_embed_images_in_emails -> secure_uploads_allow_embed_images_in_emails * secure_media_max_email_embed_image_size_kb -> secure_uploads_max_email_embed_image_size_kb
176 lines
4.9 KiB
JavaScript
176 lines
4.9 KiB
JavaScript
import xss from "xss";
|
|
|
|
const HTML_TYPES = ["html_block", "html_inline"];
|
|
|
|
// add image to array if src has an upload
|
|
function addImage(uploads, token) {
|
|
if (token.attrs) {
|
|
for (let i = 0; i < token.attrs.length; i++) {
|
|
const value = token.attrs[i][1];
|
|
if (value?.startsWith("upload://")) {
|
|
uploads.push({ token, srcIndex: i, origSrc: value });
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function attr(name, value) {
|
|
if (value) {
|
|
return `${name}="${xss.escapeAttrValue(value)}"`;
|
|
}
|
|
|
|
return name;
|
|
}
|
|
|
|
function uploadLocatorString(url) {
|
|
return `___REPLACE_UPLOAD_SRC_${url}___`;
|
|
}
|
|
|
|
function findUploadsInHtml(uploads, blockToken) {
|
|
// Slightly misusing our HTML sanitizer to look for upload://
|
|
// image src attributes, and replace them with a placeholder.
|
|
// Note that we can't use browser DOM APIs because this needs
|
|
// to run in mini-racer.
|
|
let foundImage = false;
|
|
let allowList;
|
|
|
|
const filter = new xss.FilterXSS({
|
|
allowList: [],
|
|
allowCommentTag: true,
|
|
onTag(tag, html, info) {
|
|
// We're not using this for sanitizing, so allow all tags through
|
|
info.isWhite = true;
|
|
allowList[tag] = [];
|
|
},
|
|
onTagAttr(tag, name, value) {
|
|
if (tag === "img" && name === "src" && value.startsWith("upload://")) {
|
|
uploads.push({ token: blockToken, srcIndex: null, origSrc: value });
|
|
foundImage = true;
|
|
return uploadLocatorString(value);
|
|
}
|
|
return attr(name, value);
|
|
},
|
|
});
|
|
|
|
allowList = filter.options.whiteList;
|
|
const newContent = filter.process(blockToken.content);
|
|
|
|
if (foundImage) {
|
|
blockToken.content = newContent;
|
|
}
|
|
}
|
|
|
|
function processToken(uploads, token) {
|
|
if (token.tag === "img" || token.tag === "a") {
|
|
addImage(uploads, token);
|
|
} else if (HTML_TYPES.includes(token.type)) {
|
|
findUploadsInHtml(uploads, token);
|
|
}
|
|
|
|
if (token.children) {
|
|
for (let j = 0; j < token.children.length; j++) {
|
|
const childToken = token.children[j];
|
|
processToken(uploads, childToken);
|
|
}
|
|
}
|
|
}
|
|
|
|
function rule(state) {
|
|
let uploads = [];
|
|
|
|
for (let i = 0; i < state.tokens.length; i++) {
|
|
let blockToken = state.tokens[i];
|
|
|
|
processToken(uploads, blockToken);
|
|
}
|
|
|
|
if (uploads.length > 0) {
|
|
let srcList = uploads.map((u) => u.origSrc);
|
|
|
|
// In client-side cooking, this lookup returns nothing
|
|
// This means we set data-orig-src, and let decorateCooked
|
|
// lookup the image URLs asynchronously
|
|
let lookup = state.md.options.discourse.lookupUploadUrls;
|
|
let longUrls = (lookup && lookup(srcList)) || {};
|
|
|
|
uploads.forEach(({ token, srcIndex, origSrc }) => {
|
|
let mapped = longUrls[origSrc];
|
|
|
|
if (HTML_TYPES.includes(token.type)) {
|
|
const locator = uploadLocatorString(origSrc);
|
|
let attrs = [];
|
|
|
|
if (mapped) {
|
|
attrs.push(
|
|
attr("src", mapped.url),
|
|
attr("data-base62-sha1", mapped.base62_sha1)
|
|
);
|
|
} else {
|
|
attrs.push(
|
|
attr(
|
|
"src",
|
|
state.md.options.discourse.getURL("/images/transparent.png")
|
|
),
|
|
attr("data-orig-src", origSrc)
|
|
);
|
|
}
|
|
|
|
token.content = token.content.replace(locator, attrs.join(" "));
|
|
} else if (token.tag === "img") {
|
|
if (mapped) {
|
|
token.attrs[srcIndex][1] = mapped.url;
|
|
token.attrs.push(["data-base62-sha1", mapped.base62_sha1]);
|
|
} else {
|
|
// no point putting a transparent .png for audio/video
|
|
if (token.content.match(/\|video|\|audio/)) {
|
|
token.attrs[srcIndex][1] =
|
|
state.md.options.discourse.getURL("/404");
|
|
} else {
|
|
token.attrs[srcIndex][1] = state.md.options.discourse.getURL(
|
|
"/images/transparent.png"
|
|
);
|
|
}
|
|
|
|
token.attrs.push(["data-orig-src", origSrc]);
|
|
}
|
|
} else if (token.tag === "a") {
|
|
if (mapped) {
|
|
// when secure uploads is enabled we want the full /secure-media-uploads or /secure-uploads
|
|
// url to take advantage of access control security
|
|
if (
|
|
state.md.options.discourse.limitedSiteSettings.secureUploads &&
|
|
(mapped.url.includes("secure-media-uploads") ||
|
|
mapped.url.includes("secure-uploads"))
|
|
) {
|
|
token.attrs[srcIndex][1] = mapped.url;
|
|
} else {
|
|
token.attrs[srcIndex][1] = mapped.short_path;
|
|
}
|
|
} else {
|
|
token.attrs[srcIndex][1] = state.md.options.discourse.getURL("/404");
|
|
|
|
token.attrs.push(["data-orig-href", origSrc]);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
export function setup(helper) {
|
|
const opts = helper.getOptions();
|
|
if (opts.previewing) {
|
|
helper.allowList(["img.resizable"]);
|
|
}
|
|
|
|
helper.allowList([
|
|
"img[data-orig-src]",
|
|
"img[data-base62-sha1]",
|
|
"a[data-orig-href]",
|
|
]);
|
|
|
|
helper.registerPlugin((md) => {
|
|
md.core.ruler.push("upload-protocol", rule);
|
|
});
|
|
}
|