From 2ecdb6f5c265020ca5bbb94a01fb542d62d1d3d5 Mon Sep 17 00:00:00 2001 From: Niels-Henrik Sejthen Date: Thu, 12 Nov 2026 14:54:45 +0100 Subject: [PATCH 1/3] Protect email and agent sanitizer compatibility during the platform dependency upgrade Fixes #13994 AB#642579 Add four Email Message regressions through the existing public Create overload and two Agent Message regressions through the existing internal wrapper. Verify basic formatting and encoded text survive while styles, event handlers, and MathML annotation-xml foreign content are removed. Exercise both text/html and application/xhtml+xml integration encodings. Protect the existing plain-text path and explicit HTML sanitization opt-out. The application references the stable AppHtmlSanitizer DotNet alias rather than AngleSharp APIs. Compilation and runtime evidence demonstrate that the coordinated platform dependency upgrade requires no production AL change, new API, data migration, permissions change, or feature flag. Keep this application change regression-only. Validation: - Compiled Email Test, Agent Test and their dependency closure with the repository's internal-module CodeCop ruleset and patched platform add-in. - No analyzer diagnostics in the two changed files; four AA0139 warnings are confined to unchanged Email test/library sources. - Executed exactly the six new methods using the modern AL Test Runner in a separately provisioned database/NST with the patched dependencies. All six methods and both test codeunits reported success. - Preserved the genuine JSON results and generated TRX diagnostics from those results. Full Email/Agent suites were not executed. - The dedicated NST was stopped afterwards. Existing shared NST service states and executable/config paths were unchanged. The component-governance fix is in the separate platform change. These tests do not claim the old restrictive email allowlist was exploitable. Affected release branches still need platform backports and CG rescans. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4880f3b9-e4b5-4124-bfaf-74629b812e2f --- .../src/SDK/AgentMessageTest.Codeunit.al | 23 +++++++ .../src/EmailMessageUnitTest.Codeunit.al | 62 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/src/System Application/Test/Agent/src/SDK/AgentMessageTest.Codeunit.al b/src/System Application/Test/Agent/src/SDK/AgentMessageTest.Codeunit.al index 01c20f6c474..afcd4148590 100644 --- a/src/System Application/Test/Agent/src/SDK/AgentMessageTest.Codeunit.al +++ b/src/System Application/Test/Agent/src/SDK/AgentMessageTest.Codeunit.al @@ -1329,6 +1329,29 @@ codeunit 133963 "Agent Message Test" Assert.AreEqual('last-file.txt', LastAttachment."File Name", 'Last attachment file name should match'); end; + [Test] + procedure SanitizeMessagePreservesBasicFormatting() + var + AgentTaskMsgBuilderImpl: Codeunit "Agent Task Msg. Builder Impl."; + Body: Text; + begin + Body := '

Hello world
4 < 5

'; + + Assert.AreEqual('

Hello world
4 < 5

', AgentTaskMsgBuilderImpl.SanitizeMessage(Body), 'Agent messages must retain basic formatting and encoded text without styling or event handlers.'); + end; + + [Test] + procedure SanitizeMessageRemovesMathmlIntegrationPoints() + var + AgentTaskMsgBuilderImpl: Codeunit "Agent Task Msg. Builder Impl."; + Body: Text; + begin + Body := '

Safe

<a encoding="">'; + + Assert.AreEqual('

Safe

', AgentTaskMsgBuilderImpl.SanitizeMessage(Body), 'Foreign HTML integration points and their contents must not survive agent message sanitization.'); + Assert.AreEqual('

Safe

', AgentTaskMsgBuilderImpl.SanitizeMessage(Body.Replace('text/html', 'application/xhtml+xml')), 'The XHTML encoding must receive the same sanitization policy.'); + end; + [Test] procedure ContentTypeFromFilenameForKnownExtensions() var diff --git a/src/System Application/Test/Email/src/EmailMessageUnitTest.Codeunit.al b/src/System Application/Test/Email/src/EmailMessageUnitTest.Codeunit.al index 0c6020f4874..a43cd0357a9 100644 --- a/src/System Application/Test/Email/src/EmailMessageUnitTest.Codeunit.al +++ b/src/System Application/Test/Email/src/EmailMessageUnitTest.Codeunit.al @@ -77,6 +77,68 @@ codeunit 134689 "Email Message Unit Test" Assert.IsTrue(Message.Attachments_Next() = 0, 'No attachments were expected'); end; + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + procedure CreateMessageWithSanitizationPreservesBasicFormatting() + var + Message: Codeunit "Email Message"; + begin + PermissionsMock.Set('Email Edit'); + + Message.Create('recipient@test.com', 'Test subject', '

Hello world
4 < 5

', true, true); + + Assert.AreEqual('

Hello world
4 < 5

', Message.GetBody(), 'Sanitization must preserve basic formatting and encoded text but remove styling and event handlers.'); + end; + + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + procedure CreateMessageWithSanitizationRemovesMathmlIntegrationPoints() + var + Message: Codeunit "Email Message"; + Body: Text; + begin + PermissionsMock.Set('Email Edit'); + Body := '

Safe

<a encoding="">'; + + Message.Create('recipient@test.com', 'Test subject', Body, true, true); + Assert.AreEqual('

Safe

', Message.GetBody(), 'Foreign HTML integration points and their contents must not survive email sanitization.'); + + Body := Body.Replace('text/html', 'application/xhtml+xml'); + Message.Create('recipient@test.com', 'Test subject', Body, true, true); + Assert.AreEqual('

Safe

', Message.GetBody(), 'The XHTML encoding must receive the same sanitization policy.'); + end; + + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + procedure CreatePlainTextMessageDoesNotApplyHtmlSanitization() + var + Message: Codeunit "Email Message"; + Body: Text; + begin + PermissionsMock.Set('Email Edit'); + Body := '

Literal text

& more text'; + + Message.Create('recipient@test.com', 'Test subject', Body, false, true); + + Assert.AreEqual(Body, Message.GetBody(), 'The sanitization option must not alter a plain-text message.'); + Assert.IsFalse(Message.IsBodyHTMLFormatted(), 'The message must remain plain text.'); + end; + + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + procedure CreateHtmlMessageCanKeepSanitizationDisabled() + var + Message: Codeunit "Email Message"; + Body: Text; + begin + PermissionsMock.Set('Email Edit'); + Body := '

Formatted text

'; + + Message.Create('recipient@test.com', 'Test subject', Body, true, false); + + Assert.AreEqual(Body, Message.GetBody(), 'The dependency upgrade must not implicitly enable the optional sanitizer.'); + end; + [Test] [TransactionModel(TransactionModel::AutoRollback)] procedure CreateMessageWithCCAndBCCTest() From beff1ae3b549b0dc241c2ebcc09a3a8beb262c03 Mon Sep 17 00:00:00 2001 From: Niels-Henrik Sejthen Date: Fri, 13 Nov 2026 09:27:53 +0100 Subject: [PATCH 2/3] Structure sanitizer regressions as readable business scenarios AB#642579 Address both review threads in PR 13995 by adding SCENARIO, GIVEN, WHEN and THEN tags to all six new tests. Make agent sanitization an explicit action before its assertions and retain the original integration-point payload for both HTML encodings. Preserve test names, expectations, plain-text behavior and the explicit sanitization opt-out. Rebuilt Email Test and Agent Test with the existing internal-module CodeCop rules; no diagnostics in changed files. All six selected methods and both codeunits passed again in the task-owned patched NST, with genuine JSON and derived TRX results. The isolated service was returned to its stopped state. No production application or shared-environment changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4880f3b9-e4b5-4124-bfaf-74629b812e2f --- .../src/SDK/AgentMessageTest.Codeunit.al | 30 +++++++++++++++++-- .../src/EmailMessageUnitTest.Codeunit.al | 26 ++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/System Application/Test/Agent/src/SDK/AgentMessageTest.Codeunit.al b/src/System Application/Test/Agent/src/SDK/AgentMessageTest.Codeunit.al index afcd4148590..7c9aca0d5a6 100644 --- a/src/System Application/Test/Agent/src/SDK/AgentMessageTest.Codeunit.al +++ b/src/System Application/Test/Agent/src/SDK/AgentMessageTest.Codeunit.al @@ -1334,10 +1334,18 @@ codeunit 133963 "Agent Message Test" var AgentTaskMsgBuilderImpl: Codeunit "Agent Task Msg. Builder Impl."; Body: Text; + SanitizedBody: Text; begin + // [SCENARIO] Agent message sanitization preserves readable formatting and encoded text without active content. + + // [GIVEN] An HTML message body containing formatting, styling, and an event handler. Body := '

Hello world
4 < 5

'; - Assert.AreEqual('

Hello world
4 < 5

', AgentTaskMsgBuilderImpl.SanitizeMessage(Body), 'Agent messages must retain basic formatting and encoded text without styling or event handlers.'); + // [WHEN] The agent message body is sanitized. + SanitizedBody := AgentTaskMsgBuilderImpl.SanitizeMessage(Body); + + // [THEN] Formatting and encoded text survive, while styling and the event handler are removed. + Assert.AreEqual('

Hello world
4 < 5

', SanitizedBody, 'Agent messages must retain basic formatting and encoded text without styling or event handlers.'); end; [Test] @@ -1345,11 +1353,27 @@ codeunit 133963 "Agent Message Test" var AgentTaskMsgBuilderImpl: Codeunit "Agent Task Msg. Builder Impl."; Body: Text; + SanitizedBody: Text; begin + // [SCENARIO] Sanitized agent messages exclude foreign integration-point content for both HTML encodings. + + // [GIVEN] A safe paragraph followed by a MathML HTML integration-point payload. Body := '

Safe

<a encoding="">'; - Assert.AreEqual('

Safe

', AgentTaskMsgBuilderImpl.SanitizeMessage(Body), 'Foreign HTML integration points and their contents must not survive agent message sanitization.'); - Assert.AreEqual('

Safe

', AgentTaskMsgBuilderImpl.SanitizeMessage(Body.Replace('text/html', 'application/xhtml+xml')), 'The XHTML encoding must receive the same sanitization policy.'); + // [WHEN] The agent message body is sanitized. + SanitizedBody := AgentTaskMsgBuilderImpl.SanitizeMessage(Body); + + // [THEN] Only the safe paragraph remains. + Assert.AreEqual('

Safe

', SanitizedBody, 'Foreign HTML integration points and their contents must not survive agent message sanitization.'); + + // [GIVEN] The same payload uses the XHTML integration-point encoding. + Body := Body.Replace('text/html', 'application/xhtml+xml'); + + // [WHEN] The XHTML-encoded payload is sanitized. + SanitizedBody := AgentTaskMsgBuilderImpl.SanitizeMessage(Body); + + // [THEN] The encoding does not change the removal of foreign content. + Assert.AreEqual('

Safe

', SanitizedBody, 'The XHTML encoding must receive the same sanitization policy.'); end; [Test] diff --git a/src/System Application/Test/Email/src/EmailMessageUnitTest.Codeunit.al b/src/System Application/Test/Email/src/EmailMessageUnitTest.Codeunit.al index a43cd0357a9..c22dc833c60 100644 --- a/src/System Application/Test/Email/src/EmailMessageUnitTest.Codeunit.al +++ b/src/System Application/Test/Email/src/EmailMessageUnitTest.Codeunit.al @@ -83,10 +83,15 @@ codeunit 134689 "Email Message Unit Test" var Message: Codeunit "Email Message"; begin + // [SCENARIO] Sanitized HTML email retains readable formatting and encoded text without active content. + + // [GIVEN] An email editor and HTML containing formatting, styling, and an event handler. PermissionsMock.Set('Email Edit'); + // [WHEN] The HTML message is created with sanitization enabled. Message.Create('recipient@test.com', 'Test subject', '

Hello world
4 < 5

', true, true); + // [THEN] Formatting and encoded text survive, while styling and the event handler are removed. Assert.AreEqual('

Hello world
4 < 5

', Message.GetBody(), 'Sanitization must preserve basic formatting and encoded text but remove styling and event handlers.'); end; @@ -97,14 +102,25 @@ codeunit 134689 "Email Message Unit Test" Message: Codeunit "Email Message"; Body: Text; begin + // [SCENARIO] Sanitized email excludes foreign integration-point content for both HTML encodings. + + // [GIVEN] An email editor and a safe paragraph followed by a MathML HTML integration-point payload. PermissionsMock.Set('Email Edit'); Body := '

Safe

<a encoding="">'; + // [WHEN] The HTML message is created with sanitization enabled. Message.Create('recipient@test.com', 'Test subject', Body, true, true); + + // [THEN] Only the safe paragraph remains. Assert.AreEqual('

Safe

', Message.GetBody(), 'Foreign HTML integration points and their contents must not survive email sanitization.'); + // [GIVEN] The same payload uses the XHTML integration-point encoding. Body := Body.Replace('text/html', 'application/xhtml+xml'); + + // [WHEN] The XHTML-encoded payload is sanitized during message creation. Message.Create('recipient@test.com', 'Test subject', Body, true, true); + + // [THEN] The encoding does not change the removal of foreign content. Assert.AreEqual('

Safe

', Message.GetBody(), 'The XHTML encoding must receive the same sanitization policy.'); end; @@ -115,11 +131,16 @@ codeunit 134689 "Email Message Unit Test" Message: Codeunit "Email Message"; Body: Text; begin + // [SCENARIO] Plain-text email treats markup as literal content even when sanitization is requested. + + // [GIVEN] An email editor and literal text containing markup and an ampersand. PermissionsMock.Set('Email Edit'); Body := '

Literal text

& more text'; + // [WHEN] A plain-text message is created with the sanitization option enabled. Message.Create('recipient@test.com', 'Test subject', Body, false, true); + // [THEN] The body is unchanged and the message remains plain text. Assert.AreEqual(Body, Message.GetBody(), 'The sanitization option must not alter a plain-text message.'); Assert.IsFalse(Message.IsBodyHTMLFormatted(), 'The message must remain plain text.'); end; @@ -131,11 +152,16 @@ codeunit 134689 "Email Message Unit Test" Message: Codeunit "Email Message"; Body: Text; begin + // [SCENARIO] Email callers can retain HTML styling by explicitly disabling sanitization. + + // [GIVEN] An email editor and an HTML body with styling. PermissionsMock.Set('Email Edit'); Body := '

Formatted text

'; + // [WHEN] The HTML message is created with sanitization disabled. Message.Create('recipient@test.com', 'Test subject', Body, true, false); + // [THEN] The HTML body, including its styling, is preserved. Assert.AreEqual(Body, Message.GetBody(), 'The dependency upgrade must not implicitly enable the optional sanitizer.'); end; From 788753211a2472449e14a856eb6771150090571c Mon Sep 17 00:00:00 2001 From: Niels-Henrik Sejthen Date: Fri, 13 Nov 2026 11:19:16 +0100 Subject: [PATCH 3/3] Split HTML and XHTML sanitizer regression scenarios Address the two follow-up testing comments on microsoft/BCApps#13995. Each MathML encoding case now has an independent procedure, local payload, SCENARIO/GIVEN/WHEN/THEN structure, and one sanitizer action. A failure in the HTML case no longer prevents execution or identification of the XHTML case. Keep the original HTML test names and add explicit XHTML tests in Email Message Unit Test and Agent Message Test. Preserve the original sanitizer expectations, Email permission setup and AutoRollback transaction model, and Agent action/result separation. No production AL code, public API, manifest, dependency, permission or schema changes. Validation: - Recompiled Email Test and Agent Test from an isolated PR worktree with the repository's internal.module.ruleset.json CodeCop rules. - Executed all eight sanitizer regressions against the task-owned NST containing the patched platform dependencies: five Email and three Agent methods passed, and both test codeunits reported success. - Retained genuine AL Test Runner JSON and derived TRX diagnostics. - Verified exactly one WHEN in each of the eight sanitizer procedures and a clean git diff --check. - Restored the task-owned NST's stopped state and verified existing NST states and executable/config paths were unchanged. Left the unrelated active BcApps branch and its untracked files untouched. AB#642579 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4880f3b9-e4b5-4124-bfaf-74629b812e2f --- .../src/SDK/AgentMessageTest.Codeunit.al | 18 ++++++++++++++---- .../src/EmailMessageUnitTest.Codeunit.al | 19 +++++++++++++++---- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/System Application/Test/Agent/src/SDK/AgentMessageTest.Codeunit.al b/src/System Application/Test/Agent/src/SDK/AgentMessageTest.Codeunit.al index 7c9aca0d5a6..6cda705cf98 100644 --- a/src/System Application/Test/Agent/src/SDK/AgentMessageTest.Codeunit.al +++ b/src/System Application/Test/Agent/src/SDK/AgentMessageTest.Codeunit.al @@ -1355,7 +1355,7 @@ codeunit 133963 "Agent Message Test" Body: Text; SanitizedBody: Text; begin - // [SCENARIO] Sanitized agent messages exclude foreign integration-point content for both HTML encodings. + // [SCENARIO] Sanitized agent messages exclude foreign integration-point content using the HTML encoding. // [GIVEN] A safe paragraph followed by a MathML HTML integration-point payload. Body := '

Safe

<a encoding="">'; @@ -1365,14 +1365,24 @@ codeunit 133963 "Agent Message Test" // [THEN] Only the safe paragraph remains. Assert.AreEqual('

Safe

', SanitizedBody, 'Foreign HTML integration points and their contents must not survive agent message sanitization.'); + end; - // [GIVEN] The same payload uses the XHTML integration-point encoding. - Body := Body.Replace('text/html', 'application/xhtml+xml'); + [Test] + procedure SanitizeMessageRemovesXhtmlMathmlIntegrationPoints() + var + AgentTaskMsgBuilderImpl: Codeunit "Agent Task Msg. Builder Impl."; + Body: Text; + SanitizedBody: Text; + begin + // [SCENARIO] Sanitized agent messages exclude foreign integration-point content using the XHTML encoding. + + // [GIVEN] A safe paragraph followed by a MathML XHTML integration-point payload. + Body := '

Safe

<a encoding="">'; // [WHEN] The XHTML-encoded payload is sanitized. SanitizedBody := AgentTaskMsgBuilderImpl.SanitizeMessage(Body); - // [THEN] The encoding does not change the removal of foreign content. + // [THEN] Only the safe paragraph remains. Assert.AreEqual('

Safe

', SanitizedBody, 'The XHTML encoding must receive the same sanitization policy.'); end; diff --git a/src/System Application/Test/Email/src/EmailMessageUnitTest.Codeunit.al b/src/System Application/Test/Email/src/EmailMessageUnitTest.Codeunit.al index c22dc833c60..8c15d2d3d63 100644 --- a/src/System Application/Test/Email/src/EmailMessageUnitTest.Codeunit.al +++ b/src/System Application/Test/Email/src/EmailMessageUnitTest.Codeunit.al @@ -102,7 +102,7 @@ codeunit 134689 "Email Message Unit Test" Message: Codeunit "Email Message"; Body: Text; begin - // [SCENARIO] Sanitized email excludes foreign integration-point content for both HTML encodings. + // [SCENARIO] Sanitized email excludes foreign integration-point content using the HTML encoding. // [GIVEN] An email editor and a safe paragraph followed by a MathML HTML integration-point payload. PermissionsMock.Set('Email Edit'); @@ -113,14 +113,25 @@ codeunit 134689 "Email Message Unit Test" // [THEN] Only the safe paragraph remains. Assert.AreEqual('

Safe

', Message.GetBody(), 'Foreign HTML integration points and their contents must not survive email sanitization.'); + end; + + [Test] + [TransactionModel(TransactionModel::AutoRollback)] + procedure CreateMessageWithSanitizationRemovesXhtmlMathmlIntegrationPoints() + var + Message: Codeunit "Email Message"; + Body: Text; + begin + // [SCENARIO] Sanitized email excludes foreign integration-point content using the XHTML encoding. - // [GIVEN] The same payload uses the XHTML integration-point encoding. - Body := Body.Replace('text/html', 'application/xhtml+xml'); + // [GIVEN] An email editor and a safe paragraph followed by a MathML XHTML integration-point payload. + PermissionsMock.Set('Email Edit'); + Body := '

Safe

<a encoding="">'; // [WHEN] The XHTML-encoded payload is sanitized during message creation. Message.Create('recipient@test.com', 'Test subject', Body, true, true); - // [THEN] The encoding does not change the removal of foreign content. + // [THEN] Only the safe paragraph remains. Assert.AreEqual('

Safe

', Message.GetBody(), 'The XHTML encoding must receive the same sanitization policy.'); end;