SECURITY: Apply transformations to text nodes only
Previously, the replacement system would modify raw HTML, which is prone to issues and vulnerabilities. With this commit, we iterate over text nodes only, and do simple string replacements on their content. That means that the user input never gets passed into an HTML parser, and there is no chance of injection attacks.
The re-rendering system is also simplified to store the original value for re-use later, instead of mapping position/length of replacements.
This does mean the behavior is changed slightly. Replacements will no longer be applied to html attributes (e.g `a[href]`). If this affects your use-case, please let us know [on Meta](https://meta.discourse.org/t/113533).
This is a followup to the fix in a62f711d56
This commit is contained in:
parent
a62f711d56
commit
948634fe31
|
@ -1,6 +1,5 @@
|
||||||
import { debounce, later } from "@ember/runloop";
|
import { debounce, later } from "@ember/runloop";
|
||||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||||
import { escapeExpression } from "discourse/lib/utilities";
|
|
||||||
import DiscoursePlaceholderBuilder from "../components/modal/discourse-placeholder-builder";
|
import DiscoursePlaceholderBuilder from "../components/modal/discourse-placeholder-builder";
|
||||||
|
|
||||||
const VALID_TAGS =
|
const VALID_TAGS =
|
||||||
|
@ -10,6 +9,8 @@ const EXPIRE_AFTER_DAYS = 7;
|
||||||
const EXPIRE_AFTER_SECONDS = EXPIRE_AFTER_DAYS * 24 * 60 * 60;
|
const EXPIRE_AFTER_SECONDS = EXPIRE_AFTER_DAYS * 24 * 60 * 60;
|
||||||
const STORAGE_PREFIX = "d-placeholder-";
|
const STORAGE_PREFIX = "d-placeholder-";
|
||||||
|
|
||||||
|
const originalContentMap = new WeakMap();
|
||||||
|
|
||||||
function buildInput(key, placeholder) {
|
function buildInput(key, placeholder) {
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.classList.add("discourse-placeholder-value");
|
input.classList.add("discourse-placeholder-value");
|
||||||
|
@ -112,7 +113,6 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
const postIdentifier = `${postWidget.widget.attrs.topicId}-${postWidget.widget.attrs.id}-`;
|
const postIdentifier = `${postWidget.widget.attrs.topicId}-${postWidget.widget.attrs.id}-`;
|
||||||
const mappings = [];
|
|
||||||
const placeholders = {};
|
const placeholders = {};
|
||||||
|
|
||||||
const processChange = (inputEvent) => {
|
const processChange = (inputEvent) => {
|
||||||
|
@ -120,6 +120,7 @@ export default {
|
||||||
const key = inputEvent.target.dataset.key;
|
const key = inputEvent.target.dataset.key;
|
||||||
const placeholder = placeholders[inputEvent.target.dataset.key];
|
const placeholder = placeholders[inputEvent.target.dataset.key];
|
||||||
const placeholderIdentifier = `${postIdentifier}${key}`;
|
const placeholderIdentifier = `${postIdentifier}${key}`;
|
||||||
|
const placeholderWithDelimiter = `${placeholder.delimiter}${key}${placeholder.delimiter}`;
|
||||||
|
|
||||||
if (value) {
|
if (value) {
|
||||||
if (value !== placeholder.default) {
|
if (value !== placeholder.default) {
|
||||||
|
@ -133,83 +134,37 @@ export default {
|
||||||
if (value && value.length && value !== "none") {
|
if (value && value.length && value !== "none") {
|
||||||
newValue = value;
|
newValue = value;
|
||||||
} else {
|
} else {
|
||||||
newValue = `${placeholder.delimiter}${key}${placeholder.delimiter}`;
|
newValue = placeholderWithDelimiter;
|
||||||
}
|
}
|
||||||
|
|
||||||
newValue = escapeExpression(newValue);
|
cooked.querySelectorAll(VALID_TAGS).forEach((elem) => {
|
||||||
|
const textNodeWalker = document.createTreeWalker(
|
||||||
|
elem,
|
||||||
|
NodeFilter.SHOW_TEXT
|
||||||
|
);
|
||||||
|
|
||||||
cooked.querySelectorAll(VALID_TAGS).forEach((elem, index) => {
|
while (textNodeWalker.nextNode()) {
|
||||||
const mapping = mappings[index];
|
const node = textNodeWalker.currentNode;
|
||||||
|
let text;
|
||||||
|
|
||||||
if (!mapping) {
|
if (originalContentMap.has(node)) {
|
||||||
return;
|
// The content of this node has already been transformed. Use the value
|
||||||
}
|
// we saved as the source of truth
|
||||||
|
text = originalContentMap.get(node);
|
||||||
let diff = 0;
|
} else {
|
||||||
let replaced = false;
|
// Haven't seen this node before. Get the text, and store it for future
|
||||||
let newInnerHTML = elem.innerHTML;
|
// transformations
|
||||||
|
text = node.data;
|
||||||
mapping.forEach((m) => {
|
originalContentMap.set(node, text);
|
||||||
if (
|
|
||||||
m.pattern !==
|
|
||||||
`${placeholder.delimiter}${key}${placeholder.delimiter}`
|
|
||||||
) {
|
|
||||||
m.position = m.position + diff;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
replaced = true;
|
node.data = text.replaceAll(placeholderWithDelimiter, newValue);
|
||||||
|
|
||||||
const previousLength = m.length;
|
|
||||||
const prefix = newInnerHTML.slice(0, m.position + diff);
|
|
||||||
const suffix = newInnerHTML.slice(
|
|
||||||
m.position + diff + m.length,
|
|
||||||
newInnerHTML.length
|
|
||||||
);
|
|
||||||
newInnerHTML = `${prefix}${newValue}${suffix}`;
|
|
||||||
|
|
||||||
m.length = newValue.length;
|
|
||||||
m.position = m.position + diff;
|
|
||||||
diff = diff + newValue.length - previousLength;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (replaced) {
|
|
||||||
elem.innerHTML = newInnerHTML;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
function processPlaceholders() {
|
|
||||||
mappings.length = 0;
|
|
||||||
|
|
||||||
const keys = Object.keys(placeholders);
|
|
||||||
const pattern = keys
|
|
||||||
.map((key) => {
|
|
||||||
const placeholder = placeholders[key];
|
|
||||||
return `(${placeholder.delimiter}${key}${placeholder.delimiter})`;
|
|
||||||
})
|
|
||||||
.join("|");
|
|
||||||
const regex = new RegExp(pattern, "g");
|
|
||||||
|
|
||||||
cooked.querySelectorAll(VALID_TAGS).forEach((elem, index) => {
|
|
||||||
let match;
|
|
||||||
|
|
||||||
mappings[index] = mappings[index] || [];
|
|
||||||
|
|
||||||
while ((match = regex.exec(elem.innerHTML)) != null) {
|
|
||||||
mappings[index].push({
|
|
||||||
pattern: match[0],
|
|
||||||
position: match.index,
|
|
||||||
length: match[0].length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const _fillPlaceholders = () => {
|
const _fillPlaceholders = () => {
|
||||||
if (Object.keys(placeholders).length > 0) {
|
if (Object.keys(placeholders).length > 0) {
|
||||||
processPlaceholders(placeholders, cooked, mappings);
|
|
||||||
|
|
||||||
// trigger fake event to setup initial state
|
// trigger fake event to setup initial state
|
||||||
Object.keys(placeholders).forEach((placeholderKey) => {
|
Object.keys(placeholders).forEach((placeholderKey) => {
|
||||||
const placeholder = placeholders[placeholderKey];
|
const placeholder = placeholders[placeholderKey];
|
||||||
|
|
Loading…
Reference in New Issue