diff --git a/CHANGELOG.md b/CHANGELOG.md
index e24dd1b2..17ea488d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,19 @@
# @formio/core
+## 2.8.1
+
+### Patch Changes
+
+- 5fa2592: FIO-11314: upgrade dompurify to 3.4.12
+- dc60a11: FIO-11489 prevent HTML injection via logic
+
+## 2.8.1-api99.0
+
+### Patch Changes
+
+- 5fa2592: FIO-11314: upgrade dompurify to 3.4.12
+- dc60a11: FIO-11489 prevent HTML injection via logic
+
## 2.8.0
### Minor Changes
diff --git a/package.json b/package.json
index 5d371801..51b84433 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@formio/core",
- "version": "2.8.0",
+ "version": "2.8.1",
"description": "The core Form.io renderering framework.",
"main": "lib/index.js",
"exports": {
@@ -110,7 +110,7 @@
"browser-cookies": "^1.2.0",
"core-js": "^3.39.0",
"dayjs": "^1.11.12",
- "dompurify": "^3.4.0",
+ "dompurify": "^3.4.12",
"eventemitter3": "^5.0.0",
"fast-json-patch": "^3.1.1",
"fetch-ponyfill": "^7.1.0",
diff --git a/src/utils/__tests__/logic-merge-escape.test.ts b/src/utils/__tests__/logic-merge-escape.test.ts
new file mode 100644
index 00000000..b06baa92
--- /dev/null
+++ b/src/utils/__tests__/logic-merge-escape.test.ts
@@ -0,0 +1,37 @@
+import { expect } from 'chai';
+import { setMergeComponentSchema } from '../logic';
+import type { LogicContext } from 'types/process/logic/LogicContext';
+import type { LogicActionMergeComponentSchema } from 'types/AdvancedLogic';
+
+describe('setMergeComponentSchema', () => {
+ it('HTML-escapes submission data used when merging schema so labels cannot inject HTML', () => {
+ const payload = '
';
+ const component: any = {
+ key: 'checkbox',
+ type: 'checkbox',
+ label: 'Original',
+ };
+ const data: Record = {
+ textField: payload,
+ };
+ const scope = {} as LogicContext['scope'];
+ const context = {
+ component,
+ data,
+ row: data,
+ path: 'checkbox',
+ scope,
+ } as LogicContext;
+
+ const action: LogicActionMergeComponentSchema = {
+ type: 'mergeComponentSchema',
+ schemaDefinition: `schema = { label: data.textField };`,
+ };
+
+ setMergeComponentSchema(context, action);
+
+ expect(component.label).to.not.include('
{
@@ -107,15 +107,25 @@ export function setActionStringProperty(
context: LogicContext,
action: LogicActionPropertyString,
): boolean {
- const { component } = context;
+ const { component, data, row, result } = context;
const property = action.property.value;
const textValue = action.property.component
? (action as any)[action.property.component]
: action.text;
const currentValue = get(component, property, '');
- const newValue = interpolate(textValue, { ...context, value: '' }, (evalContext: any) => {
- evalContext.value = currentValue;
- });
+ const newValue = interpolate(
+ textValue,
+ {
+ ...context,
+ value: '',
+ data: escapeInterpolationDataStrings(data),
+ row: escapeInterpolationDataStrings(row),
+ result: typeof result === 'string' ? escape(result) : escapeInterpolationDataStrings(result),
+ },
+ (evalContext: any) => {
+ evalContext.value = currentValue;
+ },
+ );
if (newValue !== currentValue) {
set(component, property, newValue);
return true;
@@ -158,16 +168,26 @@ export function setMergeComponentSchema(
context: LogicContext,
action: LogicActionMergeComponentSchema,
) {
- const { component, data, path } = context;
+ const { component, data, path, row, result } = context;
const oldValue = get(data, path);
+ const safeData = escapeInterpolationDataStrings(data);
+ const safeRow = escapeInterpolationDataStrings(row);
+ const safeResult =
+ typeof result === 'string' ? escape(result) : escapeInterpolationDataStrings(result);
const schema = evaluate(
action.schemaDefinition,
- { ...context, value: {} },
+ {
+ ...context,
+ value: {},
+ data: safeData,
+ row: safeRow,
+ result: safeResult,
+ },
'schema',
false,
(evalContext: any) => {
evalContext.value = clone(oldValue);
- evalContext.result = context.result;
+ evalContext.result = safeResult;
},
);
const merged = assign({}, component, schema);
diff --git a/src/utils/utils.ts b/src/utils/utils.ts
index ab0fb7cd..09264965 100644
--- a/src/utils/utils.ts
+++ b/src/utils/utils.ts
@@ -1,4 +1,4 @@
-import { isBoolean, isEmpty, isObject, isPlainObject, isString } from 'lodash';
+import { escape, isBoolean, isEmpty, isObject, isPlainObject, isString, mapValues } from 'lodash';
import { EvaluatorOptions, Evaluator } from './Evaluator';
import { isComponentNestedDataType, normalizeContext } from './formUtil';
import {
@@ -53,6 +53,26 @@ export function unescapeHTML(str: string) {
return doc.documentElement.textContent;
}
+/**
+ * Recursively HTML-escape strings in submission-like objects so logic templates
+ * (e.g. dynamic labels) cannot treat user-entered values as HTML.
+ */
+export function escapeInterpolationDataStrings(obj: any): any {
+ if (obj === null || obj === undefined) {
+ return obj;
+ }
+ if (typeof obj === 'string') {
+ return escape(obj);
+ }
+ if (Array.isArray(obj)) {
+ return obj.map(escapeInterpolationDataStrings);
+ }
+ if (isPlainObject(obj)) {
+ return mapValues(obj, escapeInterpolationDataStrings);
+ }
+ return obj;
+}
+
export function attachResourceToDom(options: ResourceToDomOptions) {
const { name, formio, onload, rootElement, onerror } = options;
let { src } = options;