From 680f26473199378ebdbf4fa726c2b1e72c49da39 Mon Sep 17 00:00:00 2001 From: Dawid Myslak Date: Wed, 14 Jan 2026 11:00:07 +0100 Subject: [PATCH] fix(Form Node): Preserve CSS child combinator selectors in custom styling (#24106) Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com> --- .../nodes-base/nodes/Form/test/utils.test.ts | 50 +++++++++++++++++++ packages/nodes-base/nodes/Form/utils/utils.ts | 4 +- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/nodes-base/nodes/Form/test/utils.test.ts b/packages/nodes-base/nodes/Form/test/utils.test.ts index 9cdd10cb006..afa184703b6 100644 --- a/packages/nodes-base/nodes/Form/test/utils.test.ts +++ b/packages/nodes-base/nodes/Form/test/utils.test.ts @@ -21,6 +21,7 @@ import { resolveRawData, isFormConnected, sanitizeHtml, + sanitizeCustomCss, validateResponseModeConfiguration, prepareFormFields, addFormResponseDataToReturnItem, @@ -347,6 +348,55 @@ describe('FormTrigger, sanitizeHtml', () => { }); }); +describe('sanitizeCustomCss', () => { + it('should return undefined for undefined input', () => { + expect(sanitizeCustomCss(undefined)).toBeUndefined(); + }); + + it('should return undefined for empty string', () => { + expect(sanitizeCustomCss('')).toBeUndefined(); + }); + + it('should preserve CSS child combinator selectors (>)', () => { + const css = '#n8n-form > div.form-header > p { text-align: left; }'; + expect(sanitizeCustomCss(css)).toBe(css); + }); + + it('should preserve CSS adjacent sibling selectors (+)', () => { + const css = 'h1 + p { margin-top: 0; }'; + expect(sanitizeCustomCss(css)).toBe(css); + }); + + it('should preserve CSS general sibling selectors (~)', () => { + const css = 'h1 ~ p { color: gray; }'; + expect(sanitizeCustomCss(css)).toBe(css); + }); + + it('should remove script tags from CSS', () => { + const css = '.container { color: red; }'; + expect(sanitizeCustomCss(css)).toBe('.container { color: red; }'); + }); + + it('should remove style closing tags that could break out of style block', () => { + const css = '.container { color: red; }'; + expect(sanitizeCustomCss(css)).toBe('.container { color: red; }'); + }); + + it('should preserve url() in CSS', () => { + const css = '.bg { background-image: url(https://example.com/image.png); }'; + expect(sanitizeCustomCss(css)).toBe(css); + }); + + it('should preserve complex CSS with multiple selectors and properties', () => { + const css = ` + #n8n-form > div.form-header > p { text-align: left; } + .form-container > .input-group + .input-group { margin-top: 1rem; } + button:hover { background-color: #0056b3; } + `; + expect(sanitizeCustomCss(css)).toBe(css); + }); +}); + describe('FormTrigger, formWebhook', () => { const executeFunctions = mock(); executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any); diff --git a/packages/nodes-base/nodes/Form/utils/utils.ts b/packages/nodes-base/nodes/Form/utils/utils.ts index 5e320c45f9f..2651c6f92c2 100644 --- a/packages/nodes-base/nodes/Form/utils/utils.ts +++ b/packages/nodes-base/nodes/Form/utils/utils.ts @@ -131,8 +131,8 @@ export function sanitizeCustomCss(css: string | undefined): string | undefined { return sanitize(css, { allowedTags: [], // No HTML tags allowed allowedAttributes: {}, // No attributes allowed - // This ensures we're only keeping the text content - // which should be the CSS, while removing any HTML/script tags + // Decode HTML entities that sanitize-html encodes, as they break CSS selectors like ">" + textFilter: (text) => text.replace(/>/g, '>').replace(/</g, '<').replace(/&/g, '&'), }); }