From a960fcbbcf5416c506c2a29b5ffac2b8cf6d18fd Mon Sep 17 00:00:00 2001 From: Istvan Soos Date: Tue, 28 Jul 2026 12:18:13 +0200 Subject: [PATCH 1/2] Copy google/dart-neats#402 into pkg/sanitize_html and exempt callout-box --- CHANGELOG.md | 3 +- app/lib/shared/markdown.dart | 2 + app/lib/shared/versions.dart | 2 +- pkg/_pub_shared/lib/dartdoc/dartdoc_page.dart | 3 + pkg/sanitize_html/CHANGELOG.md | 41 +++ pkg/sanitize_html/LICENSE | 202 +++++++++++ pkg/sanitize_html/README.md | 39 +++ pkg/sanitize_html/example/main.dart | 20 ++ pkg/sanitize_html/lib/sanitize_html.dart | 87 +++++ .../lib/src/sane_html_validator.dart | 318 ++++++++++++++++++ pkg/sanitize_html/pubspec.yaml | 20 ++ .../test/sanitize_html_test.dart | 203 +++++++++++ pubspec.lock | 8 - pubspec.yaml | 1 + 14 files changed, 939 insertions(+), 10 deletions(-) create mode 100644 pkg/sanitize_html/CHANGELOG.md create mode 100644 pkg/sanitize_html/LICENSE create mode 100644 pkg/sanitize_html/README.md create mode 100644 pkg/sanitize_html/example/main.dart create mode 100644 pkg/sanitize_html/lib/sanitize_html.dart create mode 100644 pkg/sanitize_html/lib/src/sane_html_validator.dart create mode 100644 pkg/sanitize_html/pubspec.yaml create mode 100644 pkg/sanitize_html/test/sanitize_html_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 154882b4da..61cb90a82b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,9 @@ Important changes to data models, configuration, and migrations between each AppEngine version, listed here to ease deployment and troubleshooting. ## Next Release (replace with git tag when deployed) - * Bump runtimeVersion to `2026.07.23`. + * Bump runtimeVersion to `2026.07.28`. * Upgraded stable Flutter analysis SDK to `3.44.7`. + * Copied `google/dart-neats#402` into `pkg/sanitize_html`. ## `20260714t095300-all` * Bump runtimeVersion to `2026.07.10`. diff --git a/app/lib/shared/markdown.dart b/app/lib/shared/markdown.dart index a5b34342aa..780850a2ff 100644 --- a/app/lib/shared/markdown.dart +++ b/app/lib/shared/markdown.dart @@ -7,6 +7,7 @@ import 'package:html/dom_parsing.dart' as html_parsing; import 'package:html/parser.dart' as html_parser; import 'package:logging/logging.dart'; import 'package:markdown/markdown.dart' as m; +import 'package:pub_dev/dartdoc/dartdoc_page.dart' show dartdocCustomElements; import 'package:pub_dev/frontend/request_context.dart'; import 'package:pub_dev/frontend/static_files.dart'; import 'package:pub_dev/service/image_proxy/backend.dart'; @@ -101,6 +102,7 @@ String _renderSafeHtml( // Renders the sanitized HTML. final html = sanitizeHtml( processedHtml, + allowElements: dartdocCustomElements.contains, allowElementId: (String id) => !disableHashIds, // TODO: Use a denylist for ids used by pub site allowClassName: (String cn) { diff --git a/app/lib/shared/versions.dart b/app/lib/shared/versions.dart index a32244d43d..ea5b0f77a2 100644 --- a/app/lib/shared/versions.dart +++ b/app/lib/shared/versions.dart @@ -24,7 +24,7 @@ final RegExp runtimeVersionPattern = RegExp(r'^\d{4}\.\d{2}\.\d{2}$'); /// when the version switch happens. const _acceptedRuntimeVersions = [ // The current [runtimeVersion]. - '2026.07.23', + '2026.07.28', // Fallback runtime versions. '2026.07.10', '2026.07.02', diff --git a/pkg/_pub_shared/lib/dartdoc/dartdoc_page.dart b/pkg/_pub_shared/lib/dartdoc/dartdoc_page.dart index b81e57e2a5..fc3b084e30 100644 --- a/pkg/_pub_shared/lib/dartdoc/dartdoc_page.dart +++ b/pkg/_pub_shared/lib/dartdoc/dartdoc_page.dart @@ -11,6 +11,8 @@ import 'package:sanitize_html/sanitize_html.dart'; part 'dartdoc_page.g.dart'; +final dartdocCustomElements = {'CALLOUT-BOX'}; + /// Generates a random nonce used for image proxying markers. /// /// This makes it practically impossible for an attacker to guess the imageProxyNonce @@ -157,6 +159,7 @@ final class DartDocPage { } final sanitized = sanitizeHtml( html, + allowElements: dartdocCustomElements.contains, allowClassName: (_) => true, allowElementId: (_) => true, addLinkRel: (href) { diff --git a/pkg/sanitize_html/CHANGELOG.md b/pkg/sanitize_html/CHANGELOG.md new file mode 100644 index 0000000000..f16e3288bb --- /dev/null +++ b/pkg/sanitize_html/CHANGELOG.md @@ -0,0 +1,41 @@ +## v2.3.0 + * Support `allowElements` to allow custom tag names. + +## v2.2.0 + * Allow the `` and `` elements, with `srcset` on ``, + matching the [`html-pipeline` allowlist](https://github.com/gjtorikian/html-pipeline/blob/main/lib/html_pipeline/sanitization_filter.rb) + this package is modeled on. Enables theme-aware images via `` with + `prefers-color-scheme`. + +## v2.1.0 + * Remove custom HTML rendering logic in favor of logic from `package:html`. + * Added `topics` to `pubspec.yaml`. + * `rel` attributes added through `addLinkRel` are appended to existing ones. + +## v2.0.0 + * Migrate to null safety. + +## v1.4.1 + * Make `addLinkRel` optional. [Issue](https://github.com/google/dart-neats/issues/71). + +## v1.4.0 + * Added `addLinkRel` option to `sanitize_html`. This will allow users to + [qualify outbound links](https://support.google.com/webmasters/answer/96569) + which may [help prevent comment spam](https://support.google.com/webmasters/answer/81749). + +## v1.3.0 + * Only print self-closing tags for + [void-elements](https://www.w3.org/TR/html5/syntax.html#void-elements). + This could cause `` in HTML documents, which is can be interpreted + as an opening tag by HTML5 parsers, causing the HTML structure to break. + +## v1.2.0 + * Does not depend on `universal_html`, uses custom HTML rendering for the output. + * Allowed classes are kept, even if there are non-allowed classes present on the same element. + +## v1.1.0 + * Add options `allowElementId` and `allowClassName` to allow specific element + ids and class names. + +## v1.0.0 + * Initial release. diff --git a/pkg/sanitize_html/LICENSE b/pkg/sanitize_html/LICENSE new file mode 100644 index 0000000000..7a4a3ea242 --- /dev/null +++ b/pkg/sanitize_html/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/pkg/sanitize_html/README.md b/pkg/sanitize_html/README.md new file mode 100644 index 0000000000..585795b3db --- /dev/null +++ b/pkg/sanitize_html/README.md @@ -0,0 +1,39 @@ +HTML Sanitizer for Dart +======================= + +When embedding HTML from untrusted source in a website it is important to +sanitize the HTML to prevent injection of untrusted JavaScript (XSS exploits). +This package provides a simple function sanitizing HTML to prevent XSS exploits +and limit interference with other elements on the page. + +**Disclaimer:** This is not an officially supported Google product. + +This package uses an HTML5 parser to build-up an in-memory DOM tree and +filter elements and attributes, in-line with [rules employed by GitHub][1] +when sanitizing GFM (GitHub Flavored Markdown). + +This removes all inline JavaScript, CSS, `
`, and other elements that +could be used for XSS. This sanitizer is more strict than necessary to +guard against XSS as this sanitizer also attempts to prevent the sanitized +HTML from interfering with the page it is injected into. + +For example, while it is possible to allow many CSS properties, this +sanitizer does not allow any CSS. This creates a sanitizer that is easy to +validate. These limitations are usually fine when sanitizing HTML from rendered +markdown. + +[1]: https://github.com/jch/html-pipeline/blob/master/lib/html/pipeline/sanitization_filter.rb + +## Example + +```dart +import 'package:sanitize_html/sanitize_html.dart' show sanitizeHtml; + +void main() { + print(sanitizeHtml('evil link')); + // Prints: evil link + // Which is a lot less evil :) +} +``` + + diff --git a/pkg/sanitize_html/example/main.dart b/pkg/sanitize_html/example/main.dart new file mode 100644 index 0000000000..c36895164a --- /dev/null +++ b/pkg/sanitize_html/example/main.dart @@ -0,0 +1,20 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:sanitize_html/sanitize_html.dart' show sanitizeHtml; + +void main() { + print(sanitizeHtml('evil link')); + // Prints: evil link +} diff --git a/pkg/sanitize_html/lib/sanitize_html.dart b/pkg/sanitize_html/lib/sanitize_html.dart new file mode 100644 index 0000000000..d191b6871c --- /dev/null +++ b/pkg/sanitize_html/lib/sanitize_html.dart @@ -0,0 +1,87 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'src/sane_html_validator.dart' show SaneHtmlValidator; + +/// Sanitize [htmlString] to prevent XSS exploits and limit interference with +/// other markup on the page. +/// +/// This function uses the HTML5 parser to build-up an in-memory DOM tree and +/// filter elements and attributes, in-line with [rules employed by Github][1] +/// when sanitizing GFM (Github Flavored Markdown). +/// +/// This removes all inline Javascript, CSS, ``, and other elements that +/// could be used for XSS. This sanitizer is more strict than necessary to +/// guard against XSS as this sanitizer also attempts to prevent the sanitized +/// HTML from interfering with the page it is injected into. +/// +/// The optional [allowElements] callback will be called with uppercase tag names. +/// +/// For example, while it is possible to allow many CSS properties, this +/// sanitizer does not allow any CSS. This creates a sanitizer that is easy to +/// validate and is usually fine when sanitizing HTML from rendered markdown. +/// The `allowElementId` and `allowClassName` options can be used to allow +/// specific element ids and class names through, otherwise `id` and `class` +/// attributes will be removed. +/// +/// **Example** +/// ```dart +/// import 'package:sanitize_html/sanitize_html.dart' show sanitizeHtml; +/// +/// void main() { +/// print(sanitizeHtml('evil link')); +/// // Prints: evil link +/// // Which is a lot less evil :) +/// } +/// ``` +/// +/// It is furthermore possible to use the [addLinkRel] to attach a `rel="..."` +/// property to links (`free stuff', +/// addLinkRel: (href) => ['ugc', 'nofollow'], +/// )); +/// // Prints: free stuff +/// // Might mitigate negative impact of hosting spam links with search engines. +/// } +/// ``` +/// +/// For more information on why to qualify outbound links, +/// see also [Ways to Prevent Comment Spam][3]. +/// +/// [1]: https://github.com/jch/html-pipeline/blob/master/lib/html/pipeline/sanitization_filter.rb +/// [2]: https://support.google.com/webmasters/answer/96569 +/// [3]: https://support.google.com/webmasters/answer/81749 +String sanitizeHtml( + String htmlString, { + bool Function(String)? allowElements, + bool Function(String)? allowElementId, + bool Function(String)? allowClassName, + Iterable? Function(String)? addLinkRel, +}) { + return SaneHtmlValidator( + allowElements: allowElements, + allowElementId: allowElementId, + allowClassName: allowClassName, + addLinkRel: addLinkRel, + ).sanitize(htmlString); +} diff --git a/pkg/sanitize_html/lib/src/sane_html_validator.dart b/pkg/sanitize_html/lib/src/sane_html_validator.dart new file mode 100644 index 0000000000..6649eec979 --- /dev/null +++ b/pkg/sanitize_html/lib/src/sane_html_validator.dart @@ -0,0 +1,318 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:html/dom.dart'; +import 'package:html/parser.dart' as html_parser; + +final _allowedElements = { + 'H1', + 'H2', + 'H3', + 'H4', + 'H5', + 'H6', + 'H7', + 'H8', + 'BR', + 'B', + 'I', + 'STRONG', + 'EM', + 'A', + 'PRE', + 'CODE', + 'IMG', + 'TT', + 'DIV', + 'INS', + 'DEL', + 'SUP', + 'SUB', + 'P', + 'PICTURE', + 'OL', + 'UL', + 'TABLE', + 'THEAD', + 'TBODY', + 'TFOOT', + 'BLOCKQUOTE', + 'DL', + 'DT', + 'DD', + 'KBD', + 'Q', + 'SAMP', + 'VAR', + 'HR', + 'RUBY', + 'RT', + 'RP', + 'LI', + 'TR', + 'TD', + 'TH', + 'S', + 'STRIKE', + 'SUMMARY', + 'DETAILS', + 'CAPTION', + 'FIGURE', + 'FIGCAPTION', + 'ABBR', + 'BDO', + 'CITE', + 'DFN', + 'MARK', + 'SMALL', + 'SOURCE', + 'SPAN', + 'TIME', + 'WBR', +}; + +final _alwaysAllowedAttributes = { + 'abbr', + 'accept', + 'accept-charset', + 'accesskey', + 'action', + 'align', + 'alt', + 'aria-describedby', + 'aria-hidden', + 'aria-label', + 'aria-labelledby', + 'axis', + 'border', + 'cellpadding', + 'cellspacing', + 'char', + 'charoff', + 'charset', + 'checked', + 'clear', + 'cols', + 'colspan', + 'color', + 'compact', + 'coords', + 'datetime', + 'dir', + 'disabled', + 'enctype', + 'for', + 'frame', + 'headers', + 'height', + 'hreflang', + 'hspace', + 'ismap', + 'label', + 'lang', + 'maxlength', + 'media', + 'method', + 'multiple', + 'name', + 'nohref', + 'noshade', + 'nowrap', + 'open', + 'prompt', + 'readonly', + 'rel', + 'rev', + 'rows', + 'rowspan', + 'rules', + 'scope', + 'selected', + 'shape', + 'size', + 'span', + 'start', + 'summary', + 'tabindex', + 'target', + 'title', + 'type', + 'usemap', + 'valign', + 'value', + 'vspace', + 'width', + 'itemprop', +}; + +bool _alwaysAllowed(String _) => true; + +bool _validLink(String url) { + try { + final uri = Uri.parse(url); + return uri.isScheme('https') || + uri.isScheme('http') || + uri.isScheme('mailto') || + !uri.hasScheme; + } on FormatException { + return false; + } +} + +bool _validUrl(String url) { + try { + final uri = Uri.parse(url); + return uri.isScheme('https') || uri.isScheme('http') || !uri.hasScheme; + } on FormatException { + return false; + } +} + +// `srcset` is a comma-separated list of " [descriptor]" candidates. +// Validate every candidate's URL with the same scheme rules as `src`, so a +// single bad entry (e.g. a `javascript:` URL) rejects the whole attribute. The +// optional descriptor must be a width (`640w`) or pixel-density (`1.5x`) value; +// arbitrary trailing text rejects the attribute. +// +// See also: https://html.spec.whatwg.org/multipage/images.html#srcset-attributes +final _srcsetDescriptor = RegExp(r'^(?:[0-9]+w|[0-9]*\.?[0-9]+x)$'); + +bool _validSrcset(String value) { + for (final candidate in value.split(',')) { + final trimmed = candidate.trim(); + if (trimmed.isEmpty) continue; + final parts = trimmed.split(RegExp(r'\s+')); + if (!_validUrl(parts.first)) return false; + if (parts.length > 2) return false; + if (parts.length == 2 && !_srcsetDescriptor.hasMatch(parts[1])) { + return false; + } + } + return true; +} + +final _citeAttributeValidator = { + 'cite': _validUrl, +}; + +final _elementAttributeValidators = + >{ + 'A': { + 'href': _validLink, + }, + 'IMG': { + 'src': _validUrl, + 'longdesc': _validUrl, + }, + 'SOURCE': { + 'srcset': _validSrcset, + }, + 'DIV': { + 'itemscope': _alwaysAllowed, + 'itemtype': _alwaysAllowed, + }, + 'BLOCKQUOTE': _citeAttributeValidator, + 'DEL': _citeAttributeValidator, + 'INS': _citeAttributeValidator, + 'Q': _citeAttributeValidator, +}; + +/// An implementation of [html.NodeValidator] that only allows sane HTML tags +/// and attributes protecting against XSS. +/// +/// Modeled after the [rules employed by Github][1] when sanitizing GFM (Github +/// Flavored Markdown). Notably this excludes CSS styles and other tags that +/// easily interferes with the rest of the page. +/// +/// [1]: https://github.com/gjtorikian/html-pipeline/blob/main/lib/html_pipeline/sanitization_filter.rb +class SaneHtmlValidator { + final bool Function(String)? allowElements; + final bool Function(String)? allowElementId; + final bool Function(String)? allowClassName; + final Iterable? Function(String)? addLinkRel; + + SaneHtmlValidator({ + required this.allowElements, + required this.allowElementId, + required this.allowClassName, + required this.addLinkRel, + }); + + String sanitize(String htmlString) { + final root = html_parser.parseFragment(htmlString); + _sanitize(root); + return root.outerHtml; + } + + void _sanitize(Node node) { + if (node is Element) { + final tagName = node.localName!.toUpperCase(); + if (!_allowedElements.contains(tagName) && + !(allowElements?.call(tagName) ?? false)) { + node.remove(); + return; + } + node.attributes.removeWhere((k, v) { + final attrName = k.toString(); + if (attrName == 'id') { + return allowElementId == null || !allowElementId!(v); + } + if (attrName == 'class') { + if (allowClassName == null) return true; + node.classes.removeWhere((cn) => !allowClassName!(cn)); + return node.classes.isEmpty; + } + return !_isAttributeAllowed(tagName, attrName, v); + }); + if (tagName == 'A') { + final href = node.attributes['href']; + if (href != null && addLinkRel != null) { + final rels = addLinkRel!(href); + if (rels != null && rels.isNotEmpty) { + final currentRel = node.attributes['rel'] ?? ''; + final allRels = { + ...currentRel.split(' ').where((e) => e.isNotEmpty), + ...rels, + }; + node.attributes['rel'] = allRels.join(' '); + } + } + } + } + if (node.hasChildNodes()) { + // doing it in reverse order, because we could otherwise skip one, when a + // node is removed... + for (var i = node.nodes.length - 1; i >= 0; i--) { + _sanitize(node.nodes[i]); + } + } + } + + bool _isAttributeAllowed(String tagName, String attrName, String value) { + if (_alwaysAllowedAttributes.contains(attrName)) return true; + + // Special validators for special attributes on special tags (href/src/cite) + final attributeValidators = _elementAttributeValidators[tagName]; + if (attributeValidators == null) { + return false; + } + + final validator = attributeValidators[attrName]; + if (validator == null) { + return false; + } + + return validator(value); + } +} diff --git a/pkg/sanitize_html/pubspec.yaml b/pkg/sanitize_html/pubspec.yaml new file mode 100644 index 0000000000..f6d20958a5 --- /dev/null +++ b/pkg/sanitize_html/pubspec.yaml @@ -0,0 +1,20 @@ +name: sanitize_html +version: 2.3.0 +description: >- + Function for sanitizing HTML to prevent XSS by restrict elements and + attributes to a safe subset of allowed values. +homepage: https://github.com/google/dart-neats/tree/master/sanitize_html +repository: https://github.com/google/dart-neats.git +issue_tracker: https://github.com/google/dart-neats/labels/pkg:sanitize_html +topics: + - security + - html +dependencies: + html: ^0.15.0 + meta: ^1.1.7 +dev_dependencies: + test: ^1.5.1 + markdown: ^7.1.1 +environment: + sdk: ^3.5.0 +resolution: workspace diff --git a/pkg/sanitize_html/test/sanitize_html_test.dart b/pkg/sanitize_html/test/sanitize_html_test.dart new file mode 100644 index 0000000000..bf7cfddc59 --- /dev/null +++ b/pkg/sanitize_html/test/sanitize_html_test.dart @@ -0,0 +1,203 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:test/test.dart'; +import 'package:sanitize_html/sanitize_html.dart' show sanitizeHtml; + +void main() { + // Calls sanitizeHtml with two different configurations. + // * When `withOptionalConfiguration` is `true`: `allowElementId`, `allowClassName` + // and `addLinkRel` overrides are passed to the sanitizeHtml call of `template`. + // (This is the default behavior for the [testContains]/[testNotContains] methods.) + // * When `withOptionalConfiguration` is false: only `template` is passed. + String doSanitizeHtml(String template, + {required bool withOptionalConfiguration}) { + if (!withOptionalConfiguration) { + return sanitizeHtml(template); + } + + return sanitizeHtml( + template, + allowElements: (tagName) => tagName == 'ONLY-ALLOWED-TAG', + allowElementId: (id) => id == 'only-allowed-id', + allowClassName: (className) => className == 'only-allowed-class', + addLinkRel: (href) => href == 'bad-link' ? ['ugc', 'nofollow'] : null, + ); + } + + void testContains(String template, String needle, + {bool withOptionalConfiguration = true}) { + test('"$template" does contain "$needle"', () { + final sanitizedHtml = doSanitizeHtml( + template, + withOptionalConfiguration: withOptionalConfiguration, + ); + expect(sanitizedHtml, contains(needle)); + }); + } + + void testNotContains(String template, String needle, + {bool withOptionalConfiguration = true}) { + test('"$template" does not contain "$needle"', () { + final sanitizedHtml = doSanitizeHtml( + template, + withOptionalConfiguration: withOptionalConfiguration, + ); + expect(sanitizedHtml, isNot(contains(needle))); + }); + } + + testNotContains('test', '
'); + testContains('test', 'test'); + testContains('a < b', '<'); + testContains('a < b > c', '>'); + testContains('

hello', 'hello'); + testContains('

hello', '

'); + testContains('

hello', '

'); + + // test tag name filtering + testContains( + 'hello', ''); + testContains('hello', 'hello'); + testNotContains('hello', 'disallowed-tag'); + testNotContains('hello', 'hello'); + + // test id filtering.. + testContains('hello', 'id'); + testContains('hello', 'only-allowed-id'); + testNotContains('hello', 'id'); + testNotContains('hello', 'only-allowed-id'); + + // test class filtering + testContains('hello', 'class'); + testContains( + 'hello', 'only-allowed-class'); + testContains('hello', + 'class="only-allowed-class"'); + testNotContains('hello', 'class'); + testNotContains( + 'hello', 'only-allowed-class'); + + testContains('hello', 'href'); + testContains('hello', 'test.html'); + testContains( + 'hello', '//example.com/test.html'); + testContains('hello', '/test.html'); + testContains('hello', + 'https://example.com/test.html'); + testContains('hello', + 'http://example.com/test.html'); + testContains( + 'hello', 'mailto:test@example.com'); + + testContains('', '', 'say hi'); + testContains('say hi', 'alt='); + testContains('say hi', 'say hi'); + testContains('say hi', 'alt='); + testContains('', 'src='); + testContains('', 'test.jpg'); + testContains('', '//test.jpg'); + testContains('', '/test.jpg'); + testContains('', + 'https://example.com/test.jpg'); + testContains('', + 'http://example.com/test.jpg'); + + testNotContains('', 'src='); + testNotContains('', 'javascript'); + testContains('', 'img'); + + // and survive sanitization, including the fallback . + // A bad URL in srcset drops only the attribute, not the element. + testContains('', ''); + testContains('', '', + '', 'srcset'); + testContains('', 'banner.webp'); + testContains( + '', + 'media='); + testContains('', 'b.webp 2x'); + testContains('', '/img/banner.webp'); + testContains('', + 'https://example.com/b.webp'); + testNotContains('', 'srcset'); + testNotContains('', 'javascript'); + testContains('', '', 'srcset'); + testNotContains( + '', 'javascript'); + + // A descriptor must be a width (`640w`) or density (`1.5x`); nothing else. + testContains('', '640w'); + testContains('', '1.5x'); + testNotContains('', 'srcset'); + testNotContains('', 'totally-bogus'); + testContains('', '', 'srcset'); + testNotContains('', 'srcset'); + + testNotContains(' hello world', 'hello world'); + testNotContains(' hello world', 'bad'); + testContains('evil link', 'evil link', 'href'); + testNotContains('evil link', 'alert'); + testNotContains('evil link', 'javascript'); + + testNotContains(' click here', 'form'); + testNotContains('

click here', 'submit'); + testNotContains('
click here', 'input'); + testContains('
click here', 'click here'); + + testContains('
', '
'); + testNotContains('
', '
'); + testNotContains('
', ''); + testContains('><', '><'); + testContains('
a
', '
a
'); + testContains('ab', + 'ab'); + + // test void elements + testContains(' hello', ''); + testContains(' hello', ''); + testNotContains(' hello', ''); + testContains('
hello
', '
'); + testNotContains('
hello
', '
'); + testNotContains('
hello
', ''); + + // test addLinkRel + testContains('hello', 'bad-link'); + testContains('hello', 'rel="ugc nofollow"'); + testNotContains('hello', 'rel="ugc nofollow"'); + + group('Optional parameters stay optional:', () { + // If any of these fail, it probably means a major version bump is required. + testContains('hey', 'href=', + withOptionalConfiguration: false); + testNotContains('hey', 'rel=', + withOptionalConfiguration: false); + testNotContains('hello', 'hello', + withOptionalConfiguration: false); + testNotContains('hello', 'id=', + withOptionalConfiguration: false); + testNotContains('hello', 'class=', + withOptionalConfiguration: false); + }); +} diff --git a/pubspec.lock b/pubspec.lock index 655a9e2201..9f82e33432 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -713,14 +713,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" - sanitize_html: - dependency: transitive - description: - name: sanitize_html - sha256: "60257e2b8fd8b13b7c755065c2639a6c800031bb5bd3e30a007fc6739c77b2e1" - url: "https://pub.dev" - source: hosted - version: "2.2.0" sass: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index de3d490985..396c24ae61 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,6 +15,7 @@ workspace: - pkg/indexed_blob - pkg/pub_package_reader - pkg/puppeteer_screenshots + - pkg/sanitize_html - pkg/image_proxy - pkg/runsc - pkg/unzip From 6bf41b9284a2187f017078d8a2aa92b1122127fd Mon Sep 17 00:00:00 2001 From: Istvan Soos Date: Tue, 28 Jul 2026 13:09:39 +0200 Subject: [PATCH 2/2] Fix lints --- pkg/sanitize_html/lib/src/sane_html_validator.dart | 2 +- pkg/sanitize_html/test/sanitize_html_test.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/sanitize_html/lib/src/sane_html_validator.dart b/pkg/sanitize_html/lib/src/sane_html_validator.dart index 6649eec979..43d3b89218 100644 --- a/pkg/sanitize_html/lib/src/sane_html_validator.dart +++ b/pkg/sanitize_html/lib/src/sane_html_validator.dart @@ -228,7 +228,7 @@ final _elementAttributeValidators = 'Q': _citeAttributeValidator, }; -/// An implementation of [html.NodeValidator] that only allows sane HTML tags +/// An implementation of `html.NodeValidator` that only allows sane HTML tags /// and attributes protecting against XSS. /// /// Modeled after the [rules employed by Github][1] when sanitizing GFM (Github diff --git a/pkg/sanitize_html/test/sanitize_html_test.dart b/pkg/sanitize_html/test/sanitize_html_test.dart index bf7cfddc59..84d79c21e0 100644 --- a/pkg/sanitize_html/test/sanitize_html_test.dart +++ b/pkg/sanitize_html/test/sanitize_html_test.dart @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import 'package:test/test.dart'; import 'package:sanitize_html/sanitize_html.dart' show sanitizeHtml; +import 'package:test/test.dart'; void main() { // Calls sanitizeHtml with two different configurations.