From 68b1b98c83fd39c566601ba246eada0088f72d45 Mon Sep 17 00:00:00 2001 From: David Olson <157068235+DavidOlson-Hyland@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:25:55 -0500 Subject: [PATCH] AAE-42904 Escape html in evaluated fields and variables for rich-text display widget (#11718) --- .../base-display-text.widget.ts | 4 +-- .../services/form-expression.service.spec.ts | 28 +++++++++++++++++++ .../form/services/form-expression.service.ts | 15 +++++++++- .../display-rich-text.widget.spec.ts | 4 ++- .../display-rich-text.widget.ts | 2 +- 5 files changed, 48 insertions(+), 5 deletions(-) diff --git a/lib/core/src/lib/form/components/widgets/base-display-text/base-display-text.widget.ts b/lib/core/src/lib/form/components/widgets/base-display-text/base-display-text.widget.ts index 0871f39f15..b3a6e93bea 100644 --- a/lib/core/src/lib/form/components/widgets/base-display-text/base-display-text.widget.ts +++ b/lib/core/src/lib/form/components/widgets/base-display-text/base-display-text.widget.ts @@ -68,8 +68,8 @@ export abstract class BaseDisplayTextWidgetComponent extends WidgetComponent imp protected abstract reevaluateExpressions(): void; private readonly destroyRef = inject(DestroyRef); - protected resolveExpressions(text: string): string { - return this.formExpressionService.resolveExpressions(this.field.form, text); + protected resolveExpressions(text: string, escapeHtml?: boolean): string { + return this.formExpressionService.resolveExpressions(this.field.form, text, escapeHtml); } private applyExpressions() { diff --git a/lib/core/src/lib/form/services/form-expression.service.spec.ts b/lib/core/src/lib/form/services/form-expression.service.spec.ts index ffd8eb1948..0758ee8d6c 100644 --- a/lib/core/src/lib/form/services/form-expression.service.spec.ts +++ b/lib/core/src/lib/form/services/form-expression.service.spec.ts @@ -268,6 +268,34 @@ describe('FormExpressionService', () => { expect(result).toBe('true'); }); + + describe('when escapeHtml is true', () => { + let getFieldSpy: jasmine.Spy; + + beforeEach(() => { + getFieldSpy = spyOn(formModel, 'getFieldById'); + }); + + it('should escape ampersands in resolved field values', () => { + getFieldSpy.and.returnValue({ id: 'f', value: 'a & b' } as any); + expect(service.resolveExpressions(formModel, '${field.f}', true)).toBe('a & b'); + }); + + it('should escape all HTML special characters in an XSS-like payload', () => { + getFieldSpy.and.returnValue({ id: 'f', value: `` } as any); + expect(service.resolveExpressions(formModel, '${field.f}', true)).toBe('<img src="x" onerror='alert(1)'>'); + }); + + it('should not escape characters when escapeHtml is false', () => { + getFieldSpy.and.returnValue({ id: 'f', value: 'bold' } as any); + expect(service.resolveExpressions(formModel, '${field.f}', false)).toBe('bold'); + }); + + it('should not escape characters when escapeHtml is omitted', () => { + getFieldSpy.and.returnValue({ id: 'f', value: 'bold' } as any); + expect(service.resolveExpressions(formModel, '${field.f}')).toBe('bold'); + }); + }); }); describe('getFieldDependencies', () => { diff --git a/lib/core/src/lib/form/services/form-expression.service.ts b/lib/core/src/lib/form/services/form-expression.service.ts index 706b4ed615..01faf4669c 100644 --- a/lib/core/src/lib/form/services/form-expression.service.ts +++ b/lib/core/src/lib/form/services/form-expression.service.ts @@ -27,7 +27,7 @@ export class FormExpressionService { private readonly VARIABLE_PREFIX = 'variable.'; private readonly VARIABLES_REGEX = /(?:field|variable)\.[a-zA-Z_$][a-zA-Z0-9_$]*/g; - resolveExpressions(form: FormModel, formField: string): string { + resolveExpressions(form: FormModel, formField: string, escapeHtml?: boolean): string { let result = formField || ''; const matches = result.match(this.GLOBAL_EXPRESSION_REGEX); @@ -43,6 +43,19 @@ export class FormExpressionService { } else if (typeof expressionResult !== 'string') { expressionResult = JSON.stringify(expressionResult); } + if (escapeHtml) { + expressionResult = expressionResult + .split('&') + .join('&') + .split('<') + .join('<') + .split('>') + .join('>') + .split('"') + .join('"') + .split("'") + .join('''); + } result = result.replace(match, expressionResult); } diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/display-rich-text.widget.spec.ts b/lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/display-rich-text.widget.spec.ts index 74a0d23e72..414b4ca337 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/display-rich-text.widget.spec.ts +++ b/lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/display-rich-text.widget.spec.ts @@ -137,13 +137,15 @@ describe('DisplayRichTextWidgetComponent', () => { }); it('should sanitize unsafe HTML', async () => { + mockRichTextParserService.parse.and.returnValue(''); widget.field = mockUnsafeFormField; fixture.detectChanges(); await fixture.whenStable(); const parsedHtmlEl = debugEl.query(By.css(cssSelector.parsedHTML)); - expect(parsedHtmlEl.nativeElement.innerHTML.includes('')).toBe(false); + expect(parsedHtmlEl.nativeElement.innerHTML.includes('img src="x"')).toBe(true); + expect(parsedHtmlEl.nativeElement.innerHTML.includes('onerror')).toBe(false); }); describe('expression evaluation', () => { diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/display-rich-text.widget.ts b/lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/display-rich-text.widget.ts index e6413b76f2..cf6f4a6193 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/display-rich-text.widget.ts +++ b/lib/process-services-cloud/src/lib/form/components/widgets/display-rich-text/display-rich-text.widget.ts @@ -90,7 +90,7 @@ export class DisplayRichTextWidgetComponent extends BaseDisplayTextWidgetCompone private applyExpressionsToBlocks(value: any): void { for (const block of value.blocks) { - block.data.text = this.resolveExpressions(block.data.text); + block.data.text = this.resolveExpressions(block.data.text, true); } this.field.value = value; }