Repository: apostrophecms/sanitize-html
Pull request #668: Add `preserveEscapedAttributes` option to retain all attributes of escaped disallowed tags
Author: login: benelliott
Created: 2024-07-08T14:11:17Z | Merged: 2025-05-12T18:38:07Z

Fixes https://github.com/apostrophecms/sanitize-html/issues/540

<!-- Please don't delete this template -->

<!-- PULL REQUEST TEMPLATE -->
<!-- (Update "[ ]" to "[x]" to check a box) -->

## Summary

*Summarize the changes briefly, including which issue/ticket this resolves. If it closes an existing Github issue, include "Closes #[issue number]"*

Add `preserveEscapedAttributes` option to allow attributes on escaped disallowed tags to be retained. Closes #540 

## What are the specific steps to test this change?

You can try the new behaviour by setting `preserveEscapedAttributes` to `true` when `disallowedTagsMode` is `'escape'` or `'recursiveEscape'`.

Added unit tests to PR. 

## What kind of change does this PR introduce?
*(Check at least one)*

- [ ] Bug fix
- [x] New feature
- [ ] Refactor
- [ ] Documentation
- [ ] Build-related changes
- [ ] Other

## Make sure the PR fulfills these requirements:

- [x] It includes a) the existing issue ID being resolved, b) a convincing reason for adding this feature, or c) a clear description of the bug it resolves
- [ ] The changelog is updated
- [x] Related documentation has been updated
- [x] Related tests have been updated

If adding a new feature without an already open issue, it's best to open a **feature request issue** first and wait for approval before working on it.

**Other information:**


Linked issue #540: Allow escaping of more than just tags
## The problem to solve

I would like the sanitised string to be displayed exactly as input. Discarding text is not helpful, as it will be missing.
`options.disallowedTagsMode = 'recursiveEscape'` already exists to escape tags instead of removing them. Attributes, etc. are still removed instead of escaped though.

User Story:
1. We start with (mostly) everything disallowed.
2. User attempts to input HTML, sees the resulting text and copy&pastes directly into a feature request to allow the tag/attribute/style/whatever they want and do not get.
3. (If the requested element can be safely allowed) we copy&paste the text directly into our mock project for TDD.
4. loop 2., 3.
5. Profit!

## Proposed solution

I, personally, would like a simple switch `options.disallowedEverythingMode: 'discard' | 'escape' | 'recursiveEscape'` or similar.

## Alternatives

Mirroring the lists and records of `options` akin to how the existing `options.disallowedTagsMode` mirrors `options.allowedTags` is probably the more sensible approach:
```
options.disallowedAttributesMode: 'discard' | 'escape' | 'recursiveEscape'
options.disallowedStylesMode: 'discard' | 'escape' | 'recursiveEscape'
<etc.>
```
Mayhap a synthetic "all the things" could be added as a function on top of those:
`function setDisallowedEverythingMode(mode: 'discard' | 'escape' | 'recursiveEscape') {/* set every flag to mode */}`

## Additional context

```
options: {
  allowedAttributes: {},
  allowedTags: [],
  disallowedTagsMode: 'recursiveEscape',
  nonTextTags: [],
  selfClosing: [],
}
```

Input: `"Cursed Project <img src=x onerror=alert('AllYourBaseAreBelongToUs!');>"`
Expected: `Cursed Project <img src=x onerror=alert('AllYourBaseAreBelongToUs!');>`
Displayed: `Cursed Project <img></img>`
Linked issue comments: user: login: boutell
created_at: 2022-02-22T13:40:24Z
body: I agree it doesn't make sense for recursiveEscape to keep tags (in escaped
form) and yet discard attributes, given that it's all being converted to
text anyway.

On Mon, Feb 21, 2022 at 8:50 AM Zsar ***@***.***> wrote:

> The problem to solve
>
> I would like the sanitised string to be displayed exactly as input.
> Discarding text is not helpful, as it will be missing.
> options.disallowedTagsMode = 'recursiveEscape' already exists to escape
> tags instead of removing them. Attributes, etc. are still removed instead
> of escaped though.
>
> User Story:
>
>    1. We start with (mostly) everything disallowed.
>    2. User attempts to input HTML, sees the resulting text and
>    copy&pastes directly into a feature request to allow the
>    tag/attribute/style/whatever they want and do not get.
>    3. (If the requested element can be safely allowed) we copy&paste the
>    text directly into our mock project for TDD.
>    4. loop 2., 3.
>    5. Profit!
>
> Proposed solution
>
> I, personally, would like a simple switch options.disallowedEverythingMode:
> 'discard' | 'escape' | 'recursiveEscape' or similar.
> Alternatives
>
> Mirroring the lists and records of options akin to how the existing
> options.disallowedTagsMode mirrors options.allowedTags is probably the
> more sensible approach:
>
> options.disallowedAttributesMode: 'discard' | 'escape' | 'recursiveEscape'
> options.disallowedStylesMode: 'discard' | 'escape' | 'recursiveEscape'
> <etc.>
>
> Mayhap a synthetic "all the things" could be added as a function on top of
> those:
> function setDisallowedEverythingMode(mode: 'discard' | 'escape' |
> 'recursiveEscape') {/* set every flag to mode */}
> Additional context
>
> options: {
>   allowedAttributes: {},
>   allowedTags: [],
>   disallowedTagsMode: 'recursiveEscape',
>   nonTextTags: [],
>   selfClosing: [],
> }
>
> Input: "Cursed Project <img src=x
> onerror=alert('AllYourBaseAreBelongToUs!');>"
> Expected: Cursed Project <img src=x
> onerror=alert('AllYourBaseAreBelongToUs!');>
> Displayed: Cursed Project <img></img>
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/apostrophecms/sanitize-html/issues/540>, or
> unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AAAH27MCVL3BBGDY2HF76RTU4I7J3ANCNFSM5O6U45NA>
> .
> Triage notifications on the go with GitHub Mobile for iOS
> <https://apps.apple.com/app/apple-store/id1477376905?ct=notification-email&mt=8&pt=524675>
> or Android
> <https://play.google.com/store/apps/details?id=com.github.android&referrer=utm_campaign%3Dnotification-email%26utm_medium%3Demail%26utm_source%3Dgithub>.
>
> You are receiving this because you are subscribed to this thread.Message
> ID: ***@***.***>
>


-- 

THOMAS BOUTELL | CHIEF TECHNOLOGY OFFICER
APOSTROPHECMS | apostrophecms.com | he/him/his

author_association: MEMBER
user: login: boutell
created_at: 2022-02-22T13:40:34Z
body: This would make a good community contribution.

On Tue, Feb 22, 2022 at 8:40 AM Tom Boutell ***@***.***> wrote:

> I agree it doesn't make sense for recursiveEscape to keep tags (in escaped
> form) and yet discard attributes, given that it's all being converted to
> text anyway.
>
> On Mon, Feb 21, 2022 at 8:50 AM Zsar ***@***.***> wrote:
>
>> The problem to solve
>>
>> I would like the sanitised string to be displayed exactly as input.
>> Discarding text is not helpful, as it will be missing.
>> options.disallowedTagsMode = 'recursiveEscape' already exists to escape
>> tags instead of removing them. Attributes, etc. are still removed instead
>> of escaped though.
>>
>> User Story:
>>
>>    1. We start with (mostly) everything disallowed.
>>    2. User attempts to input HTML, sees the resulting text and
>>    copy&pastes directly into a feature request to allow the
>>    tag/attribute/style/whatever they want and do not get.
>>    3. (If the requested element can be safely allowed) we copy&paste the
>>    text directly into our mock project for TDD.
>>    4. loop 2., 3.
>>    5. Profit!
>>
>> Proposed solution
>>
>> I, personally, would like a simple switch options.disallowedEverythingMode:
>> 'discard' | 'escape' | 'recursiveEscape' or similar.
>> Alternatives
>>
>> Mirroring the lists and records of options akin to how the existing
>> options.disallowedTagsMode mirrors options.allowedTags is probably the
>> more sensible approach:
>>
>> options.disallowedAttributesMode: 'discard' | 'escape' | 'recursiveEscape'
>> options.disallowedStylesMode: 'discard' | 'escape' | 'recursiveEscape'
>> <etc.>
>>
>> Mayhap a synthetic "all the things" could be added as a function on top
>> of those:
>> function setDisallowedEverythingMode(mode: 'discard' | 'escape' |
>> 'recursiveEscape') {/* set every flag to mode */}
>> Additional context
>>
>> options: {
>>   allowedAttributes: {},
>>   allowedTags: [],
>>   disallowedTagsMode: 'recursiveEscape',
>>   nonTextTags: [],
>>   selfClosing: [],
>> }
>>
>> Input: "Cursed Project <img src=x
>> onerror=alert('AllYourBaseAreBelongToUs!');>"
>> Expected: Cursed Project <img src=x
>> onerror=alert('AllYourBaseAreBelongToUs!');>
>> Displayed: Cursed Project <img></img>
>>
>> —
>> Reply to this email directly, view it on GitHub
>> <https://github.com/apostrophecms/sanitize-html/issues/540>, or
>> unsubscribe
>> <https://github.com/notifications/unsubscribe-auth/AAAH27MCVL3BBGDY2HF76RTU4I7J3ANCNFSM5O6U45NA>
>> .
>> Triage notifications on the go with GitHub Mobile for iOS
>> <https://apps.apple.com/app/apple-store/id1477376905?ct=notification-email&mt=8&pt=524675>
>> or Android
>> <https://play.google.com/store/apps/details?id=com.github.android&referrer=utm_campaign%3Dnotification-email%26utm_medium%3Demail%26utm_source%3Dgithub>.
>>
>> You are receiving this because you are subscribed to this thread.Message
>> ID: ***@***.***>
>>
>
>
> --
>
> THOMAS BOUTELL | CHIEF TECHNOLOGY OFFICER
> APOSTROPHECMS | apostrophecms.com | he/him/his
>


-- 

THOMAS BOUTELL | CHIEF TECHNOLOGY OFFICER
APOSTROPHECMS | apostrophecms.com | he/him/his

author_association: MEMBER
user: login: stale[bot]
created_at: 2022-04-24T06:41:12Z
body: This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

author_association: NONE

Diff:
diff --git a/README.md b/README.md
index 903c95e..6600332 100644
--- a/README.md
+++ b/README.md
@@ -745,7 +745,7 @@ This will transform `<disallowed>content <allowed>content</allowed> </disallowed
 
 #### Escape the disallowed tag and all its children even for allowed tags.
 
-if you set `disallowedTagsMode` to `recursiveEscape`, disallowed tag and its children will be escaped even for allowed tags
+if you set `disallowedTagsMode` to `recursiveEscape`, disallowed tags and their children will be escaped even for allowed tags:
 
 ```js
 disallowedTagsMode: `recursiveEscape`
@@ -753,6 +753,15 @@ disallowedTagsMode: `recursiveEscape`
 
 This will transform `<disallowed>hello<p>world</p></disallowed>` to `&lt;disallowed&gt;hello&lt;p&gt;world&lt;/p&gt;&lt;/disallowed&gt;`
 
+#### Escape the disallowed tag, including all its attributes.
+
+By default, attributes are not preserved when tags are escaped. You can set `preserveEscapedAttributes` to `true` to 
+keep the attributes, which will also be escaped and therefore have no effect on the browser.
+
+```js
+preserveEscapedAttributes: true
+```
+
 ### Ignore style attribute contents
 
 Instead of discarding faulty style attributes, you can allow them by disabling the parsing of style attributes:
diff --git a/index.js b/index.js
index ebb741e..348ec00 100644
--- a/index.js
+++ b/index.js
@@ -300,7 +300,14 @@ function sanitizeHtml(html, options, _recursing) {
         }
       }
 
-      if (!allowedAttributesMap || has(allowedAttributesMap, name) || allowedAttributesMap['*']) {
+      const isBeingEscaped = skip && (options.disallowedTagsMode === 'escape' || options.disallowedTagsMode === 'recursiveEscape');
+      const shouldPreserveEscapedAttributes = isBeingEscaped && options.preserveEscapedAttributes;
+
+      if (shouldPreserveEscapedAttributes) {
+        each(attribs, function(value, a) {
+          result += ' ' + a + '="' + escapeHtml((value || ''), true) + '"';
+        });
+      } else if (!allowedAttributesMap || has(allowedAttributesMap, name) || allowedAttributesMap['*']) {
         each(attribs, function(value, a) {
           if (!VALID_HTML_ATTRIBUTE_NAME.test(a)) {
             // This prevents part of an attribute name in the output from being
@@ -923,7 +930,8 @@ sanitizeHtml.defaults = {
   allowedSchemesAppliedToAttributes: [ 'href', 'src', 'cite' ],
   allowProtocolRelative: true,
   enforceHtmlBoundary: false,
-  parseStyleAttributes: true
+  parseStyleAttributes: true,
+  preserveEscapedAttributes: false
 };
 
 sanitizeHtml.simpleTransform = function(newTagName, newAttribs, merge) {
diff --git a/test/test.js b/test/test.js
index efd02ce..c43587e 100644
--- a/test/test.js
+++ b/test/test.js
@@ -1811,4 +1811,41 @@ describe('sanitizeHtml', function() {
     );
     assert.equal(sanitizedHtml, expectedOutput);
   });
+  it('should not preserve attributes on escaped disallowed tags when `preserveEscapedAttributes` is false', () => {
+    const inputHtml = '<div class="foo">Some Text</div>';
+    const expectedOutput = '&lt;div&gt;Some Text&lt;/div&gt;';
+    const sanitizedHtml = sanitizeHtml(inputHtml, {
+      allowedTags: [],
+      disallowedTagsMode: 'escape',
+      preserveEscapedAttributes: false
+    });
+
+    assert.equal(sanitizedHtml, expectedOutput);
+  });
+  it('should preserve attributes on escaped disallowed tags when `preserveEscapedAttributes` is true', () => {
+    const inputHtml = '<div class="foo">Some Text</div>';
+    const expectedOutput = '&lt;div class="foo"&gt;Some Text&lt;/div&gt;';
+    const sanitizedHtml = sanitizeHtml(inputHtml, {
+      allowedTags: [],
+      disallowedTagsMode: 'escape',
+      preserveEscapedAttributes: true
+    });
+
+    assert.equal(sanitizedHtml, expectedOutput);
+  });
+  it('should ignore the `preserveEscapedAttributes` option when discarding diallowed tags (rather than escaping)', () => {
+    const inputHtml = '<div class="foo">Some Text</div>';
+    const sanitizedHtmlPreservedAttrsTrue = sanitizeHtml(inputHtml, {
+      allowedTags: [],
+      disallowedTagsMode: 'discard',
+      preserveEscapedAttributes: true
+    });
+    const sanitizedHtmlPreservedAttrsFalse = sanitizeHtml(inputHtml, {
+      allowedTags: [],
+      disallowedTagsMode: 'discard',
+      preserveEscapedAttributes: false
+    });
+
+    assert.equal(sanitizedHtmlPreservedAttrsTrue, sanitizedHtmlPreservedAttrsFalse);
+  });
 });


Reviews:
user: login: BoDonkey
body: At first blush, this looks good! Can you add another test to show that the `preserveEscapedAttributes` functions correctly with the other `disallowedTagsMode`, please? Thanks!
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2024-07-08T19:28:51Z
user: login: BoDonkey
body: Nice last test. I would prefer that the docs include an example, but I'm not going to require it. Sorry that this got overlooked for a time.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2024-07-23T09:25:26Z
user: login: boutell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T12:49:04Z
user: login: boutell
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T12:49:44Z
user: login: boutell
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-12T12:49:51Z

Review comments:
diff_hunk: @@ -745,14 +745,23 @@ This will transform `<disallowed>content <allowed>content</allowed> </disallowed
 
 #### Escape the disallowed tag and all its children even for allowed tags.
 
-if you set `disallowedTagsMode` to `recursiveEscape`, disallowed tag and its children will be escaped even for allowed tags
+if you set `disallowedTagsMode` to `recursiveEscape`, disallowed tags and their children will be escaped even for allowed tags:
path: README.md
user: login: boutell
body: Unrelated grammar fix
created_at: 2025-05-12T12:49:04Z
author_association: MEMBER
diff_hunk: @@ -745,14 +745,23 @@ This will transform `<disallowed>content <allowed>content</allowed> </disallowed
 
 #### Escape the disallowed tag and all its children even for allowed tags.
 
-if you set `disallowedTagsMode` to `recursiveEscape`, disallowed tag and its children will be escaped even for allowed tags
+if you set `disallowedTagsMode` to `recursiveEscape`, disallowed tags and their children will be escaped even for allowed tags:
 
 ```js
 disallowedTagsMode: `recursiveEscape`
 ```
 
 This will transform `<disallowed>hello<p>world</p></disallowed>` to `&lt;disallowed&gt;hello&lt;p&gt;world&lt;/p&gt;&lt;/disallowed&gt;`
 
+#### Escape the disallowed tag, including all its attributes.
+
+By default, attributes are not preserved when tags are escaped. You can set `preserveEscapedAttributes` to `true` to 
path: README.md
user: login: boutell
body: I updated this language. Once a tag and its attributes are escaped, there is no risk. Also it's not just "disallowed attributes," it's all of the attributes. It's not a "real tag" anymore so no need to make it sound dangerous.
created_at: 2025-05-12T12:49:43Z
author_association: MEMBER

PR comments:
user: login: benelliott
created_at: 2024-07-09T09:00:18Z
body: Thanks @BoDonkey, I've added another test case.
author_association: CONTRIBUTOR
user: login: stale[bot]
created_at: 2025-04-26T02:55:29Z
body: This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

author_association: NONE
user: login: benelliott
created_at: 2025-04-28T09:10:18Z
body: bump
author_association: CONTRIBUTOR
user: login: BoDonkey
created_at: 2025-04-28T10:14:05Z
body: Not sure how this keeps falling between the cracks for me. Apologies.
Overall this looks good to me. Thanks for keeping it opt-in and safe by default!

I would suggest slightly improving the README docs to emphasize that `preserveEscapedAttributes: true` is intended mainly for development or trusted workflows, and may preserve dangerous attributes as escaped text. Otherwise, I'm fine with approving this.

Are you able to resolve the merge conflicts?
author_association: CONTRIBUTOR
user: login: benelliott
created_at: 2025-05-08T09:45:32Z
body: Hi @BoDonkey, no worries. I've rebased on main and added your suggestion to the readme,
author_association: CONTRIBUTOR
user: login: BoDonkey
created_at: 2025-05-08T13:16:36Z
body: I want to run this by one more set of eyes. Thanks so much for sticking in there!
author_association: CONTRIBUTOR
user: login: boutell
created_at: 2025-05-12T12:51:16Z
body: Thanks Bob. I took a look and updated some README language (there is no
risk, so I toned down the language) and then approved the PR.

On Thu, May 8, 2025 at 9:17 AM Robert Means ***@***.***>
wrote:

> @BoDonkey <https://github.com/BoDonkey> requested your review on: #668
> <https://github.com/apostrophecms/sanitize-html/pull/668> Add
> preserveEscapedAttributes option to retain all attributes of escaped
> disallowed tags.
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/apostrophecms/sanitize-html/pull/668#event-17573892290>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AAAH27KP266BLPZEUPDGVYL25NKNJAVCNFSM6AAAAAB344QDIOVHI2DSMVQWIX3LMV45UABCJFZXG5LFIV3GK3TUJZXXI2LGNFRWC5DJN5XDWMJXGU3TGOBZGIZDSMA>
> .
> You are receiving this because your review was requested.Message ID:
> ***@***.***>
>


-- 

THOMAS BOUTELL | CHIEF TECHNOLOGY OFFICER
APOSTROPHECMS | apostrophecms.com | he/him/his

author_association: MEMBER