VYPR
Moderate severityNVD Advisory· Published Nov 16, 2021· Updated Aug 4, 2024

Cross-site scripting (XSS) from writer field content in the site frontend

CVE-2021-41252

Description

Kirby is an open source file structured CMS

Impact

Kirby's writer field stores its formatted content as HTML code. Unlike with other field types, it is not possible to escape HTML special characters against cross-site scripting (XSS) attacks, otherwise the formatting would be lost. If the user is logged in to the Panel, a harmful script can for example trigger requests to Kirby's API with the permissions of the victim. Because the writer field did not securely sanitize its contents on save, it was possible to inject malicious HTML code into the content file by sending it to Kirby's API directly without using the Panel. This malicious HTML code would then be displayed on the site frontend and executed in the browsers of site visitors and logged in users who are browsing the site. Attackers must be in your group of authenticated Panel users in order to exploit this weakness. Users who do not make use of the writer field are not affected. This issue has been patched in Kirby 3.5.8 by sanitizing all writer field contents on the backend whenever the content is modified via Kirby's API. Please update to this or a later version to fix the vulnerability.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
getkirby/cmsPackagist
>= 3.5.0, < 3.5.83.5.8

Affected products

1

Patches

1
25fc5c6b3304

Merge pull request from GHSA-x7j7-qp7j-hw3q

https://github.com/getkirby/kirbyBastian AllgeierNov 16, 2021via ghsa
22 files changed · +3270 38
  • cacert.pem+80 22 modified
    @@ -1,7 +1,7 @@
     ##
     ## Bundle of CA Root Certificates
     ##
    -## Certificate data from Mozilla as of: Mon Jul  5 21:35:54 2021 GMT
    +## Certificate data from Mozilla as of: Tue Oct 26 03:12:05 2021 GMT
     ##
     ## This is a bundle of X.509 certificates of public Certificate Authorities
     ## (CA). These were automatically extracted from Mozilla's root certificates
    @@ -14,7 +14,7 @@
     ## Just configure this file as the SSLCACertificateFile.
     ##
     ## Conversion done with mk-ca-bundle.pl version 1.28.
    -## SHA256: c8f6733d1ff4e6a4769c182971a1234f95ae079247a9c439a13423fe8ba5c24f
    +## SHA256: bb36818a81feaa4cca61101e6d6276cd09e972efcb08112dfed846918ca41d7f
     ##
     
     
    @@ -381,26 +381,6 @@ mNEVX58Svnw2Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCe
     vEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep+OkuE6N36B9K
     -----END CERTIFICATE-----
     
    -DST Root CA X3
    -==============
    ------BEGIN CERTIFICATE-----
    -MIIDSjCCAjKgAwIBAgIQRK+wgNajJ7qJMDmGLvhAazANBgkqhkiG9w0BAQUFADA/MSQwIgYDVQQK
    -ExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMTDkRTVCBSb290IENBIFgzMB4X
    -DTAwMDkzMDIxMTIxOVoXDTIxMDkzMDE0MDExNVowPzEkMCIGA1UEChMbRGlnaXRhbCBTaWduYXR1
    -cmUgVHJ1c3QgQ28uMRcwFQYDVQQDEw5EU1QgUm9vdCBDQSBYMzCCASIwDQYJKoZIhvcNAQEBBQAD
    -ggEPADCCAQoCggEBAN+v6ZdQCINXtMxiZfaQguzH0yxrMMpb7NnDfcdAwRgUi+DoM3ZJKuM/IUmT
    -rE4Orz5Iy2Xu/NMhD2XSKtkyj4zl93ewEnu1lcCJo6m67XMuegwGMoOifooUMM0RoOEqOLl5CjH9
    -UL2AZd+3UWODyOKIYepLYYHsUmu5ouJLGiifSKOeDNoJjj4XLh7dIN9bxiqKqy69cK3FCxolkHRy
    -xXtqqzTWMIn/5WgTe1QLyNau7Fqckh49ZLOMxt+/yUFw7BZy1SbsOFU5Q9D8/RhcQPGX69Wam40d
    -utolucbY38EVAjqr2m7xPi71XAicPNaDaeQQmxkqtilX4+U9m5/wAl0CAwEAAaNCMEAwDwYDVR0T
    -AQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFMSnsaR7LHH62+FLkHX/xBVghYkQ
    -MA0GCSqGSIb3DQEBBQUAA4IBAQCjGiybFwBcqR7uKGY3Or+Dxz9LwwmglSBd49lZRNI+DT69ikug
    -dB/OEIKcdBodfpga3csTS7MgROSR6cz8faXbauX+5v3gTt23ADq1cEmv8uXrAvHRAosZy5Q6XkjE
    -GB5YGV8eAlrwDPGxrancWYaLbumR9YbK+rlmM6pZW87ipxZzR8srzJmwN0jP41ZL9c8PDHIyh8bw
    -RLtTcm1D9SZImlJnt1ir/md2cXjbDaJWFBM5JDGFoqgCWjBH4d1QB7wCCZAA62RjYJsWvIjJEubS
    -fZGL+T0yjWW06XyxV3bqxbYoOb8VZRzI9neWagqNdwvYkQsEjgfbKbYK7p2CNTUQ
    ------END CERTIFICATE-----
    -
     SwissSign Gold CA - G2
     ======================
     -----BEGIN CERTIFICATE-----
    @@ -3172,3 +3152,81 @@ WWRrJ8/vJ8HjJLWG965+Mk2weWjROeiQWMODvA8s1pfrzgzhIMfatz7DP78v3DSk+yshzWePS/Tj
     OPQD8rv7gmsHINFSH5pkAnuYZttcTVoP0ISVoDwUQwbKytu4QTbaakRnh6+v40URFWkIsr4WOZck
     bxJF0WddCajJFdr60qZfE2Efv4WstK2tBZQIgx51F9NxO5NQI1mg7TyRVJ12AMXDuDjb
     -----END CERTIFICATE-----
    +
    +TunTrust Root CA
    +================
    +-----BEGIN CERTIFICATE-----
    +MIIFszCCA5ugAwIBAgIUEwLV4kBMkkaGFmddtLu7sms+/BMwDQYJKoZIhvcNAQELBQAwYTELMAkG
    +A1UEBhMCVE4xNzA1BgNVBAoMLkFnZW5jZSBOYXRpb25hbGUgZGUgQ2VydGlmaWNhdGlvbiBFbGVj
    +dHJvbmlxdWUxGTAXBgNVBAMMEFR1blRydXN0IFJvb3QgQ0EwHhcNMTkwNDI2MDg1NzU2WhcNNDQw
    +NDI2MDg1NzU2WjBhMQswCQYDVQQGEwJUTjE3MDUGA1UECgwuQWdlbmNlIE5hdGlvbmFsZSBkZSBD
    +ZXJ0aWZpY2F0aW9uIEVsZWN0cm9uaXF1ZTEZMBcGA1UEAwwQVHVuVHJ1c3QgUm9vdCBDQTCCAiIw
    +DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMPN0/y9BFPdDCA61YguBUtB9YOCfvdZn56eY+hz
    +2vYGqU8ftPkLHzmMmiDQfgbU7DTZhrx1W4eI8NLZ1KMKsmwb60ksPqxd2JQDoOw05TDENX37Jk0b
    +bjBU2PWARZw5rZzJJQRNmpA+TkBuimvNKWfGzC3gdOgFVwpIUPp6Q9p+7FuaDmJ2/uqdHYVy7BG7
    +NegfJ7/Boce7SBbdVtfMTqDhuazb1YMZGoXRlJfXyqNlC/M4+QKu3fZnz8k/9YosRxqZbwUN/dAd
    +gjH8KcwAWJeRTIAAHDOFli/LQcKLEITDCSSJH7UP2dl3RxiSlGBcx5kDPP73lad9UKGAwqmDrViW
    +VSHbhlnUr8a83YFuB9tgYv7sEG7aaAH0gxupPqJbI9dkxt/con3YS7qC0lH4Zr8GRuR5KiY2eY8f
    +Tpkdso8MDhz/yV3A/ZAQprE38806JG60hZC/gLkMjNWb1sjxVj8agIl6qeIbMlEsPvLfe/ZdeikZ
    +juXIvTZxi11Mwh0/rViizz1wTaZQmCXcI/m4WEEIcb9PuISgjwBUFfyRbVinljvrS5YnzWuioYas
    +DXxU5mZMZl+QviGaAkYt5IPCgLnPSz7ofzwB7I9ezX/SKEIBlYrilz0QIX32nRzFNKHsLA4KUiwS
    +VXAkPcvCFDVDXSdOvsC9qnyW5/yeYa1E0wCXAgMBAAGjYzBhMB0GA1UdDgQWBBQGmpsfU33x9aTI
    +04Y+oXNZtPdEITAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFAaamx9TffH1pMjThj6hc1m0
    +90QhMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAqgVutt0Vyb+zxiD2BkewhpMl
    +0425yAA/l/VSJ4hxyXT968pk21vvHl26v9Hr7lxpuhbI87mP0zYuQEkHDVneixCwSQXi/5E/S7fd
    +Ao74gShczNxtr18UnH1YeA32gAm56Q6XKRm4t+v4FstVEuTGfbvE7Pi1HE4+Z7/FXxttbUcoqgRY
    +YdZ2vyJ/0Adqp2RT8JeNnYA/u8EH22Wv5psymsNUk8QcCMNE+3tjEUPRahphanltkE8pjkcFwRJp
    +adbGNjHh/PqAulxPxOu3Mqz4dWEX1xAZufHSCe96Qp1bWgvUxpVOKs7/B9dPfhgGiPEZtdmYu65x
    +xBzndFlY7wyJz4sfdZMaBBSSSFCp61cpABbjNhzI+L/wM9VBD8TMPN3pM0MBkRArHtG5Xc0yGYuP
    +jCB31yLEQtyEFpslbei0VXF/sHyz03FJuc9SpAQ/3D2gu68zngowYI7bnV2UqL1g52KAdoGDDIzM
    +MEZJ4gzSqK/rYXHv5yJiqfdcZGyfFoxnNidF9Ql7v/YQCvGwjVRDjAS6oz/v4jXH+XTgbzRB0L9z
    +ZVcg+ZtnemZoJE6AZb0QmQZZ8mWvuMZHu/2QeItBcy6vVR/cO5JyboTT0GFMDcx2V+IthSIVNg3r
    +AZ3r2OvEhJn7wAzMMujjd9qDRIueVSjAi1jTkD5OGwDxFa2DK5o=
    +-----END CERTIFICATE-----
    +
    +HARICA TLS RSA Root CA 2021
    +===========================
    +-----BEGIN CERTIFICATE-----
    +MIIFpDCCA4ygAwIBAgIQOcqTHO9D88aOk8f0ZIk4fjANBgkqhkiG9w0BAQsFADBsMQswCQYDVQQG
    +EwJHUjE3MDUGA1UECgwuSGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0aW9u
    +cyBDQTEkMCIGA1UEAwwbSEFSSUNBIFRMUyBSU0EgUm9vdCBDQSAyMDIxMB4XDTIxMDIxOTEwNTUz
    +OFoXDTQ1MDIxMzEwNTUzN1owbDELMAkGA1UEBhMCR1IxNzA1BgNVBAoMLkhlbGxlbmljIEFjYWRl
    +bWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgQ0ExJDAiBgNVBAMMG0hBUklDQSBUTFMgUlNB
    +IFJvb3QgQ0EgMjAyMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAIvC569lmwVnlskN
    +JLnQDmT8zuIkGCyEf3dRywQRNrhe7Wlxp57kJQmXZ8FHws+RFjZiPTgE4VGC/6zStGndLuwRo0Xu
    +a2s7TL+MjaQenRG56Tj5eg4MmOIjHdFOY9TnuEFE+2uva9of08WRiFukiZLRgeaMOVig1mlDqa2Y
    +Ulhu2wr7a89o+uOkXjpFc5gH6l8Cct4MpbOfrqkdtx2z/IpZ525yZa31MJQjB/OCFks1mJxTuy/K
    +5FrZx40d/JiZ+yykgmvwKh+OC19xXFyuQnspiYHLA6OZyoieC0AJQTPb5lh6/a6ZcMBaD9YThnEv
    +dmn8kN3bLW7R8pv1GmuebxWMevBLKKAiOIAkbDakO/IwkfN4E8/BPzWr8R0RI7VDIp4BkrcYAuUR
    +0YLbFQDMYTfBKnya4dC6s1BG7oKsnTH4+yPiAwBIcKMJJnkVU2DzOFytOOqBAGMUuTNe3QvboEUH
    +GjMJ+E20pwKmafTCWQWIZYVWrkvL4N48fS0ayOn7H6NhStYqE613TBoYm5EPWNgGVMWX+Ko/IIqm
    +haZ39qb8HOLubpQzKoNQhArlT4b4UEV4AIHrW2jjJo3Me1xR9BQsQL4aYB16cmEdH2MtiKrOokWQ
    +CPxrvrNQKlr9qEgYRtaQQJKQCoReaDH46+0N0x3GfZkYVVYnZS6NRcUk7M7jAgMBAAGjQjBAMA8G
    +A1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFApII6ZgpJIKM+qTW8VX6iVNvRLuMA4GA1UdDwEB/wQE
    +AwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAPpBIqm5iFSVmewzVjIuJndftTgfvnNAUX15QvWiWkKQU
    +EapobQk1OUAJ2vQJLDSle1mESSmXdMgHHkdt8s4cUCbjnj1AUz/3f5Z2EMVGpdAgS1D0NTsY9FVq
    +QRtHBmg8uwkIYtlfVUKqrFOFrJVWNlar5AWMxajaH6NpvVMPxP/cyuN+8kyIhkdGGvMA9YCRotxD
    +QpSbIPDRzbLrLFPCU3hKTwSUQZqPJzLB5UkZv/HywouoCjkxKLR9YjYsTewfM7Z+d21+UPCfDtcR
    +j88YxeMn/ibvBZ3PzzfF0HvaO7AWhAw6k9a+F9sPPg4ZeAnHqQJyIkv3N3a6dcSFA1pj1bF1BcK5
    +vZStjBWZp5N99sXzqnTPBIWUmAD04vnKJGW/4GKvyMX6ssmeVkjaef2WdhW+o45WxLM0/L5H9MG0
    +qPzVMIho7suuyWPEdr6sOBjhXlzPrjoiUevRi7PzKzMHVIf6tLITe7pTBGIBnfHAT+7hOtSLIBD6
    +Alfm78ELt5BGnBkpjNxvoEppaZS3JGWg/6w/zgH7IS79aPib8qXPMThcFarmlwDB31qlpzmq6YR/
    +PFGoOtmUW4y/Twhx5duoXNTSpv4Ao8YWxw/ogM4cKGR0GQjTQuPOAF1/sdwTsOEFy9EgqoZ0njnn
    +kf3/W9b3raYvAwtt41dU63ZTGI0RmLo=
    +-----END CERTIFICATE-----
    +
    +HARICA TLS ECC Root CA 2021
    +===========================
    +-----BEGIN CERTIFICATE-----
    +MIICVDCCAdugAwIBAgIQZ3SdjXfYO2rbIvT/WeK/zjAKBggqhkjOPQQDAzBsMQswCQYDVQQGEwJH
    +UjE3MDUGA1UECgwuSGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0aW9ucyBD
    +QTEkMCIGA1UEAwwbSEFSSUNBIFRMUyBFQ0MgUm9vdCBDQSAyMDIxMB4XDTIxMDIxOTExMDExMFoX
    +DTQ1MDIxMzExMDEwOVowbDELMAkGA1UEBhMCR1IxNzA1BgNVBAoMLkhlbGxlbmljIEFjYWRlbWlj
    +IGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgQ0ExJDAiBgNVBAMMG0hBUklDQSBUTFMgRUNDIFJv
    +b3QgQ0EgMjAyMTB2MBAGByqGSM49AgEGBSuBBAAiA2IABDgI/rGgltJ6rK9JOtDA4MM7KKrxcm1l
    +AEeIhPyaJmuqS7psBAqIXhfyVYf8MLA04jRYVxqEU+kw2anylnTDUR9YSTHMmE5gEYd103KUkE+b
    +ECUqqHgtvpBBWJAVcqeht6NCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUyRtTgRL+BNUW
    +0aq8mm+3oJUZbsowDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMDA2cAMGQCMBHervjcToiwqfAi
    +rcJRQO9gcS3ujwLEXQNwSaSS6sUUiHCm0w2wqsosQJz76YJumgIwK0eaB8bRwoF8yguWGEEbo/Qw
    +CZ61IygNnxS2PFOiTAZpffpskcYqSUXm7LcT4Tps
    +-----END CERTIFICATE-----
    
  • composer.json+1 1 modified
    @@ -8,7 +8,7 @@
         "core"
       ],
       "homepage": "https://getkirby.com",
    -  "version": "3.5.7.1",
    +  "version": "3.5.8",
       "license": "proprietary",
       "authors": [
         {
    
  • composer.lock+1 1 modified
    @@ -4,7 +4,7 @@
             "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
             "This file is @generated automatically"
         ],
    -    "content-hash": "c087786719948c60e5d74492f70440a5",
    +    "content-hash": "039b76eb24f9d5016bc5284c5b366718",
         "packages": [
             {
                 "name": "claviska/simpleimage",
    
  • config/blocks/image/image.php+4 4 modified
    @@ -9,7 +9,7 @@
     $src     = null;
     
     if ($block->location() == 'web') {
    -    $src = $block->src();
    +    $src = $block->src()->esc();
     } elseif ($image = $block->image()->toFile()) {
         $alt = $alt ?? $image->alt();
         $src = $image->url();
    @@ -19,11 +19,11 @@
     <?php if ($src): ?>
     <figure<?= attr(['data-ratio' => $ratio, 'data-crop' => $crop], ' ') ?>>
       <?php if ($link->isNotEmpty()): ?>
    -  <a href="<?= $link->toUrl() ?>">
    -    <img src="<?= $src ?>" alt="<?= $alt ?>">
    +  <a href="<?= esc($link->toUrl()) ?>">
    +    <img src="<?= $src ?>" alt="<?= $alt->esc() ?>">
       </a>
       <?php else: ?>
    -  <img src="<?= $src ?>" alt="<?= $alt ?>">
    +  <img src="<?= $src ?>" alt="<?= $alt->esc() ?>">
       <?php endif ?>
     
       <?php if ($caption->isNotEmpty()): ?>
    
  • config/fields/writer.php+3 1 modified
    @@ -1,5 +1,7 @@
     <?php
     
    +use Kirby\Sane\Html;
    +
     return [
         'props' => [
             /**
    @@ -27,7 +29,7 @@
         ],
         'computed' => [
             'value' => function () {
    -            return trim($this->value);
    +            return Html::sanitize(trim($this->value));
             }
         ],
     ];
    
  • panel/dist/js/app.js+1 1 modified
  • panel/src/components/Writer/Writer.vue+15 1 modified
    @@ -106,6 +106,7 @@ export default {
       data() {
         return {
           editor: null,
    +      json: {},
           html: this.value,
           isEmpty: true,
           toolbar: false
    @@ -140,7 +141,16 @@ export default {
               }
             },
             update: (payload) => {
    -          this.html    = payload.editor.getHTML();
    +          // compare documents to avoid minor HTML differences
    +          // to cause unwanted updates
    +          const jsonNew = JSON.stringify(this.editor.getJSON());
    +          const jsonOld = JSON.stringify(this.json);
    +
    +          if (jsonNew === jsonOld) {
    +            return;
    +          }
    +
    +          this.json    = jsonNew;
               this.isEmpty = payload.editor.isEmpty();
     
               // when a new list item or heading is created, textContent length returns 0
    @@ -156,6 +166,9 @@ export default {
                 this.html = "";
               }
     
    +          // create the final HTML to send to the server
    +          this.html = payload.editor.getHTML();
    +
               this.$emit("input", this.html);
             }
           },
    @@ -172,6 +185,7 @@ export default {
         });
     
         this.isEmpty = this.editor.isEmpty();
    +    this.json    = this.editor.getJSON();
       },
       beforeDestroy() {
         this.editor.destroy();
    
  • panel/vite.config.custom.js+11 0 added
    @@ -0,0 +1,11 @@
    +const fs = require("fs");
    +
    +process.env.VUE_APP_DEV_SERVER = "https://sandbox.kirby.test";
    +
    +module.exports = {
    +  host: "sandbox.kirby.test",
    +  https: {
    +    key: fs.readFileSync('/Users/luX/Library/Application Support/Caddy/certificates/local/sandbox.kirby.test/sandbox.kirby.test.key'),
    +    cert: fs.readFileSync('/Users/luX/Library/Application Support/Caddy/certificates/local/sandbox.kirby.test/sandbox.kirby.test.crt')
    +  }
    +};
    
  • src/Sane/DomHandler.php+165 0 added
    @@ -0,0 +1,165 @@
    +<?php
    +
    +namespace Kirby\Sane;
    +
    +use DOMAttr;
    +use DOMDocumentType;
    +use DOMElement;
    +use Kirby\Toolkit\Dom;
    +
    +/**
    + * Base class for Sane handlers with DOM file types
    + * @since 3.5.8
    + *
    + * @package   Kirby Sane
    + * @author    Lukas Bestle <lukas@getkirby.com>
    + * @link      https://getkirby.com
    + * @copyright Bastian Allgeier GmbH
    + * @license   https://opensource.org/licenses/MIT
    + */
    +class DomHandler extends Handler
    +{
    +    /**
    +     * List of all MIME types that may
    +     * be used in data URIs
    +     *
    +     * @var array
    +     */
    +    public static $allowedDataUris = [
    +        'data:image/png',
    +        'data:image/gif',
    +        'data:image/jpg',
    +        'data:image/jpe',
    +        'data:image/pjp',
    +        'data:img/png',
    +        'data:img/gif',
    +        'data:img/jpg',
    +        'data:img/jpe',
    +        'data:img/pjp',
    +    ];
    +
    +    /**
    +     * Allowed hostnames for HTTP(S) URLs
    +     *
    +     * @var array
    +     */
    +    public static $allowedDomains = [];
    +
    +    /**
    +     * Names of allowed XML processing instructions
    +     *
    +     * @var array
    +     */
    +    public static $allowedPIs = [];
    +
    +    /**
    +     * The document type (`'HTML'` or `'XML'`)
    +     * (to be set in child classes)
    +     *
    +     * @var string
    +     */
    +    protected static $type = 'XML';
    +
    +    /**
    +     * Sanitizes the given string
    +     *
    +     * @param string $string
    +     * @return string
    +     *
    +     * @throws \Kirby\Exception\InvalidArgumentException If the file couldn't be parsed
    +     */
    +    public static function sanitize(string $string): string
    +    {
    +        $dom = static::parse($string);
    +        $dom->sanitize(static::options());
    +        return $dom->toString();
    +    }
    +
    +    /**
    +     * Validates file contents
    +     *
    +     * @param string $string
    +     * @return void
    +     *
    +     * @throws \Kirby\Exception\InvalidArgumentException If the file couldn't be parsed
    +     * @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation
    +     */
    +    public static function validate(string $string): void
    +    {
    +        $dom = static::parse($string);
    +        $errors = $dom->sanitize(static::options());
    +        if (count($errors) > 0) {
    +            // there may be multiple errors, we can only throw one of them at a time
    +            throw $errors[0];
    +        }
    +    }
    +
    +    /**
    +     * Custom callback for additional attribute sanitization
    +     * @internal
    +     *
    +     * @param \DOMAttr $attr
    +     * @return array Array with exception objects for each modification
    +     */
    +    public static function sanitizeAttr(DOMAttr $attr): array
    +    {
    +        // to be extended in child classes
    +        return [];
    +    }
    +
    +    /**
    +     * Custom callback for additional element sanitization
    +     * @internal
    +     *
    +     * @param \DOMElement $element
    +     * @return array Array with exception objects for each modification
    +     */
    +    public static function sanitizeElement(DOMElement $element): array
    +    {
    +        // to be extended in child classes
    +        return [];
    +    }
    +
    +    /**
    +     * Custom callback for additional doctype validation
    +     * @internal
    +     *
    +     * @param \DOMDocumentType $doctype
    +     * @return void
    +     */
    +    public static function validateDoctype(DOMDocumentType $doctype): void
    +    {
    +        // to be extended in child classes
    +    }
    +
    +    /**
    +     * Returns the sanitization options for the handler
    +     * (to be extended in child classes)
    +     *
    +     * @return array
    +     */
    +    protected static function options(): array
    +    {
    +        return [
    +            'allowedDataUris' => static::$allowedDataUris,
    +            'allowedDomains'  => static::$allowedDomains,
    +            'allowedPIs'      => static::$allowedPIs,
    +            'attrCallback'    => [static::class, 'sanitizeAttr'],
    +            'doctypeCallback' => [static::class, 'validateDoctype'],
    +            'elementCallback' => [static::class, 'sanitizeElement'],
    +        ];
    +    }
    +
    +    /**
    +     * Parses the given string into a `Toolkit\Dom` object
    +     *
    +     * @param string $string
    +     * @return \Kirby\Toolkit\Dom
    +     *
    +     * @throws \Kirby\Exception\InvalidArgumentException If the file couldn't be parsed
    +     */
    +    protected static function parse(string $string)
    +    {
    +        return new Dom($string, static::$type);
    +    }
    +}
    
  • src/Sane/Html.php+144 0 added
    @@ -0,0 +1,144 @@
    +<?php
    +
    +namespace Kirby\Sane;
    +
    +/**
    + * Sane handler for HTML files
    + * @since 3.5.8
    + *
    + * @package   Kirby Sane
    + * @author    Bastian Allgeier <bastian@getkirby.com>,
    + *            Lukas Bestle <lukas@getkirby.com>
    + * @link      https://getkirby.com
    + * @copyright Bastian Allgeier GmbH
    + * @license   https://opensource.org/licenses/MIT
    + */
    +class Html extends DomHandler
    +{
    +    /**
    +     * Global list of allowed attribute prefixes
    +     *
    +     * @var array
    +     */
    +    public static $allowedAttrPrefixes = [
    +        'aria-',
    +        'data-',
    +    ];
    +
    +    /**
    +     * Global list of allowed attributes
    +     *
    +     * @var array
    +     */
    +    public static $allowedAttrs = [
    +        'class',
    +        'id',
    +    ];
    +
    +    /**
    +     * Allowed hostnames for HTTP(S) URLs
    +     *
    +     * @var array
    +     */
    +    public static $allowedDomains = true;
    +
    +    /**
    +     * Associative array of all allowed tag names with the value
    +     * of either an array with the list of all allowed attributes
    +     * for this tag, `true` to allow any attribute from the
    +     * `allowedAttrs` list or `false` to allow the tag without
    +     * any attributes
    +     *
    +     * @var array
    +     */
    +    public static $allowedTags = [
    +        'a'          => ['href', 'rel', 'title', 'target'],
    +        'abbr'       => ['title'],
    +        'b'          => true,
    +        'body'       => true,
    +        'blockquote' => true,
    +        'br'         => true,
    +        'code'       => true,
    +        'dl'         => true,
    +        'dd'         => true,
    +        'del'        => true,
    +        'div'        => true,
    +        'dt'         => true,
    +        'em'         => true,
    +        'footer'     => true,
    +        'h1'         => true,
    +        'h2'         => true,
    +        'h3'         => true,
    +        'h4'         => true,
    +        'h5'         => true,
    +        'h6'         => true,
    +        'hr'         => true,
    +        'html'       => true,
    +        'i'          => true,
    +        'ins'        => true,
    +        'li'         => true,
    +        'small'      => true,
    +        'span'       => true,
    +        'strong'     => true,
    +        'sub'        => true,
    +        'sup'        => true,
    +        'ol'         => true,
    +        'p'          => true,
    +        'pre'        => true,
    +        's'          => true,
    +        'u'          => true,
    +        'ul'         => true,
    +    ];
    +
    +    /**
    +     * Array of explicitly disallowed tags
    +     *
    +     * IMPORTANT: Use lower-case names here because
    +     * of the case-insensitive matching
    +     *
    +     * @var array
    +     */
    +    public static $disallowedTags = [
    +        'iframe',
    +        'meta',
    +        'object',
    +        'script',
    +        'style',
    +    ];
    +
    +    /**
    +     * List of attributes that may contain URLs
    +     *
    +     * @var array
    +     */
    +    public static $urlAttrs = [
    +        'href',
    +        'src',
    +        'xlink:href',
    +    ];
    +
    +    /**
    +     * The document type (`'HTML'` or `'XML'`)
    +     *
    +     * @var string
    +     */
    +    protected static $type = 'HTML';
    +
    +    /**
    +     * Returns the sanitization options for the handler
    +     *
    +     * @return array
    +     */
    +    protected static function options(): array
    +    {
    +        return array_merge(parent::options(), [
    +            'allowedAttrPrefixes' => static::$allowedAttrPrefixes,
    +            'allowedAttrs'        => static::$allowedAttrs,
    +            'allowedNamespaces'   => [],
    +            'allowedPIs'          => [],
    +            'allowedTags'         => static::$allowedTags,
    +            'disallowedTags'      => static::$disallowedTags,
    +            'urlAttrs'            => static::$urlAttrs,
    +        ]);
    +    }
    +}
    
  • src/Toolkit/Dom.php+920 0 added
    @@ -0,0 +1,920 @@
    +<?php
    +
    +namespace Kirby\Toolkit;
    +
    +use Closure;
    +use DOMAttr;
    +use DOMDocument;
    +use DOMDocumentType;
    +use DOMElement;
    +use DOMNode;
    +use DOMProcessingInstruction;
    +use DOMXPath;
    +use Kirby\Cms\App;
    +use Kirby\Exception\Exception;
    +use Kirby\Exception\InvalidArgumentException;
    +
    +/**
    + * Helper class for DOM handling using the DOMDocument class
    + * @since 3.5.8
    + *
    + * @package   Kirby Toolkit
    + * @author    Bastian Allgeier <bastian@getkirby.com>,
    + *            Lukas Bestle <lukas@getkirby.com>
    + * @link      https://getkirby.com
    + * @copyright Bastian Allgeier GmbH
    + * @license   https://opensource.org/licenses/MIT
    + */
    +class Dom
    +{
    +    /**
    +     * Cache for the HTML body
    +     *
    +     * @var \DOMElement|null
    +     */
    +    protected $body;
    +
    +    /**
    +     * The original input code as
    +     * passed to the constructor
    +     *
    +     * @var string
    +     */
    +    protected $code;
    +
    +    /**
    +     * Document object
    +     *
    +     * @var \DOMDocument
    +     */
    +    protected $doc;
    +
    +    /**
    +     * Document type (`'HTML'` or `'XML'`)
    +     *
    +     * @var string
    +     */
    +    protected $type;
    +
    +    /**
    +     * Class constructor
    +     *
    +     * @param string $code XML or HTML code
    +     * @param string $type Document type (`'HTML'` or `'XML'`)
    +     */
    +    public function __construct(string $code, string $type = 'HTML')
    +    {
    +        $this->code = $code;
    +        $this->doc  = new DOMDocument();
    +
    +        $loaderSetting = null;
    +        if (\PHP_VERSION_ID < 80000) {
    +            // prevent loading external entities to protect against XXE attacks;
    +            // only needed for PHP versions before 8.0 (the function was deprecated
    +            // as the disabled state is the new default in PHP 8.0+)
    +            $loaderSetting = libxml_disable_entity_loader(true);
    +        }
    +
    +        // switch to "user error handling"
    +        $intErrorsSetting = libxml_use_internal_errors(true);
    +
    +        $this->type = strtoupper($type);
    +        if ($this->type === 'HTML') {
    +            // ensure proper parsing for HTML snippets
    +            if (preg_match('/<(html|body)[> ]/i', $code) !== 1) {
    +                $code = '<body>' . $code . '</body>';
    +            }
    +
    +            // the loadHTML() method expects ISO-8859-1 by default;
    +            // force parsing as UTF-8 by injecting an XML declaration
    +            $xmlDeclaration = 'encoding="UTF-8" id="' . Str::random(10) . '"';
    +            $load = $this->doc->loadHTML('<?xml ' . $xmlDeclaration . '>' . $code);
    +
    +            // remove the injected XML declaration again
    +            $pis = $this->query('//processing-instruction()');
    +            foreach (iterator_to_array($pis, false) as $pi) {
    +                if ($pi->data === $xmlDeclaration) {
    +                    static::remove($pi);
    +                }
    +            }
    +
    +            // remove the default doctype
    +            if (Str::contains($code, '<!DOCTYPE ', true) === false) {
    +                static::remove($this->doc->doctype);
    +            }
    +        } else {
    +            $load = $this->doc->loadXML($code);
    +        }
    +
    +        if (\PHP_VERSION_ID < 80000) {
    +            // ensure that we don't alter global state by
    +            // resetting the original value
    +            libxml_disable_entity_loader($loaderSetting);
    +        }
    +
    +        // get one error for use below and reset the global state
    +        $error = libxml_get_last_error();
    +        libxml_clear_errors();
    +        libxml_use_internal_errors($intErrorsSetting);
    +
    +        if ($load !== true) {
    +            $message = 'The markup could not be parsed';
    +
    +            if ($error !== false) {
    +                $message .= ': ' . $error->message;
    +            }
    +
    +            throw new InvalidArgumentException([
    +                'fallback' => $message,
    +                'details'  => compact('error')
    +            ]);
    +        }
    +    }
    +
    +    /**
    +     * Returns the HTML body if one exists
    +     *
    +     * @return \DOMElement|null
    +     */
    +    public function body()
    +    {
    +        return $this->body = $this->body ?? $this->query('/html/body')[0] ?? null;
    +    }
    +
    +    /**
    +     * Returns the document object
    +     *
    +     * @return \DOMDocument
    +     */
    +    public function document()
    +    {
    +        return $this->doc;
    +    }
    +
    +    /**
    +     * Extracts all URLs wrapped in a url() wrapper. E.g. for style attributes.
    +     * @internal
    +     *
    +     * @param string $value
    +     * @return array
    +     */
    +    public static function extractUrls(string $value): array
    +    {
    +        // remove invisible ASCII characters from the value
    +        $value = trim(preg_replace('/[^ -~]/u', '', $value));
    +
    +        $count = preg_match_all(
    +            '!url\(\s*[\'"]?(.*?)[\'"]?\s*\)!i',
    +            $value,
    +            $matches,
    +            PREG_PATTERN_ORDER
    +        );
    +
    +        if (is_int($count) === true && $count > 0) {
    +            return $matches[1];
    +        }
    +
    +        return [];
    +    }
    +
    +    /**
    +     * Checks for allowed attributes according to the allowlist
    +     * @internal
    +     *
    +     * @param \DOMAttr $attr
    +     * @param array $options
    +     * @return true|string If not allowed, an error message is returned
    +     */
    +    public static function isAllowedAttr(DOMAttr $attr, array $options)
    +    {
    +        $allowedTags = $options['allowedTags'];
    +
    +        // check if the attribute is in the list of global allowed attributes
    +        $isAllowedGlobalAttr = static::isAllowedGlobalAttr($attr, $options);
    +
    +        // no specific tag attribute list
    +        if (is_array($allowedTags) === false) {
    +            return $isAllowedGlobalAttr;
    +        }
    +
    +        // configuration per tag name
    +        $tagName            = $attr->ownerElement->nodeName;
    +        $listedTagName      = static::listContainsName(array_keys($options['allowedTags']), $attr->ownerElement, $options);
    +        $allowedAttrsForTag = $listedTagName ? ($allowedTags[$listedTagName] ?? true) : true;
    +
    +        // the element allows all global attributes
    +        if ($allowedAttrsForTag === true) {
    +            return $isAllowedGlobalAttr;
    +        }
    +
    +        // specific attributes are allowed in addition to the global ones
    +        if (is_array($allowedAttrsForTag) === true) {
    +            // if allowed globally, we don't need further checks
    +            if ($isAllowedGlobalAttr === true) {
    +                return true;
    +            }
    +
    +            // otherwise the tag configuration decides
    +            if (static::listContainsName($allowedAttrsForTag, $attr, $options) !== false) {
    +                return true;
    +            }
    +
    +            return 'Not allowed by the "' . $tagName . '" element';
    +        }
    +
    +        return 'The "' . $tagName . '" element does not allow attributes';
    +    }
    +
    +    /**
    +     * Checks for allowed attributes according to the global allowlist
    +     * @internal
    +     *
    +     * @param \DOMAttr $attr
    +     * @param array $options
    +     * @return true|string If not allowed, an error message is returned
    +     */
    +    public static function isAllowedGlobalAttr(DOMAttr $attr, array $options)
    +    {
    +        $allowedAttrs = $options['allowedAttrs'];
    +
    +        if ($allowedAttrs === true) {
    +            // all attributes are allowed
    +            return true;
    +        }
    +
    +        if (
    +            static::listContainsName(
    +                $options['allowedAttrPrefixes'],
    +                $attr,
    +                $options,
    +                function ($expected, $real): bool {
    +                    return Str::startsWith($real, $expected);
    +                }
    +            ) !== false
    +        ) {
    +            return true;
    +        }
    +
    +        if (
    +            is_array($allowedAttrs) === true &&
    +            static::listContainsName($allowedAttrs, $attr, $options) !== false
    +        ) {
    +            return true;
    +        }
    +
    +        return 'Not included in the global allowlist';
    +    }
    +
    +    /**
    +     * Checks if the URL is acceptable for URL attributes
    +     * @internal
    +     *
    +     * @param string $url
    +     * @param array $options
    +     * @return true|string If not allowed, an error message is returned
    +     */
    +    public static function isAllowedUrl(string $url, array $options)
    +    {
    +        $url = Str::lower($url);
    +
    +        // allow empty URL values
    +        if (empty($url) === true) {
    +            return true;
    +        }
    +
    +        // allow URLs that point to fragments inside the file
    +        if (mb_substr($url, 0, 1) === '#') {
    +            return true;
    +        }
    +
    +        // disallow protocol-relative URLs
    +        if (mb_substr($url, 0, 2) === '//') {
    +            return 'Protocol-relative URLs are not allowed';
    +        }
    +
    +        // allow site-internal URLs that didn't match the
    +        // protocol-relative check above
    +        if (mb_substr($url, 0, 1) === '/') {
    +            // if a CMS instance is active, only allow the URL
    +            // if it doesn't point outside of the index URL
    +            if ($kirby = App::instance(null, true)) {
    +                $indexUrl = $kirby->url('index', true)->path()->toString(true);
    +
    +                if (Str::startsWith($url, $indexUrl) !== true) {
    +                    return 'The URL points outside of the site index URL';
    +                }
    +
    +                // disallow directory traversal outside of the index URL
    +                // TODO: the ../ sequences could be cleaned from the URL
    +                //       before the check by normalizing the URL; then the
    +                //       check above can also validate URLs with ../ sequences
    +                if (
    +                    Str::contains($url, '../') !== false ||
    +                    Str::contains($url, '..\\') !== false
    +                ) {
    +                    return 'The ../ sequence is not allowed in relative URLs';
    +                }
    +            }
    +
    +            // no active CMS instance, always allow site-internal URLs
    +            return true;
    +        }
    +
    +        // allow relative URLs (= URLs without a scheme);
    +        // this is either a URL without colon or one where the
    +        // part before the colon is definitely no valid scheme;
    +        // see https://url.spec.whatwg.org/#url-writing
    +        if (
    +            Str::contains($url, ':') === false ||
    +            Str::contains(Str::before($url, ':'), '/') === true
    +        ) {
    +            // disallow directory traversal as we cannot know
    +            // in which URL context the URL will be printed
    +            if (
    +                Str::contains($url, '../') !== false ||
    +                Str::contains($url, '..\\') !== false
    +            ) {
    +                return 'The ../ sequence is not allowed in relative URLs';
    +            }
    +
    +            return true;
    +        }
    +
    +        // allow specific HTTP(S) URLs
    +        if (
    +            Str::startsWith($url, 'http://') === true ||
    +            Str::startsWith($url, 'https://') === true
    +        ) {
    +            if ($options['allowedDomains'] === true) {
    +                return true;
    +            }
    +
    +            $hostname = parse_url($url, PHP_URL_HOST);
    +
    +            if (in_array($hostname, $options['allowedDomains']) === true) {
    +                return true;
    +            }
    +
    +            return 'The hostname "' . $hostname . '" is not allowed';
    +        }
    +
    +        // allow listed data URIs
    +        if (Str::startsWith($url, 'data:') === true) {
    +            if ($options['allowedDataUris'] === true) {
    +                return true;
    +            }
    +
    +            foreach ($options['allowedDataUris'] as $dataAttr) {
    +                if (Str::startsWith($url, $dataAttr) === true) {
    +                    return true;
    +                }
    +            }
    +
    +            return 'Invalid data URI';
    +        }
    +
    +        // allow valid email addresses
    +        if (Str::startsWith($url, 'mailto:') === true) {
    +            $address = Str::after($url, 'mailto:');
    +
    +            if (empty($address) === true || V::email($address) === true) {
    +                return true;
    +            }
    +
    +            return 'Invalid email address';
    +        }
    +
    +        // allow valid telephone numbers
    +        if (Str::startsWith($url, 'tel:') === true) {
    +            $address = Str::after($url, 'tel:');
    +
    +            if (
    +                empty($address) === true ||
    +                preg_match('!^[+]?[0-9]+$!', $address) === 1
    +            ) {
    +                return true;
    +            }
    +
    +            return 'Invalid telephone number';
    +        }
    +
    +        return 'Unknown URL type';
    +    }
    +
    +    /**
    +     * Check if the XML extension is installed on the server.
    +     * Otherwise DOMDocument won't be available and the Dom cannot
    +     * work at all.
    +     *
    +     * @return bool
    +     *
    +     * @codeCoverageIgnore
    +     */
    +    public static function isSupported(): bool
    +    {
    +        return class_exists('DOMDocument') === true;
    +    }
    +
    +    /**
    +     * Returns the XML or HTML markup contained in the node
    +     *
    +     * @param \DOMNode $node
    +     * @return string
    +     */
    +    public function innerMarkup(DOMNode $node): string
    +    {
    +        $markup = '';
    +        $method = 'save' . $this->type;
    +
    +        foreach ($node->childNodes as $child) {
    +            $markup .= $node->ownerDocument->$method($child);
    +        }
    +
    +        return $markup;
    +    }
    +
    +    /**
    +     * Checks if a list contains the name of a node considering
    +     * the allowed namespaces
    +     * @internal
    +     *
    +     * @param array $list
    +     * @param \DOMNode $node
    +     * @param array $options See `Dom::sanitize()`
    +     * @param \Closure|null Comparison callback that returns whether the expected and real name match
    +     * @return string|false Matched name in the list or `false`
    +     */
    +    public static function listContainsName(array $list, DOMNode $node, array $options, ?Closure $compare = null)
    +    {
    +        $allowedNamespaces = $options['allowedNamespaces'];
    +        $localName         = $node->localName;
    +
    +        if ($compare === null) {
    +            $compare = function ($expected, $real): bool {
    +                return $expected === $real;
    +            };
    +        }
    +
    +        if ($allowedNamespaces === true) {
    +            // take the list as it is and only consider
    +            // exact matches of the local name (which will
    +            // contain a namespace if that namespace name
    +            // is not defined in the document)
    +            foreach ($list as $item) {
    +                if ($compare($item, $localName) === true) {
    +                    return $item;
    +                }
    +            }
    +
    +            return false;
    +        }
    +
    +        // we need to consider the namespaces
    +        foreach ($list as $item) {
    +            // try to find the expected origin namespace URI
    +            $namespaceUri = null;
    +            $itemLocal    = $item;
    +            if (Str::contains($item, ':') === true) {
    +                list($namespaceName, $itemLocal) = explode(':', $item);
    +                $namespaceUri = $allowedNamespaces[$namespaceName] ?? null;
    +            } else {
    +                // list items without namespace are from the default namespace
    +                $namespaceUri = $allowedNamespaces[''] ?? null;
    +            }
    +
    +            // try if we can find an exact namespaced match
    +            if ($namespaceUri === $node->namespaceURI && $compare($itemLocal, $localName) === true) {
    +                return $item;
    +            }
    +
    +            // also try to match the fully-qualified name
    +            // if the document doesn't define the namespace
    +            if ($node->namespaceURI === null && $compare($item, $node->nodeName) === true) {
    +                return $item;
    +            }
    +        }
    +
    +        return false;
    +    }
    +
    +    /**
    +     * Removes a node from the document
    +     *
    +     * @param \DOMNode $node
    +     * @return void
    +     */
    +    public static function remove(DOMNode $node): void
    +    {
    +        $node->parentNode->removeChild($node);
    +    }
    +
    +    /**
    +     * Executes an XPath query in the document
    +     *
    +     * @param string $query
    +     * @param \DOMNode|null $node Optional context node for relative queries
    +     * @return \DOMNodeList|false
    +     */
    +    public function query(string $query, ?DOMNode $node = null)
    +    {
    +        return (new DOMXPath($this->doc))->query($query, $node);
    +    }
    +
    +    /**
    +     * Sanitizes the DOM according to the provided configuration
    +     *
    +     * @param array $options Array with the following options:
    +     *                       - `allowedAttrPrefixes`: Global list of allowed attribute prefixes
    +     *                       like `data-` and `aria-`
    +     *                       - `allowedAttrs`: Global list of allowed attrs or `true` to allow
    +     *                       any attribute
    +     *                       - `allowedDataUris`: List of all MIME types that may be used in
    +     *                       data URIs (only checked in `urlAttrs` and inside `url()` wrappers)
    +     *                       or `true` for any
    +     *                       - `allowedDomains`: Allowed hostnames for HTTP(S) URLs in `urlAttrs`
    +     *                       and inside `url()` wrappers or `true` for any
    +     *                       - `allowedNamespaces`: Associative array of all allowed namespace URIs;
    +     *                       the array keys are reference names that can be referred to from the
    +     *                       `allowedAttrPrefixes`, `allowedAttrs`, `allowedTags`, `disallowedTags`
    +     *                       and `urlAttrs` lists; the namespace names as used in the document are *not*
    +     *                       validated; setting the whole option to `true` will allow any namespace
    +     *                       - `allowedPIs`: Names of allowed XML processing instructions or
    +     *                       `true` for any
    +     *                       - `allowedTags`: Associative array of all allowed tag names with the
    +     *                       value of either an array with the list of all allowed attributes for
    +     *                       this tag, `true` to allow any attribute from the `allowedAttrs` list
    +     *                       or `false` to allow the tag without any attributes;
    +     *                       not listed tags will be unwrapped (removed, but children are kept);
    +     *                       setting the whole option to `true` will allow any tag
    +     *                       - `attrCallback`: Closure that will receive each `DOMAttr` and may
    +     *                       modify it; the callback must return an array with exception
    +     *                       objects for each modification
    +     *                       - `disallowedTags`: Array of explicitly disallowed tags, which will
    +     *                       be removed completely including their children (matched case-insensitively)
    +     *                       - `doctypeCallback`: Closure that will receive the `DOMDocumentType`
    +     *                       and may throw exceptions on validation errors
    +     *                       - `elementCallback`: Closure that will receive each `DOMElement` and
    +     *                       may modify it; the callback must return an array with exception
    +     *                       objects for each modification
    +     *                       - `urlAttrs`: List of attributes that may contain URLs
    +     * @return array List of validation errors during sanitization
    +     *
    +     * @throws \Kirby\Exception\InvalidArgumentException If the doctype is not valid
    +     */
    +    public function sanitize(array $options): array
    +    {
    +        $options = array_merge([
    +            'allowedAttrPrefixes' => [],
    +            'allowedAttrs'        => true,
    +            'allowedDataUris'     => true,
    +            'allowedDomains'      => true,
    +            'allowedNamespaces'   => true,
    +            'allowedPIs'          => true,
    +            'allowedTags'         => true,
    +            'attrCallback'        => null,
    +            'disallowedTags'      => [],
    +            'doctypeCallback'     => null,
    +            'elementCallback'     => null,
    +            'urlAttrs'            => ['href', 'src', 'xlink:href'],
    +        ], $options);
    +
    +        $errors = [];
    +
    +        // validate the doctype;
    +        // convert the `DOMNodeList` to an array first, otherwise removing
    +        // nodes would shift the list and make subsequent operations fail
    +        foreach (iterator_to_array($this->doc->childNodes, false) as $child) {
    +            if (is_a($child, 'DOMDocumentType') === true) {
    +                $this->sanitizeDoctype($child, $options, $errors);
    +            }
    +        }
    +
    +        // validate all processing instructions like <?xml-stylesheet
    +        $pis = $this->query('//processing-instruction()');
    +        foreach (iterator_to_array($pis, false) as $pi) {
    +            $this->sanitizePI($pi, $options, $errors);
    +        }
    +
    +        // validate all elements in the document tree
    +        $elements = $this->doc->getElementsByTagName('*');
    +        foreach (iterator_to_array($elements, false) as $element) {
    +            $this->sanitizeElement($element, $options, $errors);
    +        }
    +
    +        return $errors;
    +    }
    +
    +    /**
    +     * Returns the document markup as string
    +     *
    +     * @param bool $normalize If set to `true`, the document
    +     *                        is exported with an XML declaration/
    +     *                        full HTML markup even if the input
    +     *                        didn't have them
    +     * @return string
    +     */
    +    public function toString(bool $normalize = false): string
    +    {
    +        if ($this->type === 'HTML') {
    +            $string = $this->exportHtml($normalize);
    +        } else {
    +            $string = $this->exportXml($normalize);
    +        }
    +
    +        // add trailing newline if the input contained one
    +        if (rtrim($this->code, "\r\n") !== $this->code) {
    +            $string .= "\n";
    +        }
    +
    +        return $string;
    +    }
    +
    +    /**
    +     * Removes a node from the document but keeps its children
    +     * by moving them one level up
    +     *
    +     * @param \DOMNode $node
    +     * @return void
    +     */
    +    public static function unwrap(DOMNode $node): void
    +    {
    +        foreach ($node->childNodes as $childNode) {
    +            // discard text nodes as they can be unexpected
    +            // directly in the parent element
    +            if (is_a($childNode, 'DOMText') === true) {
    +                continue;
    +            }
    +
    +            $node->parentNode->insertBefore(clone $childNode, $node);
    +        }
    +
    +        static::remove($node);
    +    }
    +
    +    /**
    +     * Returns the document markup as HTML string
    +     *
    +     * @param bool $normalize If set to `true`, the document
    +     *                        is exported with full HTML markup
    +     *                        even if the input didn't have it
    +     * @return string
    +     */
    +    protected function exportHtml(bool $normalize = false): string
    +    {
    +        // enforce export as UTF-8 by injecting a <meta> tag
    +        // at the beginning of the document
    +        $metaTag = $this->doc->createElement('meta');
    +        $metaTag->setAttribute('http-equiv', 'Content-Type');
    +        $metaTag->setAttribute('content', 'text/html; charset=utf-8');
    +        $metaTag->setAttribute('id', $metaId = Str::random(10));
    +        $this->doc->insertBefore($metaTag, $this->doc->documentElement);
    +
    +        if (
    +            preg_match('/<html[> ]/i', $this->code) === 1 ||
    +            $this->doc->doctype !== null ||
    +            $normalize === true
    +        ) {
    +            // full document
    +            $html = $this->doc->saveHTML();
    +        } elseif (preg_match('/<body[> ]/i', $this->code) === 1) {
    +            // there was a <body>, but no <html>; export just the <body>
    +            $html = $this->doc->saveHTML($this->body());
    +        } else {
    +            // just an HTML snippet
    +            $html = $this->innerMarkup($this->body());
    +        }
    +
    +        // remove the <meta> tag from the document and from the output
    +        static::remove($metaTag);
    +        $html = str_replace($this->doc->saveHTML($metaTag), '', $html);
    +
    +        return trim($html);
    +    }
    +
    +    /**
    +     * Returns the document markup as XML string
    +     *
    +     * @param bool $normalize If set to `true`, the document
    +     *                        is exported with an XML declaration
    +     *                        even if the input didn't have it
    +     * @return string
    +     */
    +    protected function exportXml(bool $normalize = false): string
    +    {
    +        if (Str::contains($this->code, '<?xml ', true) === false && $normalize === false) {
    +            // the input didn't contain an XML declaration;
    +            // only return child nodes, which omits it
    +            $result = [];
    +            foreach ($this->doc->childNodes as $node) {
    +                $result[] = $this->doc->saveXML($node);
    +            }
    +
    +            return implode("\n", $result);
    +        }
    +
    +        // ensure that the document is encoded as UTF-8
    +        // unless a different encoding was specified in
    +        // the input or before exporting
    +        if ($this->doc->encoding === null) {
    +            $this->doc->encoding = 'UTF-8';
    +        }
    +
    +        return trim($this->doc->saveXML());
    +    }
    +
    +    /**
    +     * Sanitizes an attribute
    +     *
    +     * @param \DOMAttr $attr
    +     * @param array $options See `Dom::sanitize()`
    +     * @param array $errors Array to store additional errors in by reference
    +     * @return void
    +     */
    +    protected function sanitizeAttr(DOMAttr $attr, array $options, array &$errors): void
    +    {
    +        $element = $attr->ownerElement;
    +        $name    = $attr->nodeName;
    +        $value   = $attr->value;
    +
    +        $allowed = static::isAllowedAttr($attr, $options);
    +        if ($allowed !== true) {
    +            $errors[] = new InvalidArgumentException(
    +                'The "' . $name . '" attribute (line ' .
    +                $attr->getLineNo() . ') is not allowed: ' .
    +                $allowed
    +            );
    +            $element->removeAttributeNode($attr);
    +        } elseif (static::listContainsName($options['urlAttrs'], $attr, $options) !== false) {
    +            $allowed = static::isAllowedUrl($value, $options);
    +            if ($allowed !== true) {
    +                $errors[] = new InvalidArgumentException(
    +                    'The URL is not allowed in attribute "' .
    +                    $name . '" (line ' . $attr->getLineNo() . '): ' .
    +                    $allowed
    +                );
    +                $element->removeAttributeNode($attr);
    +            }
    +        } else {
    +            // check for unwanted URLs in other attributes
    +            foreach (static::extractUrls($value) as $url) {
    +                $allowed = static::isAllowedUrl($url, $options);
    +                if ($allowed !== true) {
    +                    $errors[] = new InvalidArgumentException(
    +                        'The URL is not allowed in attribute "' .
    +                        $name . '" (line ' . $attr->getLineNo() . '): ' .
    +                        $allowed
    +                    );
    +                    $element->removeAttributeNode($attr);
    +                }
    +            }
    +        }
    +    }
    +
    +    /**
    +     * Sanitizes the doctype
    +     *
    +     * @param \DOMDocumentType $doctype
    +     * @param array $options See `Dom::sanitize()`
    +     * @param array $errors Array to store additional errors in by reference
    +     * @return void
    +     */
    +    protected function sanitizeDoctype(DOMDocumentType $doctype, array $options, array &$errors): void
    +    {
    +        try {
    +            $this->validateDoctype($doctype, $options);
    +        } catch (InvalidArgumentException $e) {
    +            $errors[] = $e;
    +            static::remove($doctype);
    +        }
    +    }
    +
    +    /**
    +     * Sanitizes a single DOM element and its attribute
    +     *
    +     * @param \DOMElement $element
    +     * @param array $options See `Dom::sanitize()`
    +     * @param array $errors Array to store additional errors in by reference
    +     * @return void
    +     */
    +    protected function sanitizeElement(DOMElement $element, array $options, array &$errors): void
    +    {
    +        $name = $element->nodeName;
    +
    +        // check defined namespaces (`xmlns` attributes);
    +        // we need to check this first as the namespace can affect
    +        // whether the tag name is valid according to the configuration
    +        if (is_array($options['allowedNamespaces']) === true) {
    +            $simpleXmlElement = simplexml_import_dom($element);
    +            foreach ($simpleXmlElement->getDocNamespaces(false, false) as $namespace => $value) {
    +                if (array_search($value, $options['allowedNamespaces']) === false) {
    +                    $element->removeAttributeNS($value, $namespace);
    +                    $errors[] = new InvalidArgumentException(
    +                        'The namespace "' . $value . '" is not allowed' .
    +                        ' (around line ' . $element->getLineNo() . ')'
    +                    );
    +                }
    +            }
    +        }
    +
    +        // check if the tag is blocklisted; remove the element completely
    +        if (
    +            static::listContainsName(
    +                $options['disallowedTags'],
    +                $element,
    +                $options,
    +                function ($expected, $real): bool {
    +                    return Str::lower($expected) === Str::lower($real);
    +                }
    +            ) !== false
    +        ) {
    +            $errors[] = new InvalidArgumentException(
    +                'The "' . $name . '" element (line ' .
    +                $element->getLineNo() . ') is not allowed'
    +            );
    +            static::remove($element);
    +
    +            return;
    +        }
    +
    +        // check if the tag is not allowlisted; keep children
    +        if ($options['allowedTags'] !== true) {
    +            $listedName = static::listContainsName(array_keys($options['allowedTags']), $element, $options);
    +
    +            if ($listedName === false) {
    +                $errors[] = new InvalidArgumentException(
    +                    'The "' . $name . '" element (line ' .
    +                    $element->getLineNo() . ') is not allowed, ' .
    +                    'but its children can be kept'
    +                );
    +                static::unwrap($element);
    +
    +                return;
    +            }
    +        }
    +
    +        // check attributes
    +        if ($element->hasAttributes()) {
    +            // convert the `DOMNodeList` to an array first, otherwise removing
    +            // attributes would shift the list and make subsequent operations fail
    +            foreach (iterator_to_array($element->attributes, false) as $attr) {
    +                $this->sanitizeAttr($attr, $options, $errors);
    +
    +                // custom check (if the attribute is still in the document)
    +                if ($attr->ownerElement !== null && $options['attrCallback']) {
    +                    $errors = array_merge($errors, $options['attrCallback']($attr) ?? []);
    +                }
    +            }
    +        }
    +
    +        // custom check
    +        if ($options['elementCallback']) {
    +            $errors = array_merge($errors, $options['elementCallback']($element) ?? []);
    +        }
    +    }
    +
    +    /**
    +     * Sanitizes a single XML processing instruction
    +     *
    +     * @param \DOMProcessingInstruction $pi
    +     * @param array $options See `Dom::sanitize()`
    +     * @param array $errors Array to store additional errors in by reference
    +     * @return void
    +     */
    +    protected function sanitizePI(DOMProcessingInstruction $pi, array $options, array &$errors): void
    +    {
    +        $name = $pi->nodeName;
    +
    +        // check for allow-listed processing instructions
    +        if (is_array($options['allowedPIs']) === true && in_array($name, $options['allowedPIs']) === false) {
    +            $errors[] = new InvalidArgumentException(
    +                'The "' . $name . '" processing instruction (line ' .
    +                $pi->getLineNo() . ') is not allowed'
    +            );
    +            static::remove($pi);
    +        }
    +    }
    +
    +    /**
    +     * Validates the document type
    +     *
    +     * @param \DOMDocumentType $doctype
    +     * @param array $options See `Dom::sanitize()`
    +     * @return void
    +     *
    +     * @throws \Kirby\Exception\InvalidArgumentException If the doctype is not valid
    +     */
    +    protected function validateDoctype(DOMDocumentType $doctype, array $options): void
    +    {
    +        if (empty($doctype->publicId) === false || empty($doctype->systemId) === false) {
    +            throw new InvalidArgumentException('The doctype must not reference external files');
    +        }
    +
    +        if (empty($doctype->internalSubset) === false) {
    +            throw new InvalidArgumentException('The doctype must not define a subset');
    +        }
    +
    +        if ($options['doctypeCallback']) {
    +            $options['doctypeCallback']($doctype);
    +        }
    +    }
    +}
    
  • tests/Form/Fields/WriterFieldTest.php+36 0 added
    @@ -0,0 +1,36 @@
    +<?php
    +
    +namespace Kirby\Form\Fields;
    +
    +class WriterFieldTest extends TestCase
    +{
    +    public function testDefaultProps()
    +    {
    +        $field = $this->field('writer');
    +
    +        $this->assertSame('writer', $field->type());
    +        $this->assertSame('writer', $field->name());
    +        $this->assertSame(false, $field->inline());
    +        $this->assertSame(true, $field->marks());
    +        $this->assertSame(null, $field->nodes());
    +        $this->assertTrue($field->save());
    +    }
    +
    +    public function testValueSanitized()
    +    {
    +        $field = $this->field('writer', [
    +            'value' => 'This is a <strong>test</strong><script>alert("Hacked")</script> with <em>formatting</em>'
    +        ]);
    +
    +        $this->assertSame('This is a <strong>test</strong> with <em>formatting</em>', $field->value());
    +    }
    +
    +    public function testValueTrimmed()
    +    {
    +        $field = $this->field('writer', [
    +            'value' => 'test '
    +        ]);
    +
    +        $this->assertSame('test', $field->value());
    +    }
    +}
    
  • tests/Sane/DomHandlerTest.php+48 0 added
    @@ -0,0 +1,48 @@
    +<?php
    +
    +namespace Kirby\Sane;
    +
    +require_once __DIR__ . '/mocks.php';
    +
    +/**
    + * @covers \Kirby\Sane\DomHandler
    + */
    +class DomHandlerTest extends TestCase
    +{
    +    protected $type = 'sane';
    +
    +    public function testSanitize()
    +    {
    +        $fixture = '<xml><test attr="value">Hello world</test></xml>';
    +        $this->assertSame($fixture, DomHandler::sanitize($fixture));
    +
    +        $fixture   = '<?xml version="1.0"?><xml><test>Hello world</test></xml>';
    +        $sanitized = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<xml><test>Hello world</test></xml>";
    +        $this->assertSame($sanitized, DomHandler::sanitize($fixture));
    +
    +        $fixture   = '<?xml version="1.0" standalone="no"?><xml><test>Hello world</test></xml>';
    +        $sanitized = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n<xml><test>Hello world</test></xml>";
    +        $this->assertSame($sanitized, DomHandler::sanitize($fixture));
    +    }
    +
    +    public function testValidate()
    +    {
    +        $this->assertNull(DomHandler::validate('<!DOCTYPE xml><xml><test attr="value">Hello world</test></xml>'));
    +    }
    +
    +    public function testValidateException1()
    +    {
    +        $this->expectException('Kirby\Exception\InvalidArgumentException');
    +        $this->expectExceptionMessage('The URL is not allowed in attribute "href" (line 2): Unknown URL type');
    +
    +        DomHandler::validate("<xml>\n<a href='javascript:alert(1)'></a>\n</xml>");
    +    }
    +
    +    public function testValidateException2()
    +    {
    +        $this->expectException('Kirby\Exception\InvalidArgumentException');
    +        $this->expectExceptionMessage('The doctype must not reference external files');
    +
    +        DomHandler::validate("<!DOCTYPE xml SYSTEM \"https://malicious.com/something.dtd\">\n<xml>\n<a href='javascript:alert(1)'></a>\n</xml>");
    +    }
    +}
    
  • tests/Sane/fixtures/html/allowed/full-document-1.html+11 0 added
    @@ -0,0 +1,11 @@
    +<!DOCTYPE html>
    +<html>
    +    <body>
    +        <p>Lorem ipsum dolor sit, amet consectetur adipisicing elit.</p>
    +        <ol>
    +            <li>One</li>
    +            <li>Two</li>
    +            <li>Three</li>
    +        </ol>
    +    </body>
    +</html>
    
  • tests/Sane/fixtures/html/allowed/full-document-2.html+10 0 added
    @@ -0,0 +1,10 @@
    +<html id="root">
    +    <body class="body">
    +        <p>Lorem ipsum dolor sit, amet consectetur adipisicing elit.</p>
    +        <ol>
    +            <li>One</li>
    +            <li>Two</li>
    +            <li>Three</li>
    +        </ol>
    +    </body>
    +</html>
    
  • tests/Sane/fixtures/html/allowed/sandbox-blocks.html+23 0 added
    @@ -0,0 +1,23 @@
    +<p>Far far away, behind the word mountains, far from the countries Vokalia and Consonantia, there live the blind texts. Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean. A small river named Duden flows by <strong>their</strong> place and supplies it with the necessary regelialia. It is a paradisematic country, in which roasted parts of sentences fly into your mouth. Even the all-powerful Pointing has no control about the blind texts it is an almost unorthographic life One day however a small line of blind text by the name of Lorem Ipsum decided to leave for the far World of Grammar.</p>
    +<p><strong>The Big Oxmox</strong> advised her not to do so, because there were thousands of bad Commas, wild Question Marks and devious Semikoli, but the Little Blind Text didn’t listen. She packed her seven versalia, put her initial into the belt and made herself on the way. When she reached the first hills of the Italic <a href="https://getkirby.com" rel="noopener noreferrer nofollow">Mountains</a>, she had a last view back on the skyline of her hometown Bookmarksgrove, the headline of Alphabet Village and the subline of her own road, the Line Lane. Pityful a rethoric question ran over her cheek, then she continued her way. On her way she met a copy.</p>
    +<blockquote>
    +    Time flies like an arrow; fruit flies like a banana
    +    <footer>
    +        <a href="https://en.wikipedia.org/wiki/Anthony_Oettinger" rel="noopener noreferrer nofollow">Anthony Oettinger</a>
    +    </footer>
    +</blockquote>
    +<h2>Let's put a heading here</h2>
    +<p>But nothing the <u>copy</u> said could convince her and so it didn’t take long until a few insidious Copy Writers ambushed her, made her drunk with Longe and Parole and dragged her into their agency, where they abused her for their <em>projects</em> again and again. And if she hasn’t been rewritten, then they are still using her. Far far away, behind the word mountains, far from the countries Vokalia and Consonantia, there live the blind texts. Separated they live in <small>Bookmarksgrove</small> right at the coast of the Semantics, a large language ocean.</p>
    +<h2>This is created by some good old markdown</h2>
    +<p>You can mix it with the other blocks to get even more <strong>flexibility</strong>. Markdown can be <strong>mixed</strong> with HTML too, which is nice. And of course <a href="https://getkirby.com">Kirbytags</a> are cool too.</p>
    +<ul><li><p>Look, it's a list</p></li><li><p>With some nice bullet points</p></li><li><p>Here's another one</p><ol><li><p>This is a nested list</p></li><li><p>With even more list items</p></li><li><p>Mixed list types are possible as well</p></li></ol></li></ul>
    +<h2>Stars stars stars</h2>
    +<p>Donec ullamcorper nulla non metus auctor fringilla. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Etiam porta sem malesuada magna <code>mollis</code> euismod. Nulla vitae elit libero, a pharetra augue. Nullam quis risus eget urna mollis ornare vel eu leo. Aenean lacinia bibendum nulla sed consectetur. Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit.</p>
    +<pre class=" language-php"><code class=" language-php"><span class="token php language-php"><span class="token delimiter important">&lt;?php</span> <span class="token keyword">foreach</span> <span class="token punctuation">(</span><span class="token variable">$page</span><span class="token operator">-</span><span class="token operator">&gt;</span><span class="token function">text</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">-</span><span class="token operator">&gt;</span><span class="token function">toBlocks</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token keyword">as</span> <span class="token variable">$block</span><span class="token punctuation">)</span><span class="token punctuation">:</span> <span class="token delimiter important">?&gt;</span></span>
    +<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>div</span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation">=</span><span class="token punctuation">"</span>block<span class="token punctuation">"</span></span><span class="token punctuation">&gt;</span></span>
    +<span class="token php language-php"><span class="token delimiter important">&lt;?=</span> <span class="token variable">$block</span> <span class="token delimiter important">?&gt;</span></span>
    +<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>div</span><span class="token punctuation">&gt;</span></span>
    +<span class="token php language-php"><span class="token delimiter important">&lt;?php</span> <span class="token keyword">endforeach</span> <span class="token delimiter important">?&gt;</span></span></code>
    +</pre>
    +<h3>Last heading – promised!</h3>
    +<p>Aenean lacinia bibendum nulla sed consectetur. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. <s>Nullam id dolor id nibh ultricies vehicula ut id elit.</s> Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor. Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
    
  • tests/Sane/HtmlTest.php+30 0 added
    @@ -0,0 +1,30 @@
    +<?php
    +
    +namespace Kirby\Sane;
    +
    +/**
    + * @covers \Kirby\Sane\Html
    + * @todo Add more tests from DOMPurify and the other test classes
    + */
    +class HtmlTest extends TestCase
    +{
    +    protected $type = 'html';
    +
    +    /**
    +     * @dataProvider allowedProvider
    +     */
    +    public function testAllowed(string $file)
    +    {
    +        $fixture = $this->fixture($file);
    +
    +        $this->assertNull(Html::validateFile($fixture));
    +
    +        $sanitized = Html::sanitize(file_get_contents($fixture));
    +        $this->assertStringEqualsFile($fixture, $sanitized);
    +    }
    +
    +    public function allowedProvider()
    +    {
    +        return $this->fixtureList('allowed', 'html');
    +    }
    +}
    
  • tests/Sane/TestCase.php+19 3 modified
    @@ -3,23 +3,39 @@
     namespace Kirby\Sane;
     
     use FilesystemIterator;
    +use Kirby\Toolkit\Dir;
    +use Kirby\Toolkit\F;
     use PHPUnit\Framework\TestCase as BaseTestCase;
     use RecursiveDirectoryIterator;
     use RecursiveIteratorIterator;
     
     class TestCase extends BaseTestCase
     {
    -    protected $type = 'svg';
    +    protected $type;
    +
    +    public function tearDown(): void
    +    {
    +        Dir::remove(__DIR__ . '/tmp');
    +    }
     
         /**
          * Returns the path to a test fixture file
          *
          * @param string $name Fixture name including file extension
    +     * @param bool $tmp If true, the fixture will be copied to a temporary location
          * @return string
          */
    -    protected function fixture(string $name): string
    +    protected function fixture(string $name, bool $tmp = false): string
         {
    -        return __DIR__ . '/fixtures/' . $this->type . '/' . $name;
    +        $fixtureRoot = __DIR__ . '/fixtures/' . $this->type . '/' . $name;
    +
    +        if ($tmp === false) {
    +            return $fixtureRoot;
    +        }
    +
    +        $tmpRoot = __DIR__ . '/tmp/' . $this->type . '/' . $name;
    +        F::copy($fixtureRoot, $tmpRoot);
    +        return $tmpRoot;
         }
     
         /**
    
  • tests/Toolkit/DomTest.php+1738 0 added
    @@ -0,0 +1,1738 @@
    +<?php
    +
    +namespace Kirby\Toolkit;
    +
    +use Closure;
    +use DOMAttr;
    +use DOMDocument;
    +use DOMDocumentType;
    +use DOMElement;
    +use Kirby\Cms\App;
    +use Kirby\Exception\InvalidArgumentException;
    +
    +/**
    + * @coversDefaultClass \Kirby\Toolkit\Dom
    + */
    +class DomTest extends TestCase
    +{
    +    public function parseSaveProvider(): array
    +    {
    +        return [
    +            // full document with doctype
    +            [
    +                'html',
    +                '<!DOCTYPE html><html><body><p>Lorem ipsum</p></body></html>',
    +                "<!DOCTYPE html>\n<html><body><p>Lorem ipsum</p></body></html>"
    +            ],
    +
    +            // full document with lowercase doctype
    +            [
    +                'html',
    +                '<!doctype html><html><body><p>Lorem ipsum</p></body></html>',
    +                "<!DOCTYPE html>\n<html><body><p>Lorem ipsum</p></body></html>"
    +            ],
    +
    +            // full document with doctype (with whitespace)
    +            [
    +                'html',
    +                "<!DOCTYPE html>\n\n<html><body><p>Lorem ipsum</p></body></html>\n\n",
    +                "<!DOCTYPE html>\n<html><body><p>Lorem ipsum</p></body></html>\n"
    +            ],
    +
    +            // Unicode string
    +            [
    +                'html',
    +                '<html><body><p>TEST — jūsų šildymo sistemai</p></body></html>'
    +            ],
    +
    +            // Unicode string with entities
    +            [
    +                'html',
    +                '<html><body><p>TEST &mdash;&nbsp;jūs&#371; &scaron;ildymo sistemai</p></body></html>',
    +                '<html><body><p>TEST — jūsų šildymo sistemai</p></body></html>',
    +            ],
    +
    +            // weird whitespace
    +            [
    +                'html',
    +                "<html>\n  <body>\n    <p>Lorem ipsum\n</p>\n  </body>\n</html>\n"
    +            ],
    +
    +            // HTML snippet with syntax issue
    +            [
    +                'html',
    +                '<p>This is <strong>important</strong!</p>',
    +                '<p>This is <strong>important</strong>!</p>'
    +            ],
    +
    +            // HTML snippet with doctype
    +            [
    +                'html',
    +                '<!DOCTYPE html><p>This is <strong>important</strong>!</p>',
    +                "<!DOCTYPE html>\n<html><body><p>This is <strong>important</strong>!</p></body></html>"
    +            ],
    +
    +            // HTML snippet without wrapper tag
    +            [
    +                'html',
    +                'This is <em>very</em> <strong>important</strong>!',
    +                'This is <em>very</em> <strong>important</strong>!'
    +            ],
    +
    +            // just a <body>
    +            [
    +                'html',
    +                '<body><p>This is <strong>important</strong>!</p></body>',
    +                '<body><p>This is <strong>important</strong>!</p></body>'
    +            ],
    +
    +            // just a <body> with attributes
    +            [
    +                'html',
    +                '<body id="test"><p>This is <strong>important</strong>!</p></body>',
    +                '<body id="test"><p>This is <strong>important</strong>!</p></body>'
    +            ],
    +
    +            // full document, but without body
    +            [
    +                'html',
    +                '<html><p>This is <strong>important</strong>!</p><html>',
    +                '<html><body><p>This is <strong>important</strong>!</p></body></html>'
    +            ],
    +
    +            // full document, but without body; <html> with attributes
    +            [
    +                'html',
    +                '<html id="test"><p>This is <strong>important</strong>!</p><html>',
    +                '<html id="test"><body><p>This is <strong>important</strong>!</p></body></html>'
    +            ],
    +
    +            // document with doctype
    +            [
    +                'xml',
    +                '<!DOCTYPE svg><svg><text>Lorem ipsum</text></svg>',
    +                "<!DOCTYPE svg>\n<svg><text>Lorem ipsum</text></svg>"
    +            ],
    +
    +            // document with doctype (with whitespace)
    +            [
    +                'xml',
    +                "<!DOCTYPE svg>\n\n<svg><text>Lorem ipsum</text></svg>",
    +                "<!DOCTYPE svg>\n<svg><text>Lorem ipsum</text></svg>"
    +            ],
    +
    +            // document with XML declaration
    +            [
    +                'xml',
    +                '<?xml version="1.0"?><svg><text>Lorem ipsum</text></svg>',
    +                "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg><text>Lorem ipsum</text></svg>"
    +            ],
    +
    +            // document with XML declaration and doctype
    +            [
    +                'xml',
    +                '<?xml version="1.0" encoding="utf-8"?><!DOCTYPE svg><svg><text>Lorem ipsum</text></svg>',
    +                "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE svg>\n<svg><text>Lorem ipsum</text></svg>"
    +            ],
    +
    +            // Unicode string
    +            [
    +                'xml',
    +                '<xml>TEST — jūsų šildymo sistemai</xml>'
    +            ],
    +
    +            // Unicode string with entities
    +            [
    +                'xml',
    +                '<svg><text>TEST &#x2014; jūs&#371; šildymo sistemai</text></svg>',
    +                '<svg><text>TEST — jūsų šildymo sistemai</text></svg>',
    +            ],
    +
    +            // weird whitespace
    +            [
    +                'xml',
    +                "<svg>\n  <text>\n    Lorem ipsum\n</text>\n  </svg>"
    +            ],
    +        ];
    +    }
    +
    +    /**
    +     * @dataProvider parseSaveProvider
    +     * @covers ::__construct
    +     * @covers ::toString
    +     * @covers ::exportHtml
    +     * @covers ::exportXml
    +     */
    +    public function testParseSave(string $type, string $code, string $expected = null)
    +    {
    +        $dom = new Dom($code, $type);
    +        $this->assertSame($expected ?? $code, $dom->toString());
    +    }
    +
    +    public function parseSaveNormalizeProvider(): array
    +    {
    +        return [
    +            // full document with doctype
    +            [
    +                'html',
    +                '<!DOCTYPE html><html><body><p>Lorem ipsum</p></body></html>',
    +                "<!DOCTYPE html>\n<html><body><p>Lorem ipsum</p></body></html>"
    +            ],
    +
    +            // Unicode string with entities
    +            [
    +                'html',
    +                '<html><body><p>TEST &mdash;&nbsp;jūs&#371; &scaron;ildymo sistemai</p></body></html>',
    +                '<html><body><p>TEST — jūsų šildymo sistemai</p></body></html>',
    +            ],
    +
    +            // weird whitespace
    +            [
    +                'html',
    +                "<html>\n  <body>\n    <p>Lorem ipsum\n</p>\n  </body>\n</html>\n"
    +            ],
    +
    +            // HTML snippet with syntax issue
    +            [
    +                'html',
    +                '<p>This is <strong>important</strong!</p>',
    +                '<html><body><p>This is <strong>important</strong>!</p></body></html>'
    +            ],
    +
    +            // HTML snippet with doctype
    +            [
    +                'html',
    +                '<!DOCTYPE html><p>This is <strong>important</strong>!</p>',
    +                "<!DOCTYPE html>\n<html><body><p>This is <strong>important</strong>!</p></body></html>"
    +            ],
    +
    +            // just a <body>
    +            [
    +                'html',
    +                '<body><p>This is <strong>important</strong>!</p></body>',
    +                '<html><body><p>This is <strong>important</strong>!</p></body></html>'
    +            ],
    +
    +            // just a <body> with attributes
    +            [
    +                'html',
    +                '<body id="test"><p>This is <strong>important</strong>!</p></body>',
    +                '<html><body id="test"><p>This is <strong>important</strong>!</p></body></html>'
    +            ],
    +
    +            // full document, but without body
    +            [
    +                'html',
    +                '<html><p>This is <strong>important</strong>!</p><html>',
    +                '<html><body><p>This is <strong>important</strong>!</p></body></html>'
    +            ],
    +
    +            // full document, but without body; <html> with attributes
    +            [
    +                'html',
    +                '<html id="test"><p>This is <strong>important</strong>!</p><html>',
    +                '<html id="test"><body><p>This is <strong>important</strong>!</p></body></html>'
    +            ],
    +
    +            // document with doctype
    +            [
    +                'xml',
    +                '<!DOCTYPE svg><svg><text>Lorem ipsum</text></svg>',
    +                "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE svg>\n<svg><text>Lorem ipsum</text></svg>"
    +            ],
    +
    +            // document with doctype (with whitespace)
    +            [
    +                'xml',
    +                "<!DOCTYPE svg>\n\n<svg><text>Lorem ipsum</text></svg>",
    +                "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE svg>\n<svg><text>Lorem ipsum</text></svg>"
    +            ],
    +
    +            // document with XML declaration
    +            [
    +                'xml',
    +                '<?xml version="1.0"?><svg><text>Lorem ipsum</text></svg>',
    +                "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg><text>Lorem ipsum</text></svg>"
    +            ],
    +
    +            // document with XML declaration and doctype
    +            [
    +                'xml',
    +                '<?xml version="1.0" encoding="utf-8"?><!DOCTYPE svg><svg><text>Lorem ipsum</text></svg>',
    +                "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE svg>\n<svg><text>Lorem ipsum</text></svg>"
    +            ],
    +
    +            // Unicode string
    +            [
    +                'xml',
    +                '<xml>TEST — jūsų šildymo sistemai</xml>',
    +                "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<xml>TEST — jūsų šildymo sistemai</xml>"
    +            ],
    +
    +            // Unicode string with UTF-8 XML declaration
    +            [
    +                'xml',
    +                '<?xml version="1.0" encoding="utf-8"?><xml>TEST — jūsų šildymo sistemai</xml>',
    +                "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<xml>TEST — jūsų šildymo sistemai</xml>"
    +            ],
    +
    +            // weird whitespace
    +            [
    +                'xml',
    +                "<svg>\n  <text>\n    Lorem ipsum\n</text>\n  </svg>\n",
    +                "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg>\n  <text>\n    Lorem ipsum\n</text>\n  </svg>\n"
    +            ],
    +
    +            // weird whitespace with XML declaration
    +            [
    +                'xml',
    +                "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg>\n  <text>\n    Lorem ipsum\n</text>\n  </svg>\n"
    +            ],
    +        ];
    +    }
    +
    +    /**
    +     * @dataProvider parseSaveNormalizeProvider
    +     * @covers ::__construct
    +     * @covers ::toString
    +     * @covers ::exportHtml
    +     * @covers ::exportXml
    +     */
    +    public function testParseSaveNormalize(string $type, string $code, string $expected = null)
    +    {
    +        $dom = new Dom($code, $type);
    +        $this->assertSame($expected ?? $code, $dom->toString(true));
    +    }
    +
    +    /**
    +     * @covers ::__construct
    +     */
    +    public function testParseInvalid()
    +    {
    +        $this->expectException('Kirby\Exception\InvalidArgumentException');
    +        $this->expectExceptionMessage("The markup could not be parsed: Start tag expected, '<' not found");
    +
    +        new Dom('{"this": "is not XML"}', 'XML');
    +    }
    +
    +    /**
    +     * @covers ::body
    +     */
    +    public function testBody()
    +    {
    +        // with full document input
    +        $dom = new Dom('<html><body class="test"><p>This is a test</p></body></html>', 'HTML');
    +        $this->assertInstanceOf('DOMElement', $dom->body());
    +        $this->assertSame('<body class="test"><p>This is a test</p></body>', $dom->document()->saveHtml($dom->body()));
    +
    +        // partial document 1
    +        $dom = new Dom('<body class="test"><p>This is a test</p></body>', 'HTML');
    +        $this->assertInstanceOf('DOMElement', $dom->body());
    +        $this->assertSame('<body class="test"><p>This is a test</p></body>', $dom->document()->saveHtml($dom->body()));
    +
    +        // partial document 2
    +        $dom = new Dom('<p>This is a test</p>', 'HTML');
    +        $this->assertInstanceOf('DOMElement', $dom->body());
    +        $this->assertSame('<body><p>This is a test</p></body>', $dom->document()->saveHtml($dom->body()));
    +
    +        // document without body
    +        $dom = new Dom('<html><head></head></html>', 'HTML');
    +        $this->assertNull($dom->body());
    +    }
    +
    +    /**
    +     * @covers ::document
    +     */
    +    public function testDocument()
    +    {
    +        $dom = new Dom('<p>This is a test</p>', 'HTML');
    +        $this->assertSame("<html><body><p>This is a test</p></body></html>\n", $dom->document()->saveHtml());
    +    }
    +
    +    public function extractUrlsProvider(): array
    +    {
    +        return [
    +            // empty input
    +            [
    +                '',
    +                []
    +            ],
    +
    +            // one URL
    +            [
    +                'url(https://getkirby.com)',
    +                ['https://getkirby.com']
    +            ],
    +            [
    +                'url("https://getkirby.com/?test=test&another=test")',
    +                ['https://getkirby.com/?test=test&another=test']
    +            ],
    +            [
    +                'url(\'https://getkirby.com\')',
    +                ['https://getkirby.com']
    +            ],
    +            [
    +                'url(\'https://getkirby.com)',
    +                ['https://getkirby.com']
    +            ],
    +            [
    +                'url(https://getkirby.com")',
    +                ['https://getkirby.com']
    +            ],
    +            [
    +                'url(  https://getkirby.com   )',
    +                ['https://getkirby.com']
    +            ],
    +            [
    +                'url(  "https://getkirby.com"   )',
    +                ['https://getkirby.com']
    +            ],
    +            [
    +                'url(  "  https://getkirby.com "   )',
    +                ['  https://getkirby.com ']
    +            ],
    +            [
    +                'UrL(  "  https://getkirby.com "   )',
    +                ['  https://getkirby.com ']
    +            ],
    +
    +            // multiple URLs
    +            [
    +                'url(https://getkirby.com); url(https://getkirby.com/test)',
    +                ['https://getkirby.com', 'https://getkirby.com/test']
    +            ],
    +            [
    +                'url("https://getkirby.com/?test=test&another=test"), url(https://getkirby.com/test)',
    +                ['https://getkirby.com/?test=test&another=test', 'https://getkirby.com/test']
    +            ],
    +            [
    +                'This is a test with an url(\'https://getkirby.com\') and another url("https://getkirby.com/test").',
    +                ['https://getkirby.com', 'https://getkirby.com/test']
    +            ],
    +            [
    +                'An url(\'https://getkirby.com) and another url(https://getkirby.com/test")',
    +                ['https://getkirby.com', 'https://getkirby.com/test']
    +            ],
    +            [
    +                'url(  https://getkirby.com   ) and URl(  "https://getkirby.com/test"   ) and uRl(  "  https://getkirby.com/another-test "   )',
    +                ['https://getkirby.com', 'https://getkirby.com/test', '  https://getkirby.com/another-test ']
    +            ],
    +
    +            // invisible characters
    +            [
    +                "ur\0l\0\0(\0'test://te\0st'\0)\0",
    +                ['test://test']
    +            ],
    +        ];
    +    }
    +
    +    /**
    +     * @dataProvider extractUrlsProvider
    +     * @covers ::extractUrls
    +     */
    +    public function testExtractUrls(string $url, array $expected)
    +    {
    +        $this->assertSame($expected, Dom::extractUrls($url));
    +    }
    +
    +    public function isAllowedAttrProvider(): array
    +    {
    +        return [
    +            // only the global allowlist
    +            [
    +                'html',
    +                'class',
    +                ['class'],
    +                [],
    +                true,
    +
    +                true
    +            ],
    +            [
    +                'html',
    +                'class',
    +                ['class'],
    +                [],
    +                [],
    +
    +                true
    +            ],
    +            [
    +                'html',
    +                'aria-label',
    +                [],
    +                ['aria-'],
    +                true,
    +
    +                true
    +            ],
    +            [
    +                'html',
    +                'test:test-attr',
    +                [],
    +                ['test:test-'],
    +                true,
    +
    +                true
    +            ],
    +            [
    +                'html',
    +                'id',
    +                ['class'],
    +                ['aria-'],
    +                true,
    +
    +                'Not included in the global allowlist'
    +            ],
    +            [
    +                'html',
    +                'test-attr',
    +                [],
    +                ['test:test-'],
    +                true,
    +
    +                'Not included in the global allowlist'
    +            ],
    +            [
    +                'html',
    +                'id',
    +                ['class'],
    +                ['aria-'],
    +                [],
    +
    +                'Not included in the global allowlist'
    +            ],
    +
    +            // specific configuration by tag
    +            [
    +                'html',
    +                'class',
    +                ['class'],
    +                ['aria-'],
    +                ['html' => true],
    +
    +                true
    +            ],
    +            [
    +                'html',
    +                'aria-label',
    +                ['class'],
    +                ['aria-'],
    +                ['html' => true],
    +
    +                true
    +            ],
    +            [
    +                'html',
    +                'class',
    +                ['class'],
    +                ['aria-'],
    +                ['html' => ['class']],
    +
    +                true
    +            ],
    +            [
    +                'html',
    +                'id',
    +                ['class'],
    +                ['aria-'],
    +                ['html' => ['id']],
    +
    +                true
    +            ],
    +            [
    +                'html',
    +                'test:test-attr',
    +                ['class'],
    +                ['aria-'],
    +                ['html' => ['test:test-attr']],
    +
    +                true
    +            ],
    +            [
    +                'html',
    +                'onload',
    +                ['class'],
    +                ['aria-'],
    +                ['html' => ['id']],
    +
    +                'Not allowed by the "html" element'
    +            ],
    +            [
    +                'html',
    +                'class',
    +                ['class'],
    +                ['aria-'],
    +                ['html' => false],
    +
    +                'The "html" element does not allow attributes'
    +            ],
    +        ];
    +    }
    +
    +    /**
    +     * @dataProvider isAllowedAttrProvider
    +     * @covers ::isAllowedAttr
    +     */
    +    public function testIsAllowedAttr(string $tag, string $attr, $allowedAttrs, $allowedAttrPrefixes, $allowedTags, $expected)
    +    {
    +        $doc = new DOMDocument();
    +        $element = $doc->createElement($tag);
    +        $element->setAttributeNode($attr = new DOMAttr($attr));
    +
    +        $options = [
    +            'allowedAttrPrefixes' => $allowedAttrPrefixes,
    +            'allowedAttrs'        => $allowedAttrs,
    +            'allowedTags'         => $allowedTags,
    +            'allowedNamespaces'   => ['test' => 'https://example.com']
    +        ];
    +
    +        $this->assertSame($expected, Dom::isAllowedAttr($attr, $options));
    +    }
    +
    +    public function isAllowedGlobalAttrProvider(): array
    +    {
    +        return [
    +            // all attrs are allowed
    +            [
    +                'test',
    +                true,
    +                [],
    +
    +                true
    +            ],
    +
    +            // test by prefix
    +            [
    +                'data-test',
    +                [],
    +                ['aria-', 'data-'],
    +
    +                true
    +            ],
    +            [
    +                'test:test-attr',
    +                [],
    +                ['test:test-'],
    +
    +                true
    +            ],
    +            [
    +                'aaria-',
    +                [],
    +                ['aria-', 'data-'],
    +
    +                'Not included in the global allowlist'
    +            ],
    +            [
    +                'test',
    +                [],
    +                ['aria-', 'data-'],
    +
    +                'Not included in the global allowlist'
    +            ],
    +            [
    +                'test:test-attr',
    +                [],
    +                ['test-'],
    +
    +                'Not included in the global allowlist'
    +            ],
    +            [
    +                'custom:test-attr',
    +                [],
    +                ['test:test-'],
    +
    +                'Not included in the global allowlist'
    +            ],
    +
    +            // test by full name
    +            [
    +                'class',
    +                ['class', 'id'],
    +                [],
    +
    +                true
    +            ],
    +            [
    +                'test:test-attr',
    +                ['test:test-attr'],
    +                [],
    +
    +                true
    +            ],
    +            [
    +                'test',
    +                ['class', 'id'],
    +                [],
    +
    +                'Not included in the global allowlist'
    +            ],
    +            [
    +                'test:test-attr',
    +                ['test-attr'],
    +                [],
    +
    +                'Not included in the global allowlist'
    +            ],
    +            [
    +                'custom:test-attr',
    +                ['test:test-attr'],
    +                [],
    +
    +                'Not included in the global allowlist'
    +            ],
    +
    +            // either list may allow the attribute
    +            [
    +                'test-attr',
    +                ['test-attr'],
    +                ['aria-'],
    +
    +                true
    +            ],
    +            [
    +                'test-attr',
    +                ['test'],
    +                ['test-'],
    +
    +                true
    +            ],
    +            [
    +                'aria-label',
    +                ['test'],
    +                ['test-'],
    +
    +                'Not included in the global allowlist'
    +            ],
    +        ];
    +    }
    +
    +    /**
    +     * @dataProvider isAllowedGlobalAttrProvider
    +     * @covers ::isAllowedGlobalAttr
    +     */
    +    public function testIsAllowedGlobalAttr(string $name, $allowedAttrs, $allowedAttrPrefixes, $expected)
    +    {
    +        $attr    = new DOMAttr($name);
    +        $options = [
    +            'allowedAttrs'        => $allowedAttrs,
    +            'allowedAttrPrefixes' => $allowedAttrPrefixes,
    +            'allowedNamespaces'   => ['test' => 'https://example.com']
    +        ];
    +
    +        $this->assertSame($expected, Dom::isAllowedGlobalAttr($attr, $options));
    +    }
    +
    +    public function isAllowedUrlProvider(): array
    +    {
    +        return [
    +            // allowed empty url
    +            ['', true],
    +
    +            // allowed path
    +            ['/', true],
    +
    +            // allowed path
    +            ['/some/path', true],
    +
    +            // allowed path
    +            ['some', true],
    +
    +            // allowed path
    +            ['some/path', true],
    +
    +            // allowed path
    +            ['some/path:test', true],
    +
    +            // allowed path
    +            ['some/path:some/test', true],
    +
    +            // allowed path
    +            ['./some/path', true],
    +
    +            // allowed fragment
    +            ['#', true],
    +
    +            // allowed fragment
    +            ['#test-fragment', true],
    +
    +            // allowed data uri when all are accepted
    +            ['data:image/jpeg;base64,test', true, [
    +                'allowedDataUris' => true
    +            ]],
    +
    +            // allowed data uri
    +            ['data:image/jpeg;base64,test', true, [
    +                'allowedDataUris' => [
    +                    'data:image/jpeg;base64'
    +                ]
    +            ]],
    +
    +            // allowed URL when all domains are accepted
    +            ['http://getkirby.com', true, [
    +                'allowedDomains' => true
    +            ]],
    +
    +            // allowed URL when the domain is accepted
    +            ['http://getkirby.com', true, [
    +                'allowedDomains' => [
    +                    'getkirby.com'
    +                ]
    +            ]],
    +
    +            // allowed empty email address
    +            ['mailto:', true],
    +
    +            // allowed valid email address
    +            ['mailto:test@getkirby.com', true],
    +
    +            // allowed empty phone number
    +            ['tel:', true],
    +
    +            // allowed phone number
    +            ['tel:+491122334455', true],
    +
    +            // forbidden protocol-relative URL
    +            ['//test', 'Protocol-relative URLs are not allowed'],
    +
    +            // forbidden relative URL
    +            ['../some/path', 'The ../ sequence is not allowed in relative URLs'],
    +
    +            // forbidden relative URL
    +            ['..\some\path', 'The ../ sequence is not allowed in relative URLs'],
    +
    +            // forbidden relative URL
    +            ['some/../../path', 'The ../ sequence is not allowed in relative URLs'],
    +
    +            // forbidden relative URL
    +            ['some\..\..\path', 'The ../ sequence is not allowed in relative URLs'],
    +
    +            // forbidden data uri
    +            ['data:image/jpeg;base64,test', 'Invalid data URI', [
    +                'allowedDataUris' => []
    +            ]],
    +
    +            // forbidden data uri
    +            ['data:image/png;base64,test', 'Invalid data URI', [
    +                'allowedDataUris' => ['data:image/jpeg;base64']
    +            ]],
    +
    +            // forbidden URL when no domains are accepted
    +            ['https://getkirby.com', 'The hostname "getkirby.com" is not allowed', [
    +                'allowedDomains' => []
    +            ]],
    +
    +            // forbidden URL when the particular domain is not accepted
    +            ['https://google.com', 'The hostname "google.com" is not allowed', [
    +                'allowedDomains' => [
    +                    'getkirby.com'
    +                ]
    +            ]],
    +
    +            // forbidden invalid email address
    +            ['mailto:test', 'Invalid email address'],
    +
    +            // forbidden phone numbers
    +            ['tel:test', 'Invalid telephone number'],
    +
    +            // forbidden phone numbers - too much formatting
    +            ['tel:+49 (0) 1234 5678', 'Invalid telephone number'],
    +
    +            // forbidden phone numbers - invalid plus sign position
    +            ['tel:491234+5678', 'Invalid telephone number'],
    +
    +            // forbidden URL type
    +            ['javascript:alert()', 'Unknown URL type'],
    +
    +            // forbidden URL type
    +            ['ftp:test', 'Unknown URL type'],
    +
    +            // forbidden URL type
    +            ['ftp://test', 'Unknown URL type'],
    +
    +            // forbidden URL type
    +            ['my-amazing-protocol:test', 'Unknown URL type'],
    +
    +            // forbidden URL type
    +            ['my-amazing-protocol://test', 'Unknown URL type'],
    +        ];
    +    }
    +
    +    /**
    +     * @dataProvider isAllowedUrlProvider
    +     * @covers ::isAllowedUrl
    +     */
    +    public function testIsAllowedUrl(string $url, $expected, array $options = [])
    +    {
    +        $this->assertSame($expected, Dom::isAllowedUrl($url, $options));
    +    }
    +
    +    public function isAllowedUrlCmsProvider()
    +    {
    +        return [
    +            // allowed URL with site at the domain root
    +            ['https://getkirby.com', '/some/path', true],
    +
    +            // allowed URL with site at the domain root
    +            ['/', '/some/path', true],
    +
    +            // allowed URL with site in a subfolder
    +            ['https://getkirby.com/some', '/some/path', true],
    +
    +            // allowed URL with site in a subfolder
    +            ['/some', '/some/path', true],
    +
    +            // disallowed URL with site in a subfolder
    +            ['https://getkirby.com/site', '/some/path', 'The URL points outside of the site index URL'],
    +
    +            // disallowed URL with site in a subfolder
    +            ['/site', '/some/path', 'The URL points outside of the site index URL'],
    +
    +            // disallowed URL with directory traversal
    +            ['https://getkirby.com/site', '/site/../some/path', 'The ../ sequence is not allowed in relative URLs'],
    +
    +            // disallowed URL with directory traversal
    +            ['/site', '/site/../some/path', 'The ../ sequence is not allowed in relative URLs'],
    +        ];
    +    }
    +
    +    /**
    +     * @dataProvider isAllowedUrlCmsProvider
    +     * @covers ::isAllowedUrl
    +     */
    +    public function testIsAllowedUrlCms(string $indexUrl, string $url, $expected)
    +    {
    +        new App([
    +            'urls' => [
    +                'index' => $indexUrl
    +            ]
    +        ]);
    +
    +        $this->assertSame($expected, Dom::isAllowedUrl($url, []));
    +    }
    +
    +    /**
    +     * @covers ::innerMarkup
    +     */
    +    public function testInnerMarkup()
    +    {
    +        // XML markup
    +        $dom  = new Dom('<xml><test>Test <testtest>Test test</testtest>!</test></xml>', 'XML');
    +        $node = $dom->document()->getElementsByTagName('test')[0];
    +        $this->assertSame('Test <testtest>Test test</testtest>!', $dom->innerMarkup($node));
    +
    +        // HTML markup
    +        $dom  = new Dom('<p id="test">Test <strong>Test test</strong>!</p>', 'HTML');
    +        $node = $dom->document()->getElementById('test');
    +        $this->assertSame('Test <strong>Test test</strong>!', $dom->innerMarkup($node));
    +    }
    +
    +    public function listContainsNameProvider(): array
    +    {
    +        return [
    +            // basic tests
    +            [
    +                ['html', 'body'],
    +                ['body', ''],
    +                [],
    +                null,
    +
    +                'body'
    +            ],
    +            [
    +                ['html', 'body'],
    +                ['body', ''],
    +                true,
    +                null,
    +
    +                'body'
    +            ],
    +            [
    +                ['html', 'body'],
    +                ['script', ''],
    +                [],
    +                null,
    +
    +                false
    +            ],
    +            [
    +                ['html', 'body'],
    +                ['script', ''],
    +                true,
    +                null,
    +
    +                false
    +            ],
    +            [
    +                ['html', 'body'],
    +                ['BoDy', ''],
    +                [],
    +                null,
    +
    +                false
    +            ],
    +            [
    +                ['html', 'body'],
    +                ['BoDy', ''],
    +                true,
    +                null,
    +
    +                false
    +            ],
    +
    +            // tests with namespaces
    +            [
    +                ['test', 'another-test'],
    +                ['test', 'https://example.com'],
    +                ['' => 'https://example.com'],
    +                null,
    +
    +                'test' // namespace matches
    +            ],
    +            [
    +                ['test', 'another-test'],
    +                ['test', 'https://example.com'],
    +                true,
    +                null,
    +
    +                'test' // all namespaces allowed
    +            ],
    +            [
    +                ['test', 'another-test'],
    +                ['test', 'https://example.com/different'],
    +                ['' => 'https://example.com'],
    +                null,
    +
    +                false // namespace is not allowed
    +            ],
    +            [
    +                ['test', 'another-test'],
    +                ['test', 'https://example.com'],
    +                ['testns' => 'https://example.com'],
    +                null,
    +
    +                false // namespace name mismatch in list
    +            ],
    +            [
    +                ['test', 'another-test'],
    +                ['testns:test', 'https://example.com'],
    +                ['testns' => 'https://example.com'],
    +                null,
    +
    +                false // the list counts, not the document
    +            ],
    +            [
    +                ['testns:test', 'another-test'],
    +                ['test', 'https://example.com'],
    +                ['testns' => 'https://example.com'],
    +                null,
    +
    +                'testns:test' // correct namespaced configuration
    +            ],
    +            [
    +                ['testns:test', 'another-test'],
    +                ['testns:test', 'https://example.com'],
    +                ['testns' => 'https://example.com'],
    +                null,
    +
    +                'testns:test' // namespace in document does not matter
    +            ],
    +            [
    +                ['testns:test', 'another-test'],
    +                ['customns:test', 'https://example.com'],
    +                ['testns' => 'https://example.com'],
    +                null,
    +
    +                'testns:test' // namespace in document does not matter
    +            ],
    +            [
    +                ['testns:test', 'another-test'],
    +                ['testns:test', null],
    +                ['testns' => 'https://example.com'],
    +                null,
    +
    +                'testns:test' // namespace not defined in document
    +            ],
    +            [
    +                ['testns:test', 'another-test'],
    +                ['testns:test', null],
    +                true,
    +                null,
    +
    +                'testns:test' // all namespaces allowed, local name check
    +            ],
    +            [
    +                ['testns:test', 'another-test'],
    +                ['testns:test', 'https://example.com'],
    +                true,
    +                null,
    +
    +                false // local name check fails because node has namespace
    +            ],
    +
    +            // custom compare function
    +            [
    +                ['html', 'bodY'],
    +                ['BoDy', ''],
    +                [],
    +                function ($expected, $real): bool {
    +                    return strtolower($expected) === strtolower($real);
    +                },
    +
    +                'bodY'
    +            ],
    +            [
    +                ['html', 'bodY'],
    +                ['BoDy', ''],
    +                true,
    +                function ($expected, $real): bool {
    +                    return strtolower($expected) === strtolower($real);
    +                },
    +
    +                'bodY'
    +            ],
    +        ];
    +    }
    +
    +    /**
    +     * @dataProvider listContainsNameProvider
    +     * @covers ::listContainsName
    +     */
    +    public function testListContainsName(array $list, array $node, $allowedNamespaces, ?Closure $compare, $expected)
    +    {
    +        [$nodeName, $nodeNS] = $node;
    +        if ($nodeNS !== null) {
    +            $element = new DOMElement($nodeName, null, $nodeNS);
    +        } else {
    +            $element = (new DOMDocument())->createElement($nodeName);
    +        }
    +
    +        $options = ['allowedNamespaces' => $allowedNamespaces];
    +
    +        $this->assertSame($expected, Dom::listContainsName($list, $element, $options, $compare));
    +    }
    +
    +    /**
    +     * @covers ::remove
    +     */
    +    public function testRemove()
    +    {
    +        $dom = new Dom('<p>Test <strong id="strong">Test test</strong>!</p>', 'HTML');
    +
    +        Dom::remove($dom->document()->getElementById('strong'));
    +        $this->assertSame('<p>Test !</p>', $dom->toString());
    +    }
    +
    +    /**
    +     * @covers ::query
    +     */
    +    public function testQuery()
    +    {
    +        $dom = new Dom('<span>Test <span>Test test</span>!</span>', 'HTML');
    +
    +        // global query
    +        $node = $dom->query('descendant::span')[0];
    +        $this->assertSame('<span>Test <span>Test test</span>!</span>', $dom->document()->saveHtml($node));
    +
    +        // query within a context node
    +        $node = $dom->query('descendant::span', $node)[0];
    +        $this->assertSame('<span>Test test</span>', $dom->document()->saveHtml($node));
    +    }
    +
    +    public function sanitizeProvider(): array
    +    {
    +        return [
    +            // defaults
    +            [
    +                '<p>This <strong id="test">is a test</strong>!</p>',
    +                [],
    +
    +                '<p>This <strong id="test">is a test</strong>!</p>',
    +                []
    +            ],
    +            [
    +                '<a href="https://getkirby.com/test">Link</a>',
    +                [],
    +
    +                '<a href="https://getkirby.com/test">Link</a>',
    +                []
    +            ],
    +            [
    +                '<p style="background: url(https://getkirby.com/test)">Lorem ipsum</p>',
    +                [],
    +
    +                '<p style="background: url(https://getkirby.com/test)">Lorem ipsum</p>',
    +                []
    +            ],
    +            [
    +                '<img src="data:image/jpeg;base64,test"/>',
    +                [],
    +
    +                '<img src="data:image/jpeg;base64,test"/>',
    +                []
    +            ],
    +            [
    +                "<p>\n<a href='javascript:alert()'>Link</a>\n</p>",
    +                [],
    +
    +                "<p>\n<a>Link</a>\n</p>",
    +                ['The URL is not allowed in attribute "href" (line 2): Unknown URL type']
    +            ],
    +            [
    +                "<p>\n<img src='javascript:alert()'/>\n</p>",
    +                [],
    +
    +                "<p>\n<img/>\n</p>",
    +                ['The URL is not allowed in attribute "src" (line 2): Unknown URL type']
    +            ],
    +            [
    +                '<a xmlns:xlink="https://example.com" xlink:href="https://getkirby.com">Link</a>',
    +                [],
    +
    +                '<a xmlns:xlink="https://example.com" xlink:href="https://getkirby.com">Link</a>',
    +                []
    +            ],
    +            [
    +                '<a xlink:href="javascript:alert()">Link</a>',
    +                [],
    +
    +                '<a>Link</a>',
    +                ['The URL is not allowed in attribute "xlink:href" (line 1): Unknown URL type']
    +            ],
    +            [
    +                '<a xmlns:xlink="https://example.com" xlink:href="javascript:alert()">Link</a>',
    +                [],
    +
    +                '<a xmlns:xlink="https://example.com">Link</a>',
    +                ['The URL is not allowed in attribute "xlink:href" (line 1): Unknown URL type']
    +            ],
    +            [
    +                '<p style="background: url(javascript:alert())">Lorem ipsum</p>',
    +                [],
    +
    +                '<p>Lorem ipsum</p>',
    +                ['The URL is not allowed in attribute "style" (line 1): Unknown URL type']
    +            ],
    +            [
    +                '<?xml-stylesheet href="stylesheet.css"?><p>This is a test</p>',
    +                [],
    +
    +                "<?xml-stylesheet href=\"stylesheet.css\"?>\n<p>This is a test</p>",
    +                []
    +            ],
    +
    +            // allowedAttrPrefixes
    +            [
    +                '<p aria-label="Test" data-test="Test">This is a test</p>',
    +                [
    +                    'allowedAttrPrefixes' => ['aria-'],
    +                ],
    +
    +                '<p aria-label="Test" data-test="Test">This is a test</p>',
    +                []
    +            ],
    +            [
    +                '<p aria-label="Test" data-test="Test">This is a test</p>',
    +                [
    +                    'allowedAttrPrefixes' => ['aria-'],
    +                    'allowedAttrs'        => [],
    +                ],
    +
    +                '<p aria-label="Test">This is a test</p>',
    +                ['The "data-test" attribute (line 1) is not allowed: Not included in the global allowlist']
    +            ],
    +
    +            // allowedAttrs
    +            [
    +                '<p class="test" onload="alert()">This is a test</p>',
    +                [
    +                    'allowedAttrs' => ['class', 'on'],
    +                ],
    +
    +                '<p class="test">This is a test</p>',
    +                ['The "onload" attribute (line 1) is not allowed: Not included in the global allowlist']
    +            ],
    +
    +            // allowedDataUris
    +            [
    +                "<html>\n<img class='jpeg' src='data:image/jpeg;base64,test'/>\n<img class='png' src='data:image/png;base64,test'/>\n</html>",
    +                [
    +                    'allowedDataUris' => ['data:image/jpeg'],
    +                ],
    +
    +                "<html>\n<img class=\"jpeg\" src=\"data:image/jpeg;base64,test\"/>\n<img class=\"png\"/>\n</html>",
    +                ['The URL is not allowed in attribute "src" (line 3): Invalid data URI']
    +            ],
    +
    +            // allowedDomains
    +            [
    +                '<a href="https://getkirby.com/test" src="http://example.com/">Link</a>',
    +                [
    +                    'allowedDomains' => ['getkirby.com']
    +                ],
    +
    +                '<a href="https://getkirby.com/test">Link</a>',
    +                ['The URL is not allowed in attribute "src" (line 1): The hostname "example.com" is not allowed']
    +            ],
    +
    +            // allowedNamespaces
    +            [
    +                '<p class="test">Lorem ipsum</p>',
    +                [
    +                    'allowedNamespaces' => ['' => 'https://example.com/test', 'xlink' => 'http://www.w3.org/1999/xlink']
    +                ],
    +
    +                '<p class="test">Lorem ipsum</p>',
    +                []
    +            ],
    +            [
    +                '<p xmlns:test="https://example.com/test" xmlns:mylink="http://www.w3.org/1999/xlink" id="p" test:class="test">Lorem ipsum</p>',
    +                [
    +                    'allowedNamespaces' => ['' => 'https://example.com/test', 'xlink' => 'http://www.w3.org/1999/xlink']
    +                ],
    +
    +                '<p xmlns:test="https://example.com/test" xmlns:mylink="http://www.w3.org/1999/xlink" id="p" test:class="test">Lorem ipsum</p>',
    +                []
    +            ],
    +            [
    +                '<p xmlns:test="https://example.com/" xmlns:mylink="http://www.w3.org/1999/xlink">Lorem ipsum</p>',
    +                [
    +                    'allowedNamespaces' => ['' => 'https://example.com/test', 'xlink' => 'http://www.w3.org/1999/xlink']
    +                ],
    +
    +                '<p xmlns:mylink="http://www.w3.org/1999/xlink">Lorem ipsum</p>',
    +                ['The namespace "https://example.com/" is not allowed (around line 1)']
    +            ],
    +            [
    +                '<p xmlns:test="https://example.com/test" aria-label="p" test:aria-role="test">Lorem ipsum</p>',
    +                [
    +                    'allowedAttrs' => [],
    +                    'allowedAttrPrefixes' => ['aria-'],
    +                    'allowedNamespaces' => ['' => 'https://example.com/test']
    +                ],
    +
    +                '<p xmlns:test="https://example.com/test" aria-label="p" test:aria-role="test">Lorem ipsum</p>',
    +                []
    +            ],
    +            [
    +                '<a xmlns:test="https://example.com/test" aria-label="p" test:aria-role="test">Link</a>',
    +                [
    +                    'allowedAttrs' => [],
    +                    'allowedAttrPrefixes' => ['namespace:aria-'],
    +                    'allowedNamespaces' => ['namespace' => 'https://example.com/test']
    +                ],
    +
    +                '<a xmlns:test="https://example.com/test" test:aria-role="test">Link</a>',
    +                ['The "aria-label" attribute (line 1) is not allowed: Not included in the global allowlist']
    +            ],
    +            [
    +                '<p xmlns:test="https://example.com/test" xmlns:mylink="http://www.w3.org/1999/xlink" id="p" test:class="test">Lorem ipsum</p>',
    +                [
    +                    'allowedAttrs' => ['class', 'id'],
    +                    'allowedNamespaces' => ['' => 'https://example.com/test', 'xlink' => 'http://www.w3.org/1999/xlink']
    +                ],
    +
    +                '<p xmlns:test="https://example.com/test" xmlns:mylink="http://www.w3.org/1999/xlink" id="p" test:class="test">Lorem ipsum</p>',
    +                []
    +            ],
    +            [
    +                '<a xmlns:mylink="http://www.w3.org/1999/xlink" mylink:href="https://getkirby.com">Link</a>',
    +                [
    +                    'allowedAttrs' => ['xlink:href'],
    +                    'allowedNamespaces' => ['' => 'https://example.com/test', 'xlink' => 'http://www.w3.org/1999/xlink']
    +                ],
    +
    +                '<a xmlns:mylink="http://www.w3.org/1999/xlink" mylink:href="https://getkirby.com">Link</a>',
    +                []
    +            ],
    +            [
    +                '<a xmlns:mylink="http://www.w3.org/1999/xlink" mylink:test="https://getkirby.com">Link</a>',
    +                [
    +                    'allowedAttrs' => ['xlink:href'],
    +                    'allowedNamespaces' => ['' => 'https://example.com/test', 'xlink' => 'http://www.w3.org/1999/xlink']
    +                ],
    +
    +                '<a xmlns:mylink="http://www.w3.org/1999/xlink">Link</a>',
    +                ['The "mylink:test" attribute (line 1) is not allowed: Not included in the global allowlist']
    +            ],
    +            [
    +                '<a xmlns:mylink="http://www.w3.org/1999/xlink" mylink:href="https://getkirby.com" mylink:test="https://getkirby.com">Link</a>',
    +                [
    +                    'allowedAttrs' => [],
    +                    'allowedNamespaces' => ['xlink' => 'http://www.w3.org/1999/xlink'],
    +                    'allowedTags' => ['a' => ['xlink:href']]
    +                ],
    +
    +                '<a xmlns:mylink="http://www.w3.org/1999/xlink" mylink:href="https://getkirby.com">Link</a>',
    +                ['The "mylink:test" attribute (line 1) is not allowed: Not allowed by the "a" element']
    +            ],
    +            [
    +                '<xml xmlns:test="https://example.com/test"><a>A</a><test:b>B</test:b></xml>',
    +                [
    +                    'allowedNamespaces' => ['test' => 'https://example.com/test'],
    +                    'allowedTags' => ['xml' => true, 'a' => true, 'test:b' => true]
    +                ],
    +
    +                '<xml xmlns:test="https://example.com/test"><a>A</a><test:b>B</test:b></xml>',
    +                []
    +            ],
    +            [
    +                '<xml xmlns="https://example.com/test"><a>A</a></xml>',
    +                [
    +                    'allowedNamespaces' => ['test' => 'https://example.com/test'],
    +                    'allowedTags' => ['test:xml' => true, 'test:a' => true]
    +                ],
    +
    +                '<xml xmlns="https://example.com/test"><a>A</a></xml>',
    +                []
    +            ],
    +            [
    +                '<xml xmlns="https://example.com/test"><a>A</a></xml>',
    +                [
    +                    'allowedNamespaces' => ['test' => 'https://example.com/test'],
    +                    'allowedTags' => ['xml' => true, 'test:a' => true]
    +                ],
    +
    +                '<a xmlns="https://example.com/test">A</a>',
    +                ['The "xml" element (line 1) is not allowed, but its children can be kept']
    +            ],
    +            [
    +                '<xml xmlns:test="https://example.com/test"><a>A</a><test:b>B</test:b></xml>',
    +                [
    +                    'allowedNamespaces' => ['test' => 'https://example.com/test'],
    +                    'allowedTags' => ['xml' => true, 'a' => true, 'b' => true]
    +                ],
    +
    +                '<xml xmlns:test="https://example.com/test"><a>A</a></xml>',
    +                ['The "test:b" element (line 1) is not allowed, but its children can be kept']
    +            ],
    +            [
    +                '<xml xmlns:test="https://example.com/test"><a>A</a></xml>',
    +                [
    +                    'allowedNamespaces' => ['test' => 'https://example.com/test'],
    +                    'allowedTags' => ['xml' => true, 'test:a' => true]
    +                ],
    +
    +                '<xml xmlns:test="https://example.com/test"/>',
    +                ['The "a" element (line 1) is not allowed, but its children can be kept']
    +            ],
    +            [
    +                '<a xmlns:mylink="http://www.w3.org/1999/xlink" href="javascript:" mylink:href="javascript:" mylink:test="javascript:">Link</a>',
    +                [
    +                    'allowedNamespaces' => ['xlink' => 'http://www.w3.org/1999/xlink'],
    +                    'urlAttrs' => ['href', 'xlink:test']
    +                ],
    +
    +                '<a xmlns:mylink="http://www.w3.org/1999/xlink" mylink:href="javascript:">Link</a>',
    +                [
    +                    'The URL is not allowed in attribute "href" (line 1): Unknown URL type',
    +                    'The URL is not allowed in attribute "mylink:test" (line 1): Unknown URL type'
    +                ]
    +            ],
    +            [
    +                '<a xmlns:mylink="http://www.w3.org/1999/xlink" href="javascript:" mylink:href="javascript:" mylink:test="javascript:">Link</a>',
    +                [
    +                    'allowedNamespaces' => ['xlink' => 'http://www.w3.org/1999/xlink'],
    +                    'urlAttrs' => ['href', 'xlink:href']
    +                ],
    +
    +                '<a xmlns:mylink="http://www.w3.org/1999/xlink" mylink:test="javascript:">Link</a>',
    +                [
    +                    'The URL is not allowed in attribute "href" (line 1): Unknown URL type',
    +                    'The URL is not allowed in attribute "mylink:href" (line 1): Unknown URL type'
    +                ]
    +            ],
    +            [
    +                '<xml xmlns:test="https://example.com/test"><a>A</a></xml>',
    +                [
    +                    'allowedNamespaces' => ['test' => 'https://example.com/test'],
    +                    'disallowedTags' => ['a']
    +                ],
    +
    +                '<xml xmlns:test="https://example.com/test"/>',
    +                ['The "a" element (line 1) is not allowed']
    +            ],
    +            [
    +                '<xml xmlns:test="https://example.com/test"><a>A</a></xml>',
    +                [
    +                    'allowedNamespaces' => ['test' => 'https://example.com/test'],
    +                    'disallowedTags' => ['test:a']
    +                ],
    +
    +                '<xml xmlns:test="https://example.com/test"><a>A</a></xml>',
    +                []
    +            ],
    +            [
    +                '<xml xmlns:namespace="https://example.com/test"><namespace:a>A</namespace:a></xml>',
    +                [
    +                    'allowedNamespaces' => ['test' => 'https://example.com/test'],
    +                    'disallowedTags' => ['test:a']
    +                ],
    +
    +                '<xml xmlns:namespace="https://example.com/test"/>',
    +                ['The "namespace:a" element (line 1) is not allowed']
    +            ],
    +            [
    +                '<xml xmlns:namespace="https://example.com/test"><namespace:a>A</namespace:a></xml>',
    +                [
    +                    'allowedNamespaces' => ['' => 'https://example.com/test'],
    +                    'disallowedTags' => ['a']
    +                ],
    +
    +                '<xml xmlns:namespace="https://example.com/test"/>',
    +                ['The "namespace:a" element (line 1) is not allowed']
    +            ],
    +
    +            // allowedPIs
    +            [
    +                '<?xml-stylesheet href="stylesheet.css"?><?invalid-instruction href="https://malicious.com"?><p>This is a test</p>',
    +                [
    +                    'allowedPIs' => ['xml-stylesheet']
    +                ],
    +
    +                "<?xml-stylesheet href=\"stylesheet.css\"?>\n<p>This is a test</p>",
    +                ['The "invalid-instruction" processing instruction (line 1) is not allowed']
    +            ],
    +
    +            // allowedTags
    +            [
    +                '<xml><a>A</a><b>B</b></xml>',
    +                [
    +                    'allowedTags' => ['xml' => true, 'a' => true]
    +                ],
    +
    +                '<xml><a>A</a></xml>',
    +                ['The "b" element (line 1) is not allowed, but its children can be kept']
    +            ],
    +            [
    +                "<xml id='xml' class='test'>\n<a id='a' class='test'>A</a>\n</xml>",
    +                [
    +                    'allowedAttrs' => ['id'],
    +                    'allowedTags' => ['xml' => true, 'a' => false]
    +                ],
    +
    +                "<xml id=\"xml\">\n<a>A</a>\n</xml>",
    +                [
    +                    'The "class" attribute (line 1) is not allowed: Not included in the global allowlist',
    +                    'The "id" attribute (line 2) is not allowed: The "a" element does not allow attributes',
    +                    'The "class" attribute (line 2) is not allowed: The "a" element does not allow attributes'
    +                ]
    +            ],
    +            [
    +                "<xml aria-role='xml' class='test'>\n<a aria-role='a' class='test'>A</a>\n</xml>",
    +                [
    +                    'allowedAttrs' => [],
    +                    'allowedAttrPrefixes' => ['aria-'],
    +                    'allowedTags' => ['xml' => true, 'a' => false]
    +                ],
    +
    +                "<xml aria-role=\"xml\">\n<a>A</a>\n</xml>",
    +                [
    +                    'The "class" attribute (line 1) is not allowed: Not included in the global allowlist',
    +                    'The "aria-role" attribute (line 2) is not allowed: The "a" element does not allow attributes',
    +                    'The "class" attribute (line 2) is not allowed: The "a" element does not allow attributes'
    +                ]
    +            ],
    +            [
    +                '<xml><a class="test" xmlns="https://example.com/test"><b>B1</b><b>B2</b></a></xml>',
    +                [
    +                    'allowedTags' => ['xml' => true, 'b' => true]
    +                ],
    +
    +                '<xml><b xmlns="https://example.com/test">B1</b><b xmlns="https://example.com/test">B2</b></xml>',
    +                ['The "a" element (line 1) is not allowed, but its children can be kept']
    +            ],
    +
    +            // attrCallback
    +            [
    +                '<xml a="A" b="B"/>',
    +                [
    +                    'attrCallback' => function (DOMAttr $attr): void {
    +                        // no return value
    +                    }
    +                ],
    +
    +                '<xml a="A" b="B"/>',
    +                []
    +            ],
    +            [
    +                '<xml a="A" b="B"/>',
    +                [
    +                    'attrCallback' => function (DOMAttr $attr): array {
    +                        if ($attr->nodeName === 'b') {
    +                            $attr->ownerElement->removeAttributeNode($attr);
    +                            return [new InvalidArgumentException('The "b" attribute is not allowed')];
    +                        }
    +
    +                        return [];
    +                    }
    +                ],
    +
    +                '<xml a="A"/>',
    +                ['The "b" attribute is not allowed']
    +            ],
    +
    +            // disallowedTags
    +            [
    +                '<xml><a>A1</a><disallowed class="test"><a class="test">A2</a></disallowed></xml>',
    +                [
    +                    'disallowedTags' => ['disallowed']
    +                ],
    +
    +                '<xml><a>A1</a></xml>',
    +                ['The "disallowed" element (line 1) is not allowed']
    +            ],
    +            [
    +                '<xml><a>A1</a><disAllowed class="test"><a class="test">A2</a></disAllowed></xml>',
    +                [
    +                    'disallowedTags' => ['DISallowed']
    +                ],
    +
    +                '<xml><a>A1</a></xml>',
    +                ['The "disAllowed" element (line 1) is not allowed']
    +            ],
    +
    +            // doctype defaults and doctypeCallback
    +            [
    +                '<!DOCTYPE xml><xml/>',
    +                [],
    +
    +                "<!DOCTYPE xml>\n<xml/>",
    +                []
    +            ],
    +            [
    +                '<!DOCTYPE xml PUBLIC "SOMETHING" "https://malicious.com/something.dtd"><xml/>',
    +                [],
    +
    +                '<xml/>',
    +                ['The doctype must not reference external files']
    +            ],
    +            [
    +                '<!DOCTYPE xml SYSTEM "https://malicious.com/something.dtd"><xml/>',
    +                [],
    +
    +                '<xml/>',
    +                ['The doctype must not reference external files']
    +            ],
    +            [
    +                '<!DOCTYPE xml [<!ENTITY lol "lol">]><xml/>',
    +                [],
    +
    +                '<xml/>',
    +                ['The doctype must not define a subset']
    +            ],
    +            [
    +                '<!DOCTYPE svg><xml/>',
    +                [
    +                    'doctypeCallback' => function (DOMDocumentType $doctype): void {
    +                        throw new InvalidArgumentException('The "' . $doctype->name . '" doctype is not allowed');
    +                    }
    +                ],
    +
    +                '<xml/>',
    +                ['The "svg" doctype is not allowed']
    +            ],
    +
    +            // elementCallback
    +            [
    +                '<xml><a class="a">A</a><b class="b">B</b></xml>',
    +                [
    +                    'elementCallback' => function (DOMElement $element): void {
    +                        // no return value
    +                    }
    +                ],
    +
    +                '<xml><a class="a">A</a><b class="b">B</b></xml>',
    +                []
    +            ],
    +            [
    +                '<xml><a class="a">A</a><b class="b">B</b></xml>',
    +                [
    +                    'elementCallback' => function (DOMElement $element): array {
    +                        if ($element->nodeName === 'b') {
    +                            Dom::remove($element);
    +                            return [new InvalidArgumentException('The "b" element is not allowed')];
    +                        }
    +
    +                        return [];
    +                    }
    +                ],
    +
    +                '<xml><a class="a">A</a></xml>',
    +                ['The "b" element is not allowed']
    +            ],
    +
    +            // urlAttrs
    +            [
    +                '<a class="javascript:alert()" href="javascript:alert()"/>',
    +                [
    +                    'urlAttrs' => []
    +                ],
    +
    +                '<a class="javascript:alert()" href="javascript:alert()"/>',
    +                []
    +            ],
    +            [
    +                '<a class="javascript:alert()" href="javascript:alert()"/>',
    +                [
    +                    'urlAttrs' => ['href']
    +                ],
    +
    +                '<a class="javascript:alert()"/>',
    +                ['The URL is not allowed in attribute "href" (line 1): Unknown URL type']
    +            ]
    +        ];
    +    }
    +
    +    /**
    +     * @dataProvider sanitizeProvider
    +     * @covers ::sanitize
    +     * @covers ::sanitizeAttr
    +     * @covers ::sanitizeDoctype
    +     * @covers ::sanitizeElement
    +     * @covers ::sanitizePI
    +     * @covers ::validateDoctype
    +     */
    +    public function testSanitize(string $code, array $options, string $expectedCode, array $expectedErrors)
    +    {
    +        $dom    = new Dom($code, 'XML');
    +        $errors = $dom->sanitize($options);
    +
    +        $this->assertSame($expectedErrors, array_map(function ($error) {
    +            return $error->getMessage();
    +        }, $errors));
    +        $this->assertSame($expectedCode, $dom->toString());
    +    }
    +
    +    /**
    +     * @covers ::sanitize
    +     * @covers ::sanitizeDoctype
    +     * @covers ::validateDoctype
    +     */
    +    public function testSanitizeDoctypeCallbackException()
    +    {
    +        $this->expectException('Exception');
    +        $this->expectExceptionMessage('This exception is not caught as validation error');
    +
    +        $dom = new Dom('<!DOCTYPE xml><xml/>', 'XML');
    +        $dom->sanitize([
    +            'doctypeCallback' => function (DOMDocumentType $doctype): void {
    +                throw new \InvalidArgumentException('This exception is not caught as validation error');
    +            }
    +        ]);
    +    }
    +
    +    /**
    +     * @covers ::unwrap
    +     */
    +    public function testUnwrap()
    +    {
    +        $dom = new Dom('<body><p>This is a test</p><invalid>And this is <p>Awesome<strong>!</strong></p> but contains text</invalid></body>', 'HTML');
    +
    +        $node = $dom->document()->getElementsByTagName('invalid')[0];
    +        Dom::unwrap($node);
    +
    +        $this->assertSame('<body><p>This is a test</p><p>Awesome<strong>!</strong></p></body>', $dom->toString());
    +    }
    +}
    
  • vendor/composer/autoload_classmap.php+3 0 modified
    @@ -199,7 +199,9 @@
         'Kirby\\Parsley\\Schema' => $baseDir . '/src/Parsley/Schema.php',
         'Kirby\\Parsley\\Schema\\Blocks' => $baseDir . '/src/Parsley/Schema/Blocks.php',
         'Kirby\\Parsley\\Schema\\Plain' => $baseDir . '/src/Parsley/Schema/Plain.php',
    +    'Kirby\\Sane\\DomHandler' => $baseDir . '/src/Sane/DomHandler.php',
         'Kirby\\Sane\\Handler' => $baseDir . '/src/Sane/Handler.php',
    +    'Kirby\\Sane\\Html' => $baseDir . '/src/Sane/Html.php',
         'Kirby\\Sane\\Sane' => $baseDir . '/src/Sane/Sane.php',
         'Kirby\\Sane\\Svg' => $baseDir . '/src/Sane/Svg.php',
         'Kirby\\Sane\\Svgz' => $baseDir . '/src/Sane/Svgz.php',
    @@ -220,6 +222,7 @@
         'Kirby\\Toolkit\\Config' => $baseDir . '/src/Toolkit/Config.php',
         'Kirby\\Toolkit\\Controller' => $baseDir . '/src/Toolkit/Controller.php',
         'Kirby\\Toolkit\\Dir' => $baseDir . '/src/Toolkit/Dir.php',
    +    'Kirby\\Toolkit\\Dom' => $baseDir . '/src/Toolkit/Dom.php',
         'Kirby\\Toolkit\\Escape' => $baseDir . '/src/Toolkit/Escape.php',
         'Kirby\\Toolkit\\F' => $baseDir . '/src/Toolkit/F.php',
         'Kirby\\Toolkit\\Facade' => $baseDir . '/src/Toolkit/Facade.php',
    
  • vendor/composer/autoload_static.php+3 0 modified
    @@ -294,7 +294,9 @@ class ComposerStaticInitc26333d865e0329b638bdc17afd29896
             'Kirby\\Parsley\\Schema' => __DIR__ . '/../..' . '/src/Parsley/Schema.php',
             'Kirby\\Parsley\\Schema\\Blocks' => __DIR__ . '/../..' . '/src/Parsley/Schema/Blocks.php',
             'Kirby\\Parsley\\Schema\\Plain' => __DIR__ . '/../..' . '/src/Parsley/Schema/Plain.php',
    +        'Kirby\\Sane\\DomHandler' => __DIR__ . '/../..' . '/src/Sane/DomHandler.php',
             'Kirby\\Sane\\Handler' => __DIR__ . '/../..' . '/src/Sane/Handler.php',
    +        'Kirby\\Sane\\Html' => __DIR__ . '/../..' . '/src/Sane/Html.php',
             'Kirby\\Sane\\Sane' => __DIR__ . '/../..' . '/src/Sane/Sane.php',
             'Kirby\\Sane\\Svg' => __DIR__ . '/../..' . '/src/Sane/Svg.php',
             'Kirby\\Sane\\Svgz' => __DIR__ . '/../..' . '/src/Sane/Svgz.php',
    @@ -315,6 +317,7 @@ class ComposerStaticInitc26333d865e0329b638bdc17afd29896
             'Kirby\\Toolkit\\Config' => __DIR__ . '/../..' . '/src/Toolkit/Config.php',
             'Kirby\\Toolkit\\Controller' => __DIR__ . '/../..' . '/src/Toolkit/Controller.php',
             'Kirby\\Toolkit\\Dir' => __DIR__ . '/../..' . '/src/Toolkit/Dir.php',
    +        'Kirby\\Toolkit\\Dom' => __DIR__ . '/../..' . '/src/Toolkit/Dom.php',
             'Kirby\\Toolkit\\Escape' => __DIR__ . '/../..' . '/src/Toolkit/Escape.php',
             'Kirby\\Toolkit\\F' => __DIR__ . '/../..' . '/src/Toolkit/F.php',
             'Kirby\\Toolkit\\Facade' => __DIR__ . '/../..' . '/src/Toolkit/Facade.php',
    
  • vendor/composer/installed.php+4 4 modified
    @@ -1,7 +1,7 @@
     <?php return array(
         'root' => array(
    -        'pretty_version' => '3.5.7.1',
    -        'version' => '3.5.7.1',
    +        'pretty_version' => '3.5.8',
    +        'version' => '3.5.8.0',
             'type' => 'kirby-cms',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),
    @@ -29,8 +29,8 @@
                 'dev_requirement' => false,
             ),
             'getkirby/cms' => array(
    -            'pretty_version' => '3.5.7.1',
    -            'version' => '3.5.7.1',
    +            'pretty_version' => '3.5.8',
    +            'version' => '3.5.8.0',
                 'type' => 'kirby-cms',
                 'install_path' => __DIR__ . '/../../',
                 'aliases' => array(),
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.