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;