Explorar o código

Merge branch 'master' into scrutinizer-fix

El RIDO hai 3 días
pai
achega
58066d2434

+ 1 - 1
.devcontainer/devcontainer.json

@@ -38,7 +38,7 @@
     "features": {
         "ghcr.io/devcontainers-extra/features/mocha:2": {},
         "ghcr.io/yassinedoghri/devcontainers/php-extensions-installer:1": {
-            "extensions": "gd"
+            "extensions": "gd intl"
         }
     },
     "forwardPorts": [

+ 2 - 2
.github/workflows/codeql-analysis.yml

@@ -43,7 +43,7 @@ jobs:
 
     # Initializes the CodeQL tools for scanning.
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@v4.39.1
+      uses: github/codeql-action/init@v4.39.2
       with:
         languages: ${{ matrix.language }}
         # If you wish to specify custom queries, you can do so here or in a config file.
@@ -52,4 +52,4 @@ jobs:
         # queries: ./path/to/local/query, your-org/your-repo/queries@main
 
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@v4.39.1
+      uses: github/codeql-action/analyze@v4.39.2

+ 1 - 1
.github/workflows/snyk-scan.yml

@@ -35,6 +35,6 @@ jobs:
         with:
           args: --sarif-file-output=snyk.sarif
       - name: Upload result to GitHub Code Scanning
-        uses: github/codeql-action/upload-sarif@v4.39.1
+        uses: github/codeql-action/upload-sarif@v4.39.2
         with:
           sarif_file: snyk.sarif

+ 1 - 1
.github/workflows/test-results.yml

@@ -25,7 +25,7 @@ jobs:
 
     steps:
        - name: Download and Extract Artifacts
-         uses: dawidd6/action-download-artifact@eab87c9830c39eff17e5a6eadb20bfb4bc880477
+         uses: dawidd6/action-download-artifact@a92c7ae74cf1df8810783758581e485ac3aaa309
          with:
           run_id: ${{ github.event.workflow_run.id }}
           path: artifacts

+ 16 - 6
CHANGELOG.md

@@ -1,20 +1,30 @@
 # PrivateBin version history
 
-## 2.1.1 (not yet released)
-* FIXED: Creating a document failed with a JavaScript error when the `discussion`, `password`, e-mail or QR code option is disabled, as the top navigation accessed the omitted elements
+## 2.1.1 (2026-11-04)
 * ADDED: Translation for Korean (#2071)
+* ADDED: Comments authenticate the IDs of their document and parent comment if present. Older comments without these IDs are still accepted and shown, unless the new `discussionrequireauthenticatedids` option is enabled to validate these fields (#2101)
+* CHANGED: Check `sizelimit` over full request, before attempting decoding
+* CHANGED: Database backend now logs errors when reading comments, like it already did for comment existence checks (#2100)
+* FIXED: Creating a document failed with a JavaScript error when the `discussion`, `password`, e-mail or QR code option is disabled, as the top navigation accessed the omitted elements
 * FIXED: Slovenian plural forms were stored in the wrong order, i.e. "1 uri" instead of "1 ura" for one hour (#1951)
 * FIXED: Lithuanian plural formula selected the "few" form for numbers ending in 0 above 10, i.e. "20 sekundės" instead of "20 sekundžių" (#1956)
+* FIXED: new paste button resets it to default formatter/style instead of hardcoding 'plaintext' (#2026)
+* FIXED: Comments must now be unique in cipher text within a document, not reuse existing cipher texts (#2100)
+* FIXED: Password peek button next to the password input when creating a document had no effect
+* FIXED: E-mail button sent several e-mails after being shown again or after its confirmation dialog was dismissed
+* FIXED: SVG attachment previews could turn non-ASCII characters into markup after sanitization, enabling script execution when opening the preview in a new tab
+* FIXED: SVG attachment previews use a data URL, so they no longer run in the instance's origin when opened in a new tab
+* FIXED: Password characters above U+00FF got truncated before key derivation, now passwords get UTF-8 encoded and existing documents remain readable
 
 ## 2.1.0 (2026-10-18)
 * ADDED: Added `shortenviachhoto` endpoint with an `chhoto` configuration section
 * ADDED: Translation for Chinese (Traditional)
 * ADDED: Subject line to the "Email" share button's mailto link (#928)
 * ADDED: Send the document with Ctrl+S, or Cmd+S on macOS (#739)
-* CHANGED: We removed jQuery in the Frontend and replaced it with vanilla JS.
-* CHANGED: Removed the unmaintained js-verify and replaced it with fast-check library.
-* CHANGED: Added a `jsconfig.json` in order to check the types of JavaScript.
-* CHANGED: Removed support for Bootstrap 3 as it requires jQuery.
+* CHANGED: We removed jQuery in the Frontend and replaced it with vanilla JS
+* CHANGED: Removed the unmaintained js-verify and replaced it with fast-check library
+* CHANGED: Added a `jsconfig.json` in order to check the types of JavaScript
+* CHANGED: Removed support for Bootstrap 3 as it requires jQuery
 * CHANGED: Encode documents with `TextEncoder` in `compress()`, instead of the `utf16To8()` percent-encoding round trip, which allocated ~20 times the payload
 * CHANGED: Upgrading libraries to: DOMpurify 3.4.17, ip-lib 1.22.1 & polyfill-php80 1.43.0
 * FIXED: Decode decompressed documents with `TextDecoder` instead of `utf8To16()`, which allocated ~3.4 GB for a 100 MB document and killed the tab (#2027)

+ 1 - 1
Makefile

@@ -1,6 +1,6 @@
 .PHONY: all coverage coverage-js coverage-php doc doc-js doc-php increment sign test test-js test-php help
 
-CURRENT_VERSION = 2.1.0
+CURRENT_VERSION = 2.1.1
 VERSION ?= 2.1.1
 VERSION_FILES = README.md SECURITY.md doc/Installation.md js/package.json lib/Controller.php Makefile
 REGEX_CURRENT_VERSION := $(shell echo $(CURRENT_VERSION) | sed "s/\./\\\./g")

+ 1 - 1
README.md

@@ -1,6 +1,6 @@
 # [![PrivateBin](https://raw.githubusercontent.com/PrivateBin/assets/master/images/preview/logoSmall.png)](https://privatebin.info/)
 
-*Current version: 2.1.0*
+*Current version: 2.1.1*
 
 **PrivateBin** is a minimalist, open source online
 [pastebin](https://en.wikipedia.org/wiki/Pastebin)

+ 2 - 2
SECURITY.md

@@ -4,8 +4,8 @@
 
 | Version | Supported          |
 | ------- | ------------------ |
-| 2.1.0   | :heavy_check_mark: |
-| < 2.1.0 | :x:                |
+| 2.1.1   | :heavy_check_mark: |
+| < 2.1.1 | :x:                |
 
 ## Reporting a Vulnerability
 

+ 6 - 1
cfg/conf.sample.php

@@ -23,6 +23,11 @@ opendiscussion = false
 ; the comments by creation time, but you can choose not to display them.
 ; discussiondatedisplay = false
 
+; require new comments to match the IDs of their document and parent
+; comment, defaults to false. Comments sent by clients that don't, are rejected.
+; This prevents copies of comments from getting moved elsewhere in the discussion.
+; discussionrequireauthenticatedids = true
+
 ; enable or disable the password feature, defaults to true
 password = true
 
@@ -40,7 +45,7 @@ defaultformatter = "plaintext"
 ; syntaxhighlightingtheme = "sons-of-obsidian"
 
 ; size limit per document or comment in bytes, defaults to 10 Megabytes
-sizelimit = 10000000
+sizelimit = 10485760
 
 ; by default PrivateBin use "bootstrap5" template (tpl/bootstrap5.php).
 ; Optionally you can enable the template selection menu, which uses

+ 42 - 3
doc/Installation.md

@@ -141,10 +141,49 @@ you can also adjust an existing `robots.txt` file. More bot detection is
 implemented in JavaScript, but note none of these mechanisms is a 100% fail-safe
 way to prevent non-human visitors on your site.
 
+#### Server-side User-Agent blocking
+
+Client-side checks and `robots.txt` only help after a request reaches PrivateBin
+(or when a crawler chooses to honour `robots.txt`). You can additionally reject
+obvious crawler User-Agents in the web server, before PHP runs. That reduces
+noise in logs and lowers the chance that automated clients fetch paste URLs.
+
 A `.htaccess.disabled` file is provided in the root dir of PrivateBin. It blocks
 some known robots and link-scanning bots. If you use Apache, you can rename the
-file to `.htaccess` to enable this feature. If you use another webserver, you
-have to configure it manually to do the same.
+file to `.htaccess` to enable this feature. The shipped rules return HTTP 403
+when the User-Agent matches patterns such as `bot`, `spider`, `crawl`, embedded
+URLs, WhatsApp, Skype preview, or `facebookexternalhit`, while still allowing
+the Let's Encrypt validation User-Agent.
+
+If you use another web server, configure an equivalent deny list manually.
+For nginx, a `map` based on `$http_user_agent` is a common approach:
+
+```nginx
+map $http_user_agent $privatebin_bad_bot {
+    default                                                 0;
+    ~*Let's Encrypt validation server                        0;
+    ~*(?:bot|spider|crawl|https?://|WhatsApp|SkypeUriPreview|facebookexternalhit) 1;
+}
+
+server {
+    # ... existing PrivateBin server block ...
+
+    if ($privatebin_bad_bot) {
+        return 403;
+    }
+}
+```
+
+Tune the patterns for your environment. Broad matches such as `bot` can block
+legitimate tools or uptime monitors that put those substrings in their
+User-Agent. Prefer keeping health checks on simple HTTPS GETs that do not need
+to execute the client-side JavaScript bot detection.
+
+Operators of the official container images can apply the same idea in the
+image web-server config (for example nginx in
+[docker-nginx-fpm-alpine](https://github.com/PrivateBin/docker-nginx-fpm-alpine)
+or the Unit config in
+[docker-unit-alpine](https://github.com/PrivateBin/docker-unit-alpine)).
 
 ### On using Cloudflare
 
@@ -218,7 +257,7 @@ CREATE INDEX parent ON prefix_comment(pasteid);
 CREATE TABLE prefix_config (
     id CHAR(16) NOT NULL, value TEXT, PRIMARY KEY (id)
 );
-INSERT INTO prefix_config VALUES('VERSION', '2.1.0');
+INSERT INTO prefix_config VALUES('VERSION', '2.1.1');
 ```
 
 In **PostgreSQL**, the `data`, `attachment`, `nickname` and `vizhash` columns

+ 1 - 1
js/comment.jsonld

@@ -26,7 +26,7 @@
 			"@type": "pb:CipherText"
 		},
 		"adata": {
-			"@type": "pb:CipherParameters"
+			"@type": "cm:AuthenticatedData"
 		},
 		"meta": {
 			"@type": "cm:MetaData"

+ 37 - 0
js/commentmeta.jsonld

@@ -3,6 +3,43 @@
 		"so": "https://schema.org/",
 		"pb": "?jsonld=types#"
 	},
+	"AuthenticatedData": {
+		"@container": "@list",
+		"@value": [
+			{
+				"@type": "pb:InitializationVector"
+			},
+			{
+				"@type": "pb:Salt"
+			},
+			{
+				"@type": "pb:Iterations"
+			},
+			{
+				"@type": "pb:KeySize"
+			},
+			{
+				"@type": "pb:TagSize"
+			},
+			{
+				"@type": "pb:Algorithm"
+			},
+			{
+				"@type": "pb:Mode"
+			},
+			{
+				"@type": "pb:Compression"
+			},
+			{
+				"@type": "pb:DocumentId",
+				"@optional": true
+			},
+			{
+				"@type": "pb:ParentId",
+				"@optional": true
+			}
+		]
+	},
 	"MetaData": {
 		"created": {
 			"@type": "CreationTime"

+ 6 - 0
js/common.js

@@ -72,6 +72,12 @@ exports.fcA2zString = function() {
     return fc.constantFrom(...a2zString);
 };
 
+// removes %s and %d placeholders from a string, including the whole run of
+// percent signs before them, so the removal can't create new ones (i.e. %%dd)
+exports.removePlaceholders = function(string) {
+    return string.replace(/%+(?=[sd])/g, '');
+};
+
 // provides random lowercase alpha numeric characters (a to z and 0 to 9)
 exports.fcAlnumString = function() {
     return fc.constantFrom(...alnumString);

+ 70 - 80
js/package-lock.json

@@ -10,10 +10,10 @@
       "license": "zlib-acknowledgement",
       "devDependencies": {
         "@eslint/js": "^10.0.1",
-        "@peculiar/webcrypto": "^1.5.0",
-        "eslint": "^10.13.0",
+        "@peculiar/webcrypto": "^2.0.0",
+        "eslint": "^10.14.0",
         "fast-check": "^4.10.2",
-        "globals": "^17.13.0",
+        "globals": "^17.14.0",
         "jsdom": "^30.1.3",
         "jsdom-global": "^3.0.2",
         "jshint": "^2.13.6",
@@ -930,51 +930,59 @@
       "license": "MIT"
     },
     "node_modules/@peculiar/asn1-schema": {
-      "version": "2.7.0",
-      "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz",
-      "integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==",
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-3.0.0.tgz",
+      "integrity": "sha512-VQgBSyf3teIp7219Y0rHFruJUVY/teYkKvvvObuwtwx6+jOt1f1+jvZoTBRua370H0a8eYpnUWoe0G6GqV0yaA==",
       "dev": true,
+      "license": "MIT",
+      "peer": true,
       "dependencies": {
-        "@peculiar/utils": "^2.0.2",
-        "asn1js": "^3.0.6",
-        "tslib": "^2.8.1"
+        "@peculiar/utils": "^4.1.0"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "peerDependencies": {
+        "asn1js": "^4.0.0"
       }
     },
     "node_modules/@peculiar/json-schema": {
-      "version": "1.1.12",
-      "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz",
-      "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==",
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-3.0.0.tgz",
+      "integrity": "sha512-G9dN0s7Znz2Uy1sGvTsiHYKoTYcoZyqUYj635F7R5pDh3uwunDBBH3Cf7+xZQYMRYrZJeBVuaZmf+Evkf0Zcuw==",
       "dev": true,
-      "dependencies": {
-        "tslib": "^2.0.0"
-      },
+      "license": "MIT",
+      "peer": true,
       "engines": {
-        "node": ">=8.0.0"
+        "node": "^20.19.0 || >=22.12.0"
       }
     },
     "node_modules/@peculiar/utils": {
-      "version": "2.0.3",
-      "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
-      "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-4.1.0.tgz",
+      "integrity": "sha512-y2oDqAwp+VPZMUYFGFFMYlXM8xinwb7ehb69+2EhbEL+n7KsykCQX5F0u3/B+BFHxCkwTyft3T1id0ugyzNHVQ==",
       "dev": true,
-      "dependencies": {
-        "tslib": "^2.8.1"
+      "license": "MIT",
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
       }
     },
     "node_modules/@peculiar/webcrypto": {
-      "version": "1.7.1",
-      "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz",
-      "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==",
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-2.0.0.tgz",
+      "integrity": "sha512-JvQXHq92+jj5Db3REr5cO/chYsaTOQdaBNXiUtfUreDibZ5iHpQDB6U5PZqJVSdSoOcjm0SOu84kmJlA+MyIBw==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
-        "@peculiar/asn1-schema": "^2.7.0",
-        "@peculiar/json-schema": "^1.1.12",
-        "@peculiar/utils": "^2.0.2",
-        "tslib": "^2.8.1",
-        "webcrypto-core": "^1.9.2"
+        "@peculiar/utils": "^4.1.0"
       },
       "engines": {
-        "node": ">=14.18.0"
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "peerDependencies": {
+        "@peculiar/asn1-schema": "^3.0.0",
+        "@peculiar/json-schema": "^3.0.0",
+        "webcrypto-core": "^2.0.0"
       }
     },
     "node_modules/@types/esrecurse": {
@@ -1092,17 +1100,17 @@
       "dev": true
     },
     "node_modules/asn1js": {
-      "version": "3.0.10",
-      "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
-      "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-4.0.0.tgz",
+      "integrity": "sha512-81NlooYbXCbBWL2+p1/ivGyT4AR2+bnfKYlPq/3rCoIPJSuLbdS8G70XYt561wOJnbaYfBS2HQDMENaX5hM2vA==",
       "dev": true,
+      "license": "BSD-3-Clause",
+      "peer": true,
       "dependencies": {
-        "pvtsutils": "^1.3.6",
-        "pvutils": "^1.1.5",
-        "tslib": "^2.8.1"
+        "@peculiar/utils": "^4.1.0"
       },
       "engines": {
-        "node": ">=12.0.0"
+        "node": "^20.19.0 || >=22.12.0"
       }
     },
     "node_modules/balanced-match": {
@@ -1577,9 +1585,9 @@
       }
     },
     "node_modules/eslint": {
-      "version": "10.13.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.13.0.tgz",
-      "integrity": "sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==",
+      "version": "10.14.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.14.0.tgz",
+      "integrity": "sha512-T0pNsxkb9IKmtQjhpK3d7nWOpFv+YHuMbTJnSVMoQblF0onvXfErrtp4V1jHtsQPan7dKaGVD0q1WCXnzMF31A==",
       "dev": true,
       "license": "MIT",
       "workspaces": [
@@ -1592,7 +1600,7 @@
         "@eslint/config-helpers": "^0.7.0",
         "@eslint/core": "^1.2.1",
         "@eslint/plugin-kit": "^0.7.3",
-        "@humanfs/node": "^0.16.6",
+        "@humanfs/node": "^0.16.8",
         "@humanwhocodes/module-importer": "^1.0.1",
         "@humanwhocodes/retry": "^0.4.2",
         "@types/estree": "^1.0.6",
@@ -1613,7 +1621,7 @@
         "imurmurhash": "^0.1.4",
         "is-glob": "^4.0.0",
         "json-stable-stringify-without-jsonify": "^1.0.1",
-        "minimatch": "^10.2.5",
+        "minimatch": "^10.2.6",
         "natural-compare": "^1.4.0",
         "optionator": "^0.9.3"
       },
@@ -2047,9 +2055,9 @@
       }
     },
     "node_modules/globals": {
-      "version": "17.13.0",
-      "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz",
-      "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==",
+      "version": "17.14.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-17.14.0.tgz",
+      "integrity": "sha512-UtdkTVyAJePQv0J2M9Xe4L0ruNSDV314XiIJsADI2Zf40bcdAonE/D/OhAU8AT99oij9mhLByb+NvA3QWZYgCg==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -3358,24 +3366,6 @@
       ],
       "license": "MIT"
     },
-    "node_modules/pvtsutils": {
-      "version": "1.3.6",
-      "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
-      "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
-      "dev": true,
-      "dependencies": {
-        "tslib": "^2.8.1"
-      }
-    },
-    "node_modules/pvutils": {
-      "version": "1.1.5",
-      "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
-      "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
-      "dev": true,
-      "engines": {
-        "node": ">=16.0.0"
-      }
-    },
     "node_modules/qified": {
       "version": "0.10.1",
       "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz",
@@ -3514,9 +3504,9 @@
       }
     },
     "node_modules/serialize-javascript": {
-      "version": "7.1.2",
-      "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.2.tgz",
-      "integrity": "sha512-GL2BWwVa6JydKO6l/ljVgjAZF4QJ3S7dWDWi53s5GZT8PJzD2fNxi67HANQGKAqPrwEpL5ba7gUBGi0Ls/sEoQ==",
+      "version": "7.1.4",
+      "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.4.tgz",
+      "integrity": "sha512-FqlEuoTq/zRmA4Lg6aOqyH3lCHbUtCB0bmHCeFniX2GBdDqMJM3FwdvEhlE5Pim1dlt2FfBdVPmUNNiGhhKqpw==",
       "dev": true,
       "license": "BSD-3-Clause",
       "engines": {
@@ -3761,12 +3751,6 @@
       "integrity": "sha512-93WeyHNuRggPEsfCe+yHxCgM2s6H3Q8Wmlt6b6ObJL8qc7eZlRaFjQxwTrB+zbvGtlDRnAkMoYYo3+2uH/fEwA==",
       "dev": true
     },
-    "node_modules/tslib": {
-      "version": "2.8.1",
-      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
-      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
-      "dev": true
-    },
     "node_modules/type-check": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -3871,16 +3855,22 @@
       }
     },
     "node_modules/webcrypto-core": {
-      "version": "1.9.2",
-      "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz",
-      "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==",
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-2.0.0.tgz",
+      "integrity": "sha512-dQv+gpwrrDy5nCmy/qvlUU3/Sx6CMISXkku1dSF9vjkhCMUcw7pA7A/qnwe/nKjriN6la534i+cYH58gTxyhXg==",
       "dev": true,
+      "license": "MIT",
+      "peer": true,
       "dependencies": {
-        "@peculiar/asn1-schema": "^2.7.0",
-        "@peculiar/json-schema": "^1.1.12",
-        "@peculiar/utils": "^2.0.2",
-        "asn1js": "^3.0.10",
-        "tslib": "^2.8.1"
+        "@peculiar/utils": "^4.1.0"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "peerDependencies": {
+        "@peculiar/asn1-schema": "^3.0.0",
+        "@peculiar/json-schema": "^3.0.0",
+        "asn1js": "^4.0.0"
       }
     },
     "node_modules/webidl-conversions": {

+ 4 - 4
js/package.json

@@ -1,6 +1,6 @@
 {
   "name": "privatebin",
-  "version": "2.1.0",
+  "version": "2.1.1",
   "description": "PrivateBin is a minimalist, open source online pastebin where the server has zero knowledge of stored data. Data is encrypted/decrypted in the browser using 256 bit AES in Galois Counter mode (GCM).",
   "main": "privatebin.js",
   "directories": {
@@ -8,10 +8,10 @@
   },
   "devDependencies": {
     "@eslint/js": "^10.0.1",
-    "@peculiar/webcrypto": "^1.5.0",
-    "eslint": "^10.13.0",
+    "@peculiar/webcrypto": "^2.0.0",
+    "eslint": "^10.14.0",
     "fast-check": "^4.10.2",
-    "globals": "^17.13.0",
+    "globals": "^17.14.0",
     "jsdom": "^30.1.3",
     "jsdom-global": "^3.0.2",
     "jshint": "^2.13.6",

+ 235 - 132
js/privatebin.js

@@ -229,6 +229,25 @@ window.PrivateBin = (function () {
         this.getIcon = function () {
             return this.meta.icon || '';
         };
+
+        /**
+         * checks if the comment belongs to the given document and its parent
+         *
+         * Comments without authenticated IDs predate this check and are
+         * accepted.
+         *
+         * @name Comment.isBoundTo
+         * @function
+         * @param  {string} pasteId
+         * @return {bool}
+         */
+        this.isBoundTo = function (pasteId) {
+            return this.adata.length === 8 || (
+                this.adata.length === 10 &&
+                this.adata[8] === pasteId &&
+                this.adata[9] === this.parentid
+            );
+        };
     }
 
     /**
@@ -1046,14 +1065,16 @@ window.PrivateBin = (function () {
         }
 
         /**
-         * convert UTF-8 string into a Uint8Array
+         * convert binary string into a Uint8Array
          *
-         * Iterates over the bytes of the message, writing them to the array
+         * Iterates over the characters of the message, writing their codes to
+         * the array. Only use this for binary strings, i.e. the output of atob,
+         * characters above U+00FF get truncated to their lowest byte.
          *
          * @name   CryptTool.stringToArraybuffer
          * @function
          * @private
-         * @param  {string} message UTF-8 string
+         * @param  {string} message binary string
          * @return {Uint8Array} array
          */
         function stringToArraybuffer(message)
@@ -1140,16 +1161,15 @@ window.PrivateBin = (function () {
          * @async
          * @function
          * @private
-         * @param  {string} key
-         * @param  {string} password
-         * @param  {array}  spec cryptographic specification
-         * @return {CryptoKey} derived key
+         * @param  {string}     key
+         * @param  {Uint8Array} passwordArray encoded password
+         * @param  {array}      spec cryptographic specification
+         * @return {CryptoKey}  derived key
          */
-        async function deriveKey(key, password, spec) {
+        async function deriveKey(key, passwordArray, spec) {
             let keyArray = stringToArraybuffer(key);
-            if (password.length > 0) {
-                let passwordArray = stringToArraybuffer(password),
-                    newKeyArray = new Uint8Array(keyArray.length + passwordArray.length);
+            if (passwordArray.length > 0) {
+                const newKeyArray = new Uint8Array(keyArray.length + passwordArray.length);
                 newKeyArray.set(keyArray, 0);
                 newKeyArray.set(passwordArray, keyArray.length);
                 keyArray = newKeyArray;
@@ -1235,12 +1255,12 @@ window.PrivateBin = (function () {
             for (let i = 0; i < spec.length; ++i) {
                 encodedSpec[i] = i < 2 ? btoa(spec[i]) : spec[i];
             }
-            if (adata.length === 0) {
-                // comment
-                adata = encodedSpec;
-            } else if (adata[0] === null) {
+            if (adata[0] === null) {
                 // paste
                 adata[0] = encodedSpec;
+            } else {
+                // comment, optionally followed by its document and parent IDs
+                adata = encodedSpec.concat(adata);
             }
 
             // finally, encrypt message
@@ -1249,7 +1269,11 @@ window.PrivateBin = (function () {
                     arraybufferToString(
                         await window.crypto.subtle.encrypt(
                             cryptoSettings(JSON.stringify(adata), spec),
-                            await deriveKey(key, password, spec),
+                            await deriveKey(
+                                key,
+                                new TextEncoder().encode(password),
+                                spec
+                            ),
                             await compress(message, compression, zlib)
                         ).catch(Alert.showError)
                     )
@@ -1288,16 +1312,27 @@ window.PrivateBin = (function () {
                     throw 'Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.';
                 }
             }
-            try {
-                plaintext = await window.crypto.subtle.decrypt(
-                    cryptoSettings(adataString, spec),
-                    await deriveKey(key, password, spec),
-                    stringToArraybuffer(
-                        atob(cipherMessage)
-                    )
-                );
-            } catch (err) {
-                console.error(err);
+            const passwordArray = [new TextEncoder().encode(password)];
+            // documents created by earlier versions truncated the characters
+            // of non-ASCII passwords to their lowest byte
+            if (/[\u0080-\uffff]/.test(password)) {
+                passwordArray.push(stringToArraybuffer(password));
+            }
+            let decryptionError;
+            for (const passwordAttempt of passwordArray) {
+                try {
+                    plaintext = await window.crypto.subtle.decrypt(
+                        cryptoSettings(adataString, spec),
+                        await deriveKey(key, passwordAttempt, spec),
+                        stringToArraybuffer(atob(cipherMessage))
+                    );
+                    break;
+                } catch (err) {
+                    decryptionError = err;
+                }
+            }
+            if (typeof plaintext === 'undefined') {
+                console.error(decryptionError);
                 return '';
             }
             try {
@@ -2823,23 +2858,19 @@ window.PrivateBin = (function () {
             dropzone;
 
         /**
-         * get blob URL from string data and mime type
+         * get blob URL from binary data and mime type
          *
          * @name   AttachmentViewer.getBlobUrl
          * @private
          * @function
-         * @param {string} data - raw data of attachment
-         * @param {string} data - mime type of attachment
+         * @param {Uint8Array} data - raw data of attachment
+         * @param {string} mimeType - mime type of attachment
          * @return {string} objectURL
          */
         function getBlobUrl(data, mimeType) {
             // Transform into a Blob
-            const buf = Uint8Array.from(
-                data,
-                character => character.charCodeAt(0)
-            );
             const blob = new window.Blob(
-                [buf],
+                [data],
                 {
                     type: mimeType
                 }
@@ -2878,12 +2909,19 @@ window.PrivateBin = (function () {
                 sanitizedMimeType = mimeType;
             }
 
-            // extract data and convert to binary
+            // extract data and convert to binary - atob returns a binary
+            // string, each character representing a single byte, so taking
+            // the character codes is lossless here, as opposed to strings in
+            // general, that may contain characters above U+00FF
             const base64Data = attachmentData.substring(base64Start);
-            const plainData = base64Data.length > 0 ? atob(base64Data) : '';
+            const plainData = Uint8Array.from(
+                base64Data.length > 0 ? atob(base64Data) : '',
+                character => character.charCodeAt(0)
+            );
 
-            let blobUrl = getBlobUrl(plainData, sanitizedMimeType);
+            const blobUrl = getBlobUrl(plainData, sanitizedMimeType);
             attachmentLink.setAttribute('href', blobUrl);
+            let previewUrl = blobUrl;
 
             if (typeof fileName !== 'undefined') {
                 attachmentLink.setAttribute('download', fileName);
@@ -2895,39 +2933,39 @@ window.PrivateBin = (function () {
             }
 
             // sanitize SVG preview
-            // prevents executing embedded scripts when CSP is not set and user
-            // right-clicks/long-taps and opens the SVG in a new tab - prevented
-            // in the preview by use of an img tag, which disables scripts, too
+            // prevents executing embedded scripts when the user right-clicks/
+            // long-taps and opens the SVG in a new tab - prevented in the
+            // preview by use of an img tag, which disables scripts, too
             if (mimeType.startsWith('image/svg')) {
                 try {
                     // attempt to UTF-8 decode the SVG data
-                    const svgBuffer = Uint8Array.from(
-                        plainData,
-                        character => character.charCodeAt(0)
-                    );
                     const utf8ValidatedSvgString = new TextDecoder(
                         'utf-8',
                         {fatal: true}
-                    ).decode(svgBuffer);
+                    ).decode(plainData);
                     const sanitizedData = DOMPurify.sanitize(
                         utf8ValidatedSvgString,
                         purifySvgConfig
                     );
+                    // Use a data URL instead of a blob URL: blob URLs share the
+                    // origin of this page, so any script surviving sanitization
+                    // would run in it, if the user opens the image in a new tab.
+                    // Data URLs get an opaque origin or are blocked from being
+                    // opened in a new tab. encodeURIComponent UTF-8 encodes the
+                    // sanitized string, that may contain any unicode character,
+                    // i.e. from decoded character references.
+                    previewUrl = 'data:image/svg+xml;charset=utf-8,' +
+                        encodeURIComponent(sanitizedData);
                     sanitizedMimeType = 'image/svg+xml';
-                    blobUrl = getBlobUrl(sanitizedData, sanitizedMimeType);
                 } catch (e) {
-                    // Invalid or non-UTF-8 SVG: download only, no preview as it
-                    // may be used to smuggle multi-byte sequences past DOMpurify
-                    // such as `&#x13c` to get `\x01<` & `&#x13e` to get `\x01>`
-                    sanitizedMimeType = 'application/octet-stream';
-                    blobUrl = getBlobUrl(plainData, sanitizedMimeType);
+                    // Invalid or non-UTF-8 SVG: download only, no preview
                 }
             }
 
             template.classList.remove('hidden');
             attachment.appendChild(template);
 
-            me.handleBlobAttachmentPreview(attachmentPreview, blobUrl, sanitizedMimeType);
+            me.handleBlobAttachmentPreview(attachmentPreview, previewUrl, sanitizedMimeType);
         };
 
 
@@ -3242,7 +3280,9 @@ window.PrivateBin = (function () {
                 return;
             }
 
-            const alreadyIncludesCurrentAttachment = targetElement.querySelectorAll(`[src='${blobUrl}']`).length > 0;
+            const alreadyIncludesCurrentAttachment = Array.from(
+                targetElement.querySelectorAll('[src]')
+            ).some(element => element.getAttribute('src') === blobUrl);
 
             if (blobUrl && !alreadyIncludesCurrentAttachment) {
                 if (mimeType.startsWith('image/')) {
@@ -3807,7 +3847,13 @@ window.PrivateBin = (function () {
             sendButton,
             retryButton,
             pasteExpiration = null,
-            retryButtonCallback;
+            retryButtonCallback,
+            emailConfirmModal,
+            emailConfirmBootstrapModal = null,
+            emailConfirmTimezoneCurrent,
+            emailConfirmTimezoneUtc,
+            emailDocument = null,
+            emailConfirmDocument = null;
 
         /**
          * set the expiration on bootstrap templates in dropdown
@@ -4178,10 +4224,10 @@ window.PrivateBin = (function () {
          *
          * @name   TopNav.triggerEmailSend
          * @private
-         * @param {string} subject
          * @param {string} emailBody
          */
-        function triggerEmailSend(subject, emailBody) {
+        function triggerEmailSend(emailBody) {
+            const subject = I18n._('Encrypted note on %s', document.title);
             window.open(
                 `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(emailBody)}`,
                 '_self',
@@ -4190,64 +4236,84 @@ window.PrivateBin = (function () {
         }
 
         /**
-         * Send Email with current document (URL).
+         * formats the expiration date for the e-mail body
+         *
+         * The date gets rounded down by at least 30 seconds, to make up for
+         * the delay of the request. We don't use Date.prototype.toUTCString(),
+         * because we would like to avoid GMT.
+         *
+         * @name   TopNav.formatEmailExpiration
+         * @private
+         * @function
+         * @param  {number} expirationDate - timestamp in milliseconds
+         * @param  {bool} isUtc - use UTC instead of the current timezone
+         * @return {string}
+         */
+        function formatEmailExpiration(expirationDate, isUtc) {
+            const roundedDate = new Date(expirationDate);
+            roundedDate.setUTCSeconds(roundedDate.getUTCSeconds() - 30);
+            roundedDate.setUTCSeconds(0);
+
+            const localeConfiguration = { dateStyle: 'long', timeStyle: 'long' };
+            if (isUtc) {
+                localeConfiguration.timeZone = 'UTC';
+            }
+            return roundedDate.toLocaleString([], localeConfiguration);
+        }
+
+        /**
+         * Send Email with current document (URL), asking to confirm the
+         * timezone of its expiration first, if it has one.
          *
          * @name   TopNav.sendEmail
          * @private
          * @function
-         * @param  {Date|null} expirationDate date of expiration
-         * @param  {bool} isBurnafterreading whether it is burn after reading
          */
-        function sendEmail(expirationDate, isBurnafterreading) {
-            const expirationDateRoundedToSecond = new Date(expirationDate);
+        function sendEmail() {
+            // ignore clicks on the hidden button
+            if (emailDocument === null) {
+                return;
+            }
 
-            // round down at least 30 seconds to make up for the delay of request
-            expirationDateRoundedToSecond.setUTCSeconds(
-                expirationDateRoundedToSecond.getUTCSeconds() - 30
-            );
-            expirationDateRoundedToSecond.setUTCSeconds(0);
-
-            // reused as-is by both the confirmation-modal and direct-send paths below
-            const emailSubject = I18n._('Encrypted note on %s', document.title);
-
-            const emailconfirmmodal = document.getElementById('emailconfirmmodal');
-            if (expirationDate !== null) {
-                const emailconfirmTimezoneCurrent = emailconfirmmodal.querySelector('#emailconfirm-timezone-current');
-                const emailconfirmTimezoneUtc = emailconfirmmodal.querySelector('#emailconfirm-timezone-utc');
-                let localeConfiguration = { dateStyle: 'long', timeStyle: 'long' };
-                const bootstrap5EmailConfirmModal = typeof bootstrap !== 'undefined' && bootstrap.Tooltip && bootstrap.Tooltip.VERSION ?
-                    new bootstrap.Modal(emailconfirmmodal) : null;
-
-                function sendEmailAndHideModal() {
-                    const emailBody = templateEmailBody(
-                        // we don't use Date.prototype.toUTCString() because we would like to avoid GMT
-                        expirationDateRoundedToSecond.toLocaleString(
-                            [], localeConfiguration
-                        ), isBurnafterreading
-                    );
-                    if (bootstrap5EmailConfirmModal) {
-                        bootstrap5EmailConfirmModal.hide();
-                    }
-                    triggerEmailSend(emailSubject, emailBody);
-                }
+            if (emailDocument.expirationDate === null) {
+                triggerEmailSend(templateEmailBody(null, emailDocument.isBurnafterreading));
+                return;
+            }
 
-                emailconfirmmodal.addEventListener('shown.bs.modal', () => {
-                    emailconfirmTimezoneUtc.focus();
-                });
+            emailConfirmDocument = emailDocument;
+            if (emailConfirmBootstrapModal) {
+                emailConfirmBootstrapModal.show();
+            }
+        }
 
-                emailconfirmTimezoneCurrent.removeEventListener('click', sendEmailAndHideModal);
-                emailconfirmTimezoneCurrent.addEventListener('click', sendEmailAndHideModal);
-                emailconfirmTimezoneUtc.removeEventListener('click', sendEmailAndHideModal);
-                emailconfirmTimezoneUtc.addEventListener('click', () => {
-                    localeConfiguration.timeZone = 'UTC';
-                    sendEmailAndHideModal();
-                });
-                if (bootstrap5EmailConfirmModal) {
-                    bootstrap5EmailConfirmModal.show();
-                }
-            } else {
-                triggerEmailSend(emailSubject, templateEmailBody(null, isBurnafterreading));
+        /**
+         * Send the Email of the confirmation dialog, with the expiration in
+         * the chosen timezone.
+         *
+         * @name   TopNav.confirmEmailTimezone
+         * @private
+         * @function
+         * @param  {Event} event
+         */
+        function confirmEmailTimezone(event) {
+            // ignore a dialog opened for an earlier document
+            if (emailConfirmDocument === null) {
+                return;
             }
+
+            const emailBody = templateEmailBody(
+                formatEmailExpiration(
+                    emailConfirmDocument.expirationDate,
+                    event.currentTarget === emailConfirmTimezoneUtc
+                ),
+                emailConfirmDocument.isBurnafterreading
+            );
+            // only send once per opening of the dialog
+            emailConfirmDocument = null;
+            if (emailConfirmBootstrapModal) {
+                emailConfirmBootstrapModal.hide();
+            }
+            triggerEmailSend(emailBody);
         }
 
         /**
@@ -4415,20 +4481,21 @@ window.PrivateBin = (function () {
                 return;
             }
 
+            // forget a previous document, even if the expiration can't be calculated
+            emailDocument = null;
+            emailConfirmDocument = null;
             try {
-                // we cache expiration date in closure to avoid inaccurate expiration datetime
+                // we cache the expiration date to avoid an inaccurate expiration datetime
                 const expirationDate = Helper.calculateExpirationDate(
                     new Date(),
                     typeof optionalRemainingTimeInSeconds === 'number' ? optionalRemainingTimeInSeconds : TopNav.getExpiration()
                 );
-                const isBurnafterreading = typeof optionalBurnAfterReading === 'boolean' ?
-                    optionalBurnAfterReading : TopNav.getBurnAfterReading();
-
+                emailDocument = {
+                    expirationDate: expirationDate,
+                    isBurnafterreading: typeof optionalBurnAfterReading === 'boolean' ?
+                        optionalBurnAfterReading : TopNav.getBurnAfterReading()
+                };
                 emailLink.classList.remove('hidden');
-                emailLink.removeEventListener('click', sendEmail);
-                emailLink.addEventListener('click', () => {
-                    sendEmail(expirationDate, isBurnafterreading);
-                });
             } catch (error) {
                 console.error(error);
                 Alert.showError('Cannot calculate expiration date.');
@@ -4447,7 +4514,8 @@ window.PrivateBin = (function () {
             }
 
             emailLink.classList.add('hidden');
-            emailLink.removeEventListener('click', sendEmail);
+            emailDocument = null;
+            emailConfirmDocument = null;
         };
 
         /**
@@ -4743,13 +4811,26 @@ window.PrivateBin = (function () {
         }
 
         /**
-         * init navigation manager
+         * bind the event listeners of the e-mail button and its confirmation
+         * dialog
          *
-         * preloads DOM elements
-         *
-         * @name   TopNav.init
+         * @name   TopNav.bindEmailEvents
+         * @private
          * @function
          */
+        function bindEmailEvents() {
+            if (emailLink) {
+                emailLink.addEventListener('click', sendEmail);
+            }
+            if (emailConfirmModal) {
+                emailConfirmModal.addEventListener('shown.bs.modal', () => {
+                    emailConfirmTimezoneUtc.focus();
+                });
+                emailConfirmTimezoneCurrent.addEventListener('click', confirmEmailTimezone);
+                emailConfirmTimezoneUtc.addEventListener('click', confirmEmailTimezone);
+            }
+        }
+
         /**
          * bind all navigation event listeners
          *
@@ -4804,6 +4885,7 @@ window.PrivateBin = (function () {
             if (qrCodeLink) {
                 qrCodeLink.addEventListener('click', displayQrCode);
             }
+            bindEmailEvents();
 
             if (Helper.isBootstrap5()) {
                 const pasteExpirationSelect = document.getElementById('pasteExpiration');
@@ -4847,6 +4929,14 @@ window.PrivateBin = (function () {
             }
         }
 
+        /**
+         * init navigation manager
+         *
+         * preloads DOM elements
+         *
+         * @name   TopNav.init
+         * @function
+         */
         me.init = function () {
             attach = document.getElementById('attach');
             burnAfterReading = document.getElementById('burnafterreading');
@@ -4868,6 +4958,14 @@ window.PrivateBin = (function () {
             sendButton = document.getElementById('sendbutton');
             qrCodeLink = document.getElementById('qrcodelink');
             emailLink = document.getElementById('emaillink');
+            emailConfirmModal = document.getElementById('emailconfirmmodal');
+            emailConfirmTimezoneCurrent = document.getElementById('emailconfirm-timezone-current');
+            emailConfirmTimezoneUtc = document.getElementById('emailconfirm-timezone-utc');
+            emailConfirmBootstrapModal = emailConfirmModal && typeof bootstrap !== 'undefined' &&
+                bootstrap.Tooltip && typeof bootstrap.Modal === 'function' ?
+                new bootstrap.Modal(emailConfirmModal) : null;
+            emailDocument = null;
+            emailConfirmDocument = null;
 
             bindEvents();
 
@@ -5279,14 +5377,16 @@ window.PrivateBin = (function () {
             });
 
             // fill it with unencrypted params
-            ServerInteraction.setUnencryptedData('pasteid', Model.getPasteId());
+            const pasteid = Model.getPasteId();
             if (typeof parentid === 'undefined') {
                 // if parent id is not set, this is the top-most comment, so use
                 // document id as parent, as the root element of the discussion tree
-                ServerInteraction.setUnencryptedData('parentid', Model.getPasteId());
-            } else {
-                ServerInteraction.setUnencryptedData('parentid', parentid);
+                parentid = pasteid;
             }
+            ServerInteraction.setUnencryptedData('pasteid', pasteid);
+            ServerInteraction.setUnencryptedData('parentid', parentid);
+            // authenticate the IDs, so the comment can't be moved elsewhere
+            ServerInteraction.setUnencryptedData('adata', [pasteid, parentid]);
 
             // prepare cypher message
             let cipherMessage = {
@@ -5542,11 +5642,15 @@ window.PrivateBin = (function () {
             const commentDecryptionPromises = [];
             // iterate over comments
             for (let i = 0; i < paste.comments.length; ++i) {
-                const comment = new Comment(paste.comments[i]),
-                    commentPromise = CryptTool.decipher(key, password, comment.getCipherData());
+                const comment = new Comment(paste.comments[i]);
                 paste.comments[i] = comment;
+                // skip comments that were moved from another document or parent
+                if (!comment.isBoundTo(Model.getPasteId())) {
+                    commentDecryptionPromises.push(Promise.resolve(['', '']));
+                    continue;
+                }
                 commentDecryptionPromises.push(
-                    commentPromise.then(function (commentJson) {
+                    CryptTool.decipher(key, password, comment.getCipherData()).then(function (commentJson) {
                         const commentMessage = JSON.parse(commentJson);
                         return [
                             commentMessage.comment || '',
@@ -5864,11 +5968,9 @@ window.PrivateBin = (function () {
          * @function
          */
         me.init = function () {
-            const revealButton = document.querySelector('.toggle-password');
-            if (!revealButton) {
-                return;
-            }
-            revealButton.addEventListener('click', handleRevealButtonClick);
+            document.querySelectorAll('.toggle-password').forEach(function (revealButton) {
+                revealButton.addEventListener('click', handleRevealButtonClick);
+            });
         };
 
         return me;
@@ -5918,8 +6020,9 @@ window.PrivateBin = (function () {
             TopNav.resetInput();
 
             // reset format
-            PasteViewer.setFormat('plaintext');
-            TopNav.setFormat('plaintext');
+            const defaultFormat = Model.getFormatDefault() || 'plaintext';
+            PasteViewer.setFormat(defaultFormat);
+            TopNav.setFormat(defaultFormat);
 
             TopNav.showCreateButtons();
 

+ 181 - 2
js/test/AttachmentViewer.js

@@ -45,8 +45,8 @@ describe('AttachmentViewer', function () {
                     if (filename === '\u0000') {
                         filename = '';
                     }
-                    prefix  = prefix.replace(/%(s|d)/g, '%%');
-                    postfix = postfix.replace(/%(s|d)/g, '%%').replace(/<|>/g, '');
+                    prefix  = common.removePlaceholders(prefix);
+                    postfix = common.removePlaceholders(postfix).replace(/<|>/g, '');
                     document.body.innerHTML = bodyTemplate;
                     mockCreateObjectUrl(false);
                     PrivateBin.AttachmentViewer.init();
@@ -278,6 +278,185 @@ describe('AttachmentViewer', function () {
         )
     });
 
+    describe('SVG preview encoding', function () {
+        this.timeout(30000);
+
+        // same as purifySvgConfig in privatebin.js
+        const purifySvgConfig = {
+            USE_PROFILES: {
+                svg: true,
+                svgFilters: true
+            }
+        };
+
+        // runs setAttachment on the given data URI and returns the created blobs
+        function getAttachmentBlobs(dataUri) {
+            const blobs = [];
+            document.body.innerHTML = bodyTemplate;
+            Object.defineProperty(
+                window.URL,
+                'createObjectURL',
+                {
+                    configurable: true,
+                    value: function (blob) {
+                        blobs.push(blob);
+                        return 'blob:' + blob.type + '/1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed';
+                    }
+                }
+            );
+            PrivateBin.AttachmentViewer.init();
+            PrivateBin.Model.init();
+            global.atob = common.atob;
+            PrivateBin.AttachmentViewer.setAttachment(dataUri, 'attachment');
+            return blobs;
+        }
+
+        // jsdom's Blob lacks arrayBuffer(), so read it using FileReader
+        async function readBlob(blob) {
+            const buffer = await new Promise((resolve, reject) => {
+                const reader = new window.FileReader();
+                reader.onload = () => resolve(reader.result);
+                reader.onerror = () => reject(reader.error);
+                reader.readAsArrayBuffer(blob);
+            });
+            return new Uint8Array(buffer);
+        }
+
+        // returns the bytes of a data URL, either base64 or percent-encoded
+        function readDataUrl(url) {
+            const match = /^data:([^,]*),(.*)$/s.exec(url);
+            assert.ok(match, 'uses a data URL, got: ' + url.substring(0, 50));
+            if (match[1].endsWith(';base64')) {
+                return new Uint8Array(Buffer.from(match[2], 'base64'));
+            }
+            assert.ok(/^[\x21-\x7e]*$/.test(match[2]), 'percent-encoded data URL only contains ASCII');
+            return new Uint8Array(Buffer.from(
+                match[2].replace(
+                    /%([0-9a-f]{2})/gi,
+                    (_, hex) => String.fromCharCode(parseInt(hex, 16))
+                ),
+                'latin1'
+            ));
+        }
+
+        // runs setAttachment on the given SVG source and returns the bytes of
+        // the sanitized SVG, used for the preview
+        async function getSvgPreviewBytes(svg) {
+            getAttachmentBlobs(
+                'data:image/svg+xml;base64,' + Buffer.from(svg, 'utf8').toString('base64')
+            );
+            const images = document.querySelectorAll('#attachmentPreview img');
+            assert.strictEqual(images.length, 1, 'shows one SVG preview');
+            return readDataUrl(images[0].getAttribute('src'));
+        }
+
+        const lookalikeVectors = {
+            'literal ļ/ľ (U+013C/U+013E)': '<svg xmlns="http://www.w3.org/2000/svg">\u013cscript\u013ealert(1)\u013c/script\u013e</svg>',
+            'character references &#x13c;/&#x13e; (U+013C/U+013E)': '<svg xmlns="http://www.w3.org/2000/svg">&#x13c;script&#x13e;alert(1)&#x13c;/script&#x13e;</svg>',
+            'CJK 丼/举 (U+4E3C/U+4E3E)': '<svg xmlns="http://www.w3.org/2000/svg">\u4e3cscript\u4e3ealert(1)\u4e3c/script\u4e3e</svg>'
+        };
+        for (const [name, svg] of Object.entries(lookalikeVectors)) {
+            it('does not turn look-alike characters into markup: ' + name, async function () {
+                const bytes = await getSvgPreviewBytes(svg),
+                    latin1 = Buffer.from(bytes).toString('latin1');
+                assert.ok(
+                    !/<\/?script/i.test(latin1),
+                    'sanitized SVG preview must not contain script tags, got: ' + latin1
+                );
+            });
+        }
+
+        it('UTF-8 encodes the sanitized SVG without loss', async function () {
+            await fc.assert(fc.asyncProperty(
+                fc.string({unit: 'binary'}),
+                async function (text) {
+                    // escape markup, so the text ends up as a single text node
+                    const escaped = text
+                        .replace(/&/g, '&amp;')
+                        .replace(/</g, '&lt;')
+                        .replace(/>/g, '&gt;'),
+                        svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>' + escaped + '</text></svg>',
+                        // lone surrogates can't be UTF-8 encoded, so normalize the input first
+                        decodedSvg = Buffer.from(svg, 'utf8').toString('utf8'),
+                        expected = DOMPurify.sanitize(decodedSvg, purifySvgConfig),
+                        bytes = await getSvgPreviewBytes(svg),
+                        actual = new TextDecoder('utf-8', {fatal: true}).decode(bytes);
+                    PrivateBin.AttachmentViewer.removeAttachment();
+                    return expected === actual;
+                }
+            ),
+            {numRuns: 100});
+        });
+
+        it('previews SVGs using a data URL, not a same-origin blob URL', function () {
+            const svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>Gotcha</text></svg>',
+                blobs = getAttachmentBlobs(
+                    'data:image/svg+xml;base64,' + Buffer.from(svg, 'utf8').toString('base64')
+                ),
+                images = document.querySelectorAll('#attachmentPreview img');
+            assert.ok(
+                !blobs.some(blob => blob.type === 'image/svg+xml'),
+                'creates no SVG blob'
+            );
+            assert.strictEqual(images.length, 1, 'shows one SVG preview');
+            assert.ok(
+                images[0].getAttribute('src').startsWith('data:image/svg+xml'),
+                'previews the SVG using a data URL'
+            );
+            assert.strictEqual(
+                Buffer.from(readDataUrl(images[0].getAttribute('src'))).toString('utf8'),
+                svg,
+                'previews the sanitized SVG'
+            );
+        });
+
+        it('does not preview SVGs that are not valid UTF-8', async function () {
+            // 0xC4 followed by 0x3C ("<") is not a valid UTF-8 sequence
+            const svg = Buffer.concat([
+                    Buffer.from('<svg xmlns="http://www.w3.org/2000/svg">', 'utf8'),
+                    Buffer.from([0xc4, 0x3c]),
+                    Buffer.from('</svg>', 'utf8')
+                ]),
+                blobs = getAttachmentBlobs(
+                    'data:image/svg+xml;base64,' + svg.toString('base64')
+                );
+            assert.ok(
+                !blobs.some(blob => blob.type === 'image/svg+xml'),
+                'creates no SVG blob for preview'
+            );
+            assert.strictEqual(
+                document.querySelectorAll('#attachmentPreview img').length, 0,
+                'shows no preview'
+            );
+            assert.strictEqual(
+                blobs[blobs.length - 1].type, 'application/octet-stream',
+                'offers download only'
+            );
+            assert.deepStrictEqual(
+                await readBlob(blobs[blobs.length - 1]),
+                new Uint8Array(svg),
+                'keeps the original bytes for download'
+            );
+        });
+
+        it('keeps the bytes of attachments for download intact', async function () {
+            await fc.assert(fc.asyncProperty(
+                fc.constantFrom('image/png', 'application/pdf', 'application/octet-stream', 'image/svg+xml'),
+                fc.uint8Array(),
+                async function (mimeType, data) {
+                    const blobs = getAttachmentBlobs(
+                            'data:' + mimeType + ';base64,' + Buffer.from(data).toString('base64')
+                        ),
+                        // the first blob is used for the download link
+                        bytes = await readBlob(blobs[0]);
+                    PrivateBin.AttachmentViewer.removeAttachment();
+                    return Buffer.from(bytes).equals(Buffer.from(data));
+                }
+            ),
+            {numRuns: 100});
+        });
+    });
+
     function mockCreateObjectUrl(includeType = true) {
         Object.defineProperty(
             window.URL,

+ 89 - 0
js/test/Controller.js

@@ -0,0 +1,89 @@
+'use strict';
+
+const { loadTemplateFragment } = require('./TestHelperFunctions');
+
+require('../common');
+
+describe('Controller', function () {
+    describe('newPaste', function () {
+        after(function () {
+            cleanup();
+        });
+
+        it(
+            'resets the formatter to the configured default',
+            function () {
+                const clean = cleanup('', {url: 'https://example.com/'});
+                document.body.innerHTML = `
+                    <div id="errormessage" class="hidden"></div>
+                    <div id="loadingindicator" class="hidden"></div>
+                    <div id="status" class="hidden"></div>
+                    <div id="remainingtime" class="hidden"></div>
+                    <div id="pastesuccess" class="hidden"></div>
+                    <button id="shortenbutton"></button>
+                    <ul id="editorTabs">
+                        <li id="messagetab"><a></a></li>
+                        <li id="previewtab"><a></a></li>
+                    </ul>
+                    <textarea id="message"></textarea>
+                    <div id="preview" class="hidden"></div>
+                    <div id="placeholder" class="hidden"></div>
+                    <div id="prettymessage" class="hidden"><pre id="prettyprint"></pre></div>
+                    <div id="plaintext" class="hidden"></div>
+                    <div id="attachment" class="hidden"></div>
+                    <div id="attachmentPreview" class="hidden"></div>
+                    <div id="discussion" class="hidden"><div id="commentcontainer"></div></div>
+                    <div id="templates">
+                        <div id="commenttemplate"><div></div></div>
+                        <div id="commenttailtemplate"><button></button></div>
+                        <div id="replytemplate"><div></div></div>
+                    </div>
+                    <div id="attach" class="hidden"></div>
+                    ${loadTemplateFragment('burnafterreadingoption')}
+                    <button id="clonebutton"></button>
+                    <div id="customattachment" class="hidden"></div>
+                    <div id="expiration" class="hidden"></div>
+                    <button id="fileremovebutton"></button>
+                    <div id="filewrap"></div>
+                    <div id="formatter" class="hidden">
+                        <select id="pasteFormatter">
+                            <option value="plaintext">Plain Text</option>
+                            <option value="markdown" selected="selected">Markdown</option>
+                        </select>
+                    </div>
+                    <button id="newbutton" class="hidden"></button>
+                    ${loadTemplateFragment('opendiscussionoption')}
+                    <div id="password" class="hidden"></div>
+                    <input id="passwordinput">
+                    <button id="rawtextbutton"></button>
+                    <button id="downloadtextbutton"></button>
+                    <button id="retrybutton"></button>
+                    <button id="sendbutton" class="hidden"></button>
+                    <button id="qrcodelink"></button>
+                    <a id="emaillink"></a>
+                    <select id="pasteExpiration">
+                        <option value="1week" selected="selected">1 week</option>
+                    </select>
+                    <span id="pasteExpirationDisplay"></span>
+                `;
+
+                PrivateBin.Alert.init();
+                PrivateBin.Model.init();
+                PrivateBin.AttachmentViewer.init();
+                PrivateBin.DiscussionViewer.init();
+                PrivateBin.Editor.init();
+                PrivateBin.PasteStatus.init();
+                PrivateBin.PasteViewer.init();
+                PrivateBin.TopNav.init();
+
+                PrivateBin.PasteViewer.setFormat('syntaxhighlighting');
+                PrivateBin.Controller.newPaste();
+
+                assert.strictEqual(PrivateBin.PasteViewer.getFormat(), 'markdown');
+                assert.strictEqual(document.getElementById('pasteFormatter').value, 'markdown');
+
+                clean();
+            }
+        );
+    });
+});

+ 271 - 0
js/test/CryptTool.js

@@ -44,6 +44,181 @@ describe('CryptTool', function () {
             {numRuns: 3});
         });
 
+        // The below static unit tests are included to ensure deciphering of
+        // existing pastes still works, even when the format or encoding of
+        // messages and passwords changes in the future
+        // Of course you can easily decipher the following texts, if you like.
+        // Bonus points for finding their sources and hidden meanings.
+        it(
+            'supports PrivateBin v2 ciphertext with password',
+            async function () {
+                const clean = globalThis.cleanup();
+                // ensure zlib is getting loaded
+                PrivateBin.Controller.initZlib();
+                Object.defineProperty(window, 'crypto', {
+                    value: new WebCrypto(),
+                    configurable: true,
+                    enumerable: true,
+                    writable: false
+                });
+                global.atob = common.atob;
+                global.btoa = common.btoa;
+
+                const paste = await PrivateBin.CryptTool.decipher(
+                    atob('GXPVE/FVL2HbR0eT7R4D8syaBDYwENIgU9N//0d+j74='),
+                    // -- "That's amazing. I've got the same combination on my luggage."
+                    Array.apply(0, Array(6)).map((_,b) => b + 1).join(''),
+                    [
+                        'W9QvTb1da+IXB4Xmayv73zjK816I1egk4adfhL9DNks8ZSbyMV5SOUCj' +
+                        'EPv+Ccy+1nhWFVa9VT4oHi0qZqAECmKoko6oLVdaWFwL1hWMvUdBJfIK' +
+                        '13LImrI0l3VGQzXsTKxXgRVHMOn1VqYmE0puo671eUL1g1dx5H1lm2uc' +
+                        'jxrOuLWcHMEF+ZqbAx5xwae1aZ8QptBEDL9MiYov7PQpZduxE/jR+mHA' +
+                        'RzVJV1ZyGYIIiRBa1VpJvFN+0HSkdgYSU3TnzXw+sO+xvNaNv/dqVls1' +
+                        'Lk6mMPninJGxN5ZORw9+eU3sNTzBk0+wrX2lONxUIH3sm5mVZJmdlQ5j' +
+                        'sDC+Pe4lAlhbXfeTeNV6WwvQafmaY+6vrPZWzn5ty3/vrJtCcGYOFxN4' +
+                        'Ra6GwxO3/+JqKppYJPtPvnVYriObPlcEe+6nlXXDS9EM8W2xIdw2f54I' +
+                        'VIA7WHXPj46L6Y/8KpCmlQ5YS7fioWEm3zIbGtzfG8Feu8pT3NVD1wPk' +
+                        'blJcb9eCFEIzzWi9G2/e4HdzRyH4fCV7b1L0Bg50/BpCuaC5+v1eZZ8e' +
+                        'Ku286YB42sKovgKzio9P2WDAxtbq/35nRFbGhC7cvQOzRdibZqFGFIaq' +
+                        'Df0Niz5bvzxKW6eZloBBfm5cxfWdCAX7pTxg/z9Wy/fq3coApwkOS4Ya' +
+                        'NoAOvVZo9g==',
+                        [['DLAezyPai/y3Gzo8LmUe5A==', 'G550BaMPE+k=', 100000, 256, 128, 'aes', 'gcm', 'zlib'], 'plaintext', 0, 0]
+                    ]
+                );
+                clean();
+                const result = typeof paste === 'string' && paste.includes('securely packed in iron');
+                if (!result) console.log(paste);
+                assert.ok(result);
+            }
+        );
+
+        it(
+            'supports PrivateBin v2 ciphertext no password',
+            async function () {
+                const clean = globalThis.cleanup();
+                // ensure zlib is getting loaded
+                PrivateBin.Controller.initZlib();
+                Object.defineProperty(window, 'crypto', {
+                    value: new WebCrypto(),
+                    configurable: true,
+                    enumerable: true,
+                    writable: false
+                });
+                global.atob = common.atob;
+                global.btoa = common.btoa;
+
+                const paste = await PrivateBin.CryptTool.decipher(
+                    atob('RHK8Y2QURMzoDQrbJ5NaAGr3KhIxjepX3fXwXyXQWY0='),
+                    '', // no password
+                    [
+                        'IB87fuvRS6rBY0RqLxQjG5/Ng1cU1K/2IXQejhIpUWgFI6UWldM/7Vdk' +
+                        'ZxqlmUHIRWIpCrIToODG+5iVHm0RqeW0P98CRHG82Z+d8AAznp4pOyAt' +
+                        'KEa4ifwdoWiSBEbcOBOgggWVUw1nBZt9MHG8iyve+OOH1KPoIv+xVuJV' +
+                        'nBW/+nxQ2EbDcnSY4yixXeoEB1bPjSXLHrd+ifst9SbcsTuHCNng2K/t' +
+                        'mp3CdocycLttfcfPkgTYvYNC2Gd2oCEZP0+0a2dGfT7nzuGRCFbAx+b9' +
+                        'yFNswZHrvlU8CUdrawRSsNN06KZCHkEPUkunjBrdyALLqtn8w2TKA9nf' +
+                        '2n+27R5De7dcjOJZbN80PDsAVNOBDCdTuQF+vkUE+254SqQXp2zTAVX5' +
+                        '1MjpGgTI/xdSfVdg5vOoqymySPmCV4ZO1sFXPs6AXRg2jhzxhihjxlv8' +
+                        '55WY7mSsiU1G3/TBY0Ra+1QzcQ5bSy/ycWEtT72IicM8Hj2w3aF2QwFV' +
+                        'sz5tAyFTZD3swxR5bbxQZJ+LEQ==',
+                        ['RSSzQHsYsczisktZn6eesg==', 'Dow4nIXOFgI=', 100000, 256, 128, 'aes', 'gcm', 'zlib']
+                    ]
+                );
+                clean();
+                const result = typeof paste === 'string' && paste.includes('Stable, opaque, immortal \u2013 all by dint');
+                if (!result) console.log(paste);
+                assert.ok(result);
+            }
+        );
+
+        it(
+            'supports PrivateBin v2 ciphertext with non-ASCII password and no compression',
+            async function () {
+                const clean = globalThis.cleanup();
+                // ensure zlib is getting loaded
+                PrivateBin.Controller.initZlib();
+                Object.defineProperty(window, 'crypto', {
+                    value: new WebCrypto(),
+                    configurable: true,
+                    enumerable: true,
+                    writable: false
+                });
+                global.atob = common.atob;
+                global.btoa = common.btoa;
+
+                const paste = await PrivateBin.CryptTool.decipher(
+                    atob('BgdCS8FLXvEFnQPwkAE93b0urmkaTShdw1Tr5c350Uo='),
+                    // key derivation of passwords containing characters above U+007F
+                    'Schl\u00fcssel \u94a5\u5319',
+                    [
+                        'T0qlQhwqt1KtFY6r8iqg5gmP3O8yudwx0YPQk7xU4hyIq2SlsXmGGonq' +
+                        '6T4sDIJM6i7caSEZYsE/0KeCq0rCw3CZNaUoL9E54sx4ei17m5X9ikUe' +
+                        'feRX3n8M89jbE6vd1qJ13QvbG0mGYhxllfWDnETol0ws2nsfcqVMQJrX' +
+                        'nwlm+3PHH9/sqGChLrbTYMHOcUS1ec4/l5qIFfUBXqbO+U5V5nneZRz8' +
+                        '+JuPDk5oSiHTBHV2Jyd0Q3fX37O6bpqEz0Qxl54rLEkfUSRfM7RXCJE2' +
+                        'wcAaxL9vqGcit/QdBnrJrPyTL8Vdoz289dmx+3EJZEtoW1bSEYQ1mov2' +
+                        'c4bv8pEutAEnHf9nbBYZJhWdKlVZkqX5cjM11cPLCuebpQbmw9otVCWX' +
+                        'GAqU8safz+Nu4CkUXoAerX8lrKffJEOLOoGvlJjU2qZexfOjrbxP7XJ3' +
+                        'lbpddDA4OFVDTG9u99m14T4opmYK/iPwbq6PcI1H9fxzLRwyZjNOoaip' +
+                        'b7z6vvOyyl1gwnXN98ZRBys6SChO6tqWATenQgs20UUv6oi+vyBoYhLP' +
+                        'DWFfU4ndf4p2d0BRQldYoXEMl+FgkNMcnS2UR6PhBax/BGDgZlMUljil' +
+                        'rOxcCM5vd5jSZIWs/IV6pMxOAozymCfjMDfgjE2J6ub00V+92uwu6vsV' +
+                        'VlbN1ARFCEp6VX7tN1RNOBTYqlXU6vGbSgXLh7Qng50Pin+/6GKpuHzb' +
+                        'spNmEekIqgzlsNc+/V0BrPe6xvhveVyEANNBgqT5NoRju9ioval08lW0' +
+                        'OfrFZJ5LEk4jZ9TfMEMej1K5efZMUVP94q+D6fRMFwM=',
+                        [['RekNgsDiTSbYsXae8EnjTQ==', 'wsZdI484+Cs=', 100000, 256, 128, 'aes', 'gcm', 'none'], 'plaintext', 0, 0]
+                    ]
+                );
+                clean();
+                const result = typeof paste === 'string' && paste.includes('Stable, opaque, immortal \u2013 all by dint');
+                if (!result) console.log(paste);
+                assert.ok(result);
+            }
+        );
+
+        it(
+            'supports PrivateBin v2 ciphertext with UTF-8 encoded non-ASCII password',
+            async function () {
+                const clean = globalThis.cleanup();
+                // ensure zlib is getting loaded
+                PrivateBin.Controller.initZlib();
+                Object.defineProperty(window, 'crypto', {
+                    value: new WebCrypto(),
+                    configurable: true,
+                    enumerable: true,
+                    writable: false
+                });
+                global.atob = common.atob;
+                global.btoa = common.btoa;
+
+                const paste = await PrivateBin.CryptTool.decipher(
+                    atob('58THB9UKSts178TIRMfG1l+SikJC92wqO+zu6RSbzOk='),
+                    // key derivation of passwords using their UTF-8 encoding
+                    'Schl\u00fcssel \u94a5\u5319',
+                    [
+                        '2LitLhswv6zFfUg4AzEjxd6OHWq8PJRh0ImRpzMUyVz67kuZbTYXSzZt' +
+                        '3DLHg/cvT6GpixTZdtHt7z2JK9TXYkoYDcUTuH/xAkgy/k/6w0COOwMp' +
+                        'mUhtYRRTyrhnPEa82QwpVzxZ7zIHRDNLT2J49yLQ44hp564L6zPWQz6R' +
+                        'HGuAf1g3Is/dVRNhRaRPyuOrj/PXeA2/KVkpBbWTY2nPCct6pueLiEA/' +
+                        'pbtI/oOBOo/DQRgLh9MSpZ4a68H6DtWj9/Y6RINm6zn8cit5bOlUYeC5' +
+                        'tpZD2eqpiRZIcwekXcw64Y/dNNQZZ/MD+0ZmWFicBmWlLbuS3WlxeHAH' +
+                        'VLGIoZTydTKopZ6ubmQUbI1Fiz3GUDF3q5e9c7vGUO4TVFIKsCyza/8o' +
+                        '2SBfKt66DxTyWTetwcW5ql9L+wfxVf6WFLVkbax/HKvHgozPh6YiGHFJ' +
+                        'DXeNYenlr0R6zQGjHEi+72+gEgDpHtaZSPYVob+ioNO3EsV9WuxqysK+' +
+                        'YEmuw+0cFeF2l9ki69AjbtzNjrFgAsX1QR9an+BtUXFeMekwgkx0NkUd' +
+                        'Sla2eOcoIQSR8HFkOF4tCQPxp4Pgd+UFAMYDkv55sCzPxHm3JXbVCzhw' +
+                        '7N5zmeEI3wJiqHzLzcRK/JqnaPUinqwcv+skickiAAGUKRZwUmHirCyc' +
+                        'WbIqDxlddQ==',
+                        ['R9xhVxI3CtY6hp45e1ad0w==', 'LqC0jpwAdps=', 100000, 256, 128, 'aes', 'gcm', 'zlib']
+                    ]
+                );
+                clean();
+                const result = typeof paste === 'string' && paste.includes('securely packed in iron');
+                if (!result) console.log(paste);
+                assert.ok(result);
+            }
+        );
+
         it('does not truncate messages', async function () {
             const message = fs.readFileSync('test/compression-sample.txt', 'ascii').trim(),
                 clean = globalThis.cleanup();
@@ -70,6 +245,35 @@ describe('CryptTool', function () {
             assert.strictEqual(message, plaintext);
         });
 
+        it('authenticates the document and parent IDs of comments', async function () {
+            const clean = globalThis.cleanup();
+            Object.defineProperty(window, 'crypto', {
+                value: new WebCrypto(),
+                configurable: true,
+                enumerable: true,
+                writable: false
+            });
+            // ensure zlib is getting loaded
+            PrivateBin.Controller.initZlib();
+            global.atob = common.atob;
+            global.btoa = common.btoa;
+            const cipherMessage = await PrivateBin.CryptTool.cipher(
+                    'foo', 'bar', 'baz', ['5b65a01b43987bc2', '33a5671d11b29d01']
+                ),
+                plaintext = await PrivateBin.CryptTool.decipher(
+                    'foo', 'bar', cipherMessage
+                );
+            cipherMessage[1][9] = '1111111111111111';
+            const tampered = await PrivateBin.CryptTool.decipher(
+                'foo', 'bar', cipherMessage
+            );
+            clean();
+            assert.strictEqual(cipherMessage[1].length, 10);
+            assert.strictEqual(cipherMessage[1][8], '5b65a01b43987bc2');
+            assert.strictEqual(plaintext, 'baz');
+            assert.strictEqual(tampered, '');
+        });
+
         it('can en- and decrypt a particular message (#260)', async function () {
             await fc.assert(fc.asyncProperty(
                 fc.string(),
@@ -122,6 +326,73 @@ conseq_or_bottom inv (interp (nth_iterate sBody n) (MemElem mem))
             ),
             {numRuns: 3});
         });
+
+        it('can en- and decrypt any unicode message and password', async function () {
+            await fc.assert(fc.asyncProperty(
+                fc.string({unit: 'binary', minLength: 1}),
+                fc.string({unit: 'binary'}),
+                async function (message, password) {
+                    const clean = globalThis.cleanup();
+                    // ensure zlib is getting loaded
+                    PrivateBin.Controller.initZlib();
+                    Object.defineProperty(window, 'crypto', {
+                        value: new WebCrypto(),
+                        configurable: true,
+                        enumerable: true,
+                        writable: false
+                    });
+                    global.atob = common.atob;
+                    global.btoa = common.btoa;
+                    const key = PrivateBin.CryptTool.getSymmetricKey(),
+                        cipherMessage = await PrivateBin.CryptTool.cipher(
+                            key, password, message, []
+                        ),
+                        plaintext = await PrivateBin.CryptTool.decipher(
+                            key, password, cipherMessage
+                        );
+                    clean();
+                    const result = (message === plaintext);
+                    if (!result) console.log(plaintext, cipherMessage);
+                    return result;
+                }
+            ),
+            {numRuns: 3});
+        });
+
+        it('does not accept passwords that only share the low byte of each character', async function () {
+            const passwordPairs = [
+                ['\u013c', '<'],             // U+013C vs <
+                ['\u4e2d\u6587', '-\u0087'], // U+4E2D U+6587 vs 0x2D 0x87
+                ['\u20ac', '\u00ac']         // U+20AC vs 0xAC
+            ];
+            for (const [password, lowBytePassword] of passwordPairs) {
+                const clean = globalThis.cleanup();
+                // ensure zlib is getting loaded
+                PrivateBin.Controller.initZlib();
+                Object.defineProperty(window, 'crypto', {
+                    value: new WebCrypto(),
+                    configurable: true,
+                    enumerable: true,
+                    writable: false
+                });
+                global.atob = common.atob;
+                global.btoa = common.btoa;
+                const key = PrivateBin.CryptTool.getSymmetricKey(),
+                    cipherMessage = await PrivateBin.CryptTool.cipher(
+                        key, password, 'secret', []
+                    ),
+                    plaintext = await PrivateBin.CryptTool.decipher(
+                        key, lowBytePassword, cipherMessage
+                    );
+                clean();
+                assert.strictEqual(
+                    plaintext, '',
+                    'password ' + JSON.stringify(lowBytePassword) +
+                    ' must not decrypt a document encrypted with ' +
+                    JSON.stringify(password)
+                );
+            }
+        });
     });
 
     describe('getSymmetricKey', function () {

+ 13 - 13
js/test/Helper.js

@@ -200,9 +200,9 @@ describe('Helper', function () {
                 fc.array(fc.string(), {minLength: 1}),
                 fc.string(),
                 function (prefix, params, postfix) {
-                    prefix    =    prefix.replace(/%(s|d)/g, '%%');
-                    params[0] = params[0].replace(/%(s|d)/g, '%%');
-                    postfix   =   postfix.replace(/%(s|d)/g, '%%');
+                    prefix    =    common.removePlaceholders(prefix);
+                    params[0] = common.removePlaceholders(params[0]);
+                    postfix   =   common.removePlaceholders(postfix);
                     var result = prefix + params[0] + postfix;
                     params.unshift(prefix + '%s' + postfix);
                     return result === PrivateBin.Helper.sprintf.apply(this, params);
@@ -215,8 +215,8 @@ describe('Helper', function () {
                 fc.array(fc.nat(), {minLength: 1}),
                 fc.string(),
                 function (prefix, params, postfix) {
-                    prefix  =  prefix.replace(/%(s|d)/g, '%%');
-                    postfix = postfix.replace(/%(s|d)/g, '%%');
+                    prefix  =  common.removePlaceholders(prefix);
+                    postfix = common.removePlaceholders(postfix);
                     var result = prefix + params[0] + postfix;
                     params.unshift(prefix + '%d' + postfix);
                     return result === PrivateBin.Helper.sprintf.apply(this, params);
@@ -229,8 +229,8 @@ describe('Helper', function () {
                 fc.array(fc.falsy(), {minLength: 1}),
                 fc.string(),
                 function (prefix, params, postfix) {
-                    prefix  =  prefix.replace(/%(s|d)/g, '%%');
-                    postfix = postfix.replace(/%(s|d)/g, '%%');
+                    prefix  =  common.removePlaceholders(prefix);
+                    postfix = common.removePlaceholders(postfix);
                     var result = prefix + '0' + postfix;
                     params.unshift(prefix + '%d' + postfix);
                     return result === PrivateBin.Helper.sprintf.apply(this, params);
@@ -245,9 +245,9 @@ describe('Helper', function () {
                 fc.string(),
                 fc.string(),
                 function (prefix, uint, middle, string, postfix) {
-                    prefix  =  prefix.replace(/%(s|d)/g, '');
-                    middle  =  middle.replace(/%(s|d)/g, '');
-                    postfix = postfix.replace(/%(s|d)/g, '');
+                    prefix  =  common.removePlaceholders(prefix);
+                    middle  =  common.removePlaceholders(middle);
+                    postfix = common.removePlaceholders(postfix);
                     var params = [prefix + '%d' + middle + '%s' + postfix, uint, string],
                         result = prefix + uint + middle + string + postfix;
                     return result === PrivateBin.Helper.sprintf.apply(this, params);
@@ -262,9 +262,9 @@ describe('Helper', function () {
                 fc.string(),
                 fc.string(),
                 function (prefix, uint, middle, string, postfix) {
-                    prefix  =  prefix.replace(/%(s|d)/g, '');
-                    middle  =  middle.replace(/%(s|d)/g, '');
-                    postfix = postfix.replace(/%(s|d)/g, '');
+                    prefix  =  common.removePlaceholders(prefix);
+                    middle  =  common.removePlaceholders(middle);
+                    postfix = common.removePlaceholders(postfix);
                     var params = [prefix + '%s' + middle + '%d' + postfix, string, uint],
                         result = prefix + string + middle + uint + postfix;
                     return result === PrivateBin.Helper.sprintf.apply(this, params);

+ 13 - 13
js/test/I18n.js

@@ -13,7 +13,7 @@ describe('I18n', function () {
             fc.assert(fc.property(
                 fc.string(),
                 function (messageId) {
-                    messageId   = messageId.replace(/%(s|d)/g, '%%');
+                    messageId   = common.removePlaceholders(messageId);
                     const plurals = [messageId, messageId + 's'],
                           fake    = [messageId],
                           result  = PrivateBin.I18n.translate(messageId);
@@ -56,9 +56,9 @@ describe('I18n', function () {
                 fc.array(fc.string(), {minLength: 1}),
                 fc.string(),
                 function (prefix, params, postfix) {
-                    prefix    =    prefix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
-                    params[0] = params[0].replace(/%(s|d)/g, '%%');
-                    postfix   =   postfix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
+                    prefix    =    common.removePlaceholders(prefix).replace(/<a/g, '');
+                    params[0] = common.removePlaceholders(params[0]);
+                    postfix   =   common.removePlaceholders(postfix).replace(/<a/g, '');
                     const translation = PrivateBin.Helper.htmlEntities(prefix + params[0] + postfix);
                     params.unshift(prefix + '%s' + postfix);
                     const result = PrivateBin.I18n.translate.apply(this, params);
@@ -75,9 +75,9 @@ describe('I18n', function () {
                 fc.array(fc.string(), {minLength: 1}),
                 fc.string(),
                 function (prefix, params, postfix) {
-                    prefix    =    prefix.replace(/%(s|d)/g, '%%');
-                    params[0] = params[0].replace(/%(s|d)/g, '%%');
-                    postfix   =   postfix.replace(/%(s|d)/g, '%%');
+                    prefix    =    common.removePlaceholders(prefix);
+                    params[0] = common.removePlaceholders(params[0]);
+                    postfix   =   common.removePlaceholders(postfix);
                     const translation = DOMPurify.sanitize(
                         prefix + '<a href="' + params[0] + '"></a>' + postfix, {
                             ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|magnet):)/i,
@@ -100,9 +100,9 @@ describe('I18n', function () {
                 fc.array(fc.string(), {minLength: 1}),
                 fc.string(),
                 function (prefix, params, postfix) {
-                    prefix    =    prefix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
-                    params[0] = params[0].replace(/%(s|d)/g, '%%');
-                    postfix   =   postfix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
+                    prefix    =    common.removePlaceholders(prefix).replace(/<a/g, '');
+                    params[0] = common.removePlaceholders(params[0]);
+                    postfix   =   common.removePlaceholders(postfix).replace(/<a/g, '');
                     const tempDiv = document.createElement('textarea');
                     tempDiv.textContent = (prefix + params[0] + postfix);
                     const translation = tempDiv.textContent;
@@ -133,9 +133,9 @@ describe('I18n', function () {
                 fc.array(fc.string(), {minLength: 1}),
                 fc.string(),
                 function (prefix, params, postfix) {
-                    prefix    =    prefix.replace(/%(s|d)/g, '%%').trim();
-                    params[0] = params[0].replace(/%(s|d)/g, '%%').trim();
-                    postfix   =   postfix.replace(/%(s|d)/g, '%%').trim();
+                    prefix    =    common.removePlaceholders(prefix).trim();
+                    params[0] = common.removePlaceholders(params[0]).trim();
+                    postfix   =   common.removePlaceholders(postfix).trim();
                     const translation = DOMPurify.sanitize(
                         prefix + '<a href="' + params[0] + '"></a>' + postfix, {
                             ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|magnet):)/i,

+ 138 - 0
js/test/PasswordPeek.js

@@ -0,0 +1,138 @@
+'use strict';
+require('../common');
+const fc = require('fast-check');
+
+describe('PasswordPeek', function () {
+    const iconHref = 'img/bootstrap-icons.svg#eye';
+
+    /**
+     * input group with a password field and its reveal button, as in the
+     * bootstrap5 template
+     *
+     * @param {string} id
+     * @returns {string}
+     */
+    function passwordGroup(id) {
+        return (
+            '<div class="input-group">' +
+            '<input id="' + id + '" type="password" class="form-control input-password" />' +
+            '<button class="btn btn-outline-secondary toggle-password" type="button" ' +
+            'title="Show password" aria-label="Show password">' +
+            '<svg width="16" height="16" fill="currentColor" aria-hidden="true">' +
+            '<use href="' + iconHref + '" /></svg>' +
+            '</button></div>'
+        );
+    }
+
+    /**
+     * asserts that the input and its reveal button are in the expected state
+     *
+     * @param {string} id
+     * @param {boolean} isRevealed
+     */
+    function assertState(id, isRevealed) {
+        const input = document.getElementById(id);
+        const button = input.closest('.input-group').querySelector('.toggle-password');
+        const label = isRevealed ? 'Hide password' : 'Show password';
+        assert.strictEqual(input.getAttribute('type'), isRevealed ? 'text' : 'password', id + ' type');
+        assert.strictEqual(button.getAttribute('title'), label, id + ' title');
+        assert.strictEqual(button.getAttribute('aria-label'), label, id + ' aria-label');
+        assert.strictEqual(
+            button.querySelector('use').getAttribute('href'),
+            isRevealed ? iconHref + '-slash' : iconHref,
+            id + ' icon'
+        );
+    }
+
+    /**
+     * clicks the reveal button belonging to the given input
+     *
+     * @param {string} id
+     */
+    function clickButton(id) {
+        document.getElementById(id).closest('.input-group')
+            .querySelector('.toggle-password').click();
+    }
+
+    beforeEach(function () {
+        globalThis.cleanup();
+        // same order as in the template: the decryption prompt comes before
+        // the password input of the create form
+        document.body.innerHTML = (
+            '<div id="passwordmodal"><form id="passwordform">' +
+            passwordGroup('passworddecrypt') +
+            '</form></div>' +
+            '<nav><div id="password">' +
+            passwordGroup('passwordinput') +
+            '</div></nav>'
+        );
+        PrivateBin.PasswordPeek.init();
+    });
+
+    after(function () {
+        globalThis.cleanup();
+    });
+
+    it('toggles the password prompt when opening a document', function () {
+        clickButton('passworddecrypt');
+        assertState('passworddecrypt', true);
+        assertState('passwordinput', false);
+        clickButton('passworddecrypt');
+        assertState('passworddecrypt', false);
+        assertState('passwordinput', false);
+    });
+
+    it('toggles the password input when creating a document', function () {
+        clickButton('passwordinput');
+        assertState('passwordinput', true);
+        assertState('passworddecrypt', false);
+        clickButton('passwordinput');
+        assertState('passwordinput', false);
+        assertState('passworddecrypt', false);
+    });
+
+    it('toggles each input independently, in any click order', function () {
+        fc.assert(fc.property(
+            fc.integer({min: 1, max: 5}).chain(function (count) {
+                return fc.tuple(
+                    fc.constant(count),
+                    fc.array(fc.nat({max: count - 1}), {maxLength: 20})
+                );
+            }),
+            function ([count, clicks]) {
+                globalThis.cleanup();
+                const ids = [];
+                for (let i = 0; i < count; ++i) {
+                    ids.push('password' + i);
+                }
+                document.body.innerHTML = ids.map(passwordGroup).join('');
+                PrivateBin.PasswordPeek.init();
+
+                const revealed = ids.map(() => false);
+                for (const index of clicks) {
+                    clickButton(ids[index]);
+                    revealed[index] = !revealed[index];
+                }
+                ids.forEach(function (id, index) {
+                    assertState(id, revealed[index]);
+                });
+            }
+        ), {numRuns: 50});
+    });
+
+    it('ignores a reveal button without a password input', function () {
+        globalThis.cleanup();
+        document.body.innerHTML = (
+            '<div class="input-group">' +
+            '<button class="toggle-password" type="button" title="Show password">' +
+            '<svg><use href="' + iconHref + '" /></svg></button></div>' +
+            '<button class="toggle-password" id="orphan" type="button" title="Show password"></button>'
+        );
+        PrivateBin.PasswordPeek.init();
+        document.querySelectorAll('.toggle-password').forEach(button => button.click());
+        document.querySelectorAll('.toggle-password').forEach(function (button) {
+            assert.strictEqual(button.getAttribute('title'), 'Show password');
+        });
+        assert.strictEqual(document.querySelector('use').getAttribute('href'), iconHref);
+    });
+});

+ 19 - 0
js/test/TestHelperFunctions.js

@@ -0,0 +1,19 @@
+'use strict';
+const fs = require('node:fs');
+const path = require('node:path');
+
+function loadTemplateFragment(id) {
+    const template = fs.readFileSync(path.resolve(__dirname, '../../tpl/bootstrap5.php'), 'utf8');
+    const match = template.match(new RegExp(`<div id="${id}"[^>]*>[\\s\\S]*?<\\/div>`));
+
+    if (!match) {
+        throw new Error(`Template fragment #${id} not found`);
+    }
+
+    return match[0]
+        .replace(/<\?php[\s\S]*?\?>/g, '')
+        .replace(/<\?=[\s\S]*?\?>/g, '')
+        .replace(/>\s+</g, '><')
+        .trim();
+}
+exports.loadTemplateFragment = loadTemplateFragment;

+ 280 - 9
js/test/TopNav.js

@@ -9,30 +9,301 @@ function query(selector) {
     return document.querySelector(selector);
 }
 
+/**
+ * builds the navigation with the e-mail button and its confirmation dialog, as
+ * in the bootstrap5 template
+ *
+ * @param {boolean} withShortUrl - add the link of a shortened document URL
+ */
+function buildEmailDom(withShortUrl) {
+    document.documentElement.innerHTML =
+        '<nav><div id="navbar"><ul><li>' +
+        '<button id="clonebutton" type="button" class="btn btn-secondary">' +
+        '<svg width="16" height="16" fill="currentColor" aria-hidden="true">' +
+        '<use href="img/bootstrap-icons.svg#copy"></use></svg> Clone</button>' +
+        '</li><li>' +
+        '<button id="emaillink" type="button" class="hidden btn btn-secondary">Email</button>' +
+        '</li></ul></div></nav>' +
+        '<input id="burnafterreadingoption" type="checkbox">' +
+        (withShortUrl ?
+            '<div id="pastelink">Your document is ' +
+            '<a id="pasteurl" href="https://short.example/xYz">https://short.example/xYz</a> ' +
+            '<span id="copyhint">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span></div>' :
+            '') +
+        '<div id="emailconfirmmodal" tabindex="-1" class="modal" role="dialog" aria-hidden="true">' +
+        '<div class="modal-dialog" role="document"><div class="modal-content"><div class="modal-body row">' +
+        '<button id="emailconfirm-timezone-current" type="button">Use Current Timezone</button>' +
+        '<button id="emailconfirm-timezone-utc" type="button">Convert To UTC</button>' +
+        '</div></div></div></div>';
+}
+
+/**
+ * replaces window.open, to record the e-mails sent via mailto: URLs
+ *
+ * @returns {{getUrl: function, getCount: function, reset: function, restore: function}}
+ */
+function mockWindowOpen() {
+    const originalOpen = window.open;
+    let openedUrl = null,
+        openedCount = 0;
+    window.open = function (url) {
+        openedUrl = url;
+        ++openedCount;
+        return {};
+    };
+    return {
+        getUrl: () => openedUrl,
+        getCount: () => openedCount,
+        reset: () => {
+            openedUrl = null;
+            openedCount = 0;
+        },
+        restore: () => {
+            window.open = originalOpen;
+        }
+    };
+}
+
+/**
+ * extracts and decodes a parameter of a mailto: URL
+ *
+ * @param {string} mailtoUrl
+ * @param {string} name - i.e. subject or body
+ * @returns {string}
+ */
+function mailtoParameter(mailtoUrl, name) {
+    assert.match(mailtoUrl, /^mailto:\?/, 'expected a mailto: URL');
+    const match = mailtoUrl.match(new RegExp('[?&]' + name + '=([^&]*)'));
+    assert.ok(match, 'expected the mailto: URL to have a ' + name + '= parameter');
+    return decodeURIComponent(match[1]);
+}
+
 describe('TopNav', function () {
-    describe('showEmailButton', function () {
+    describe('showEmailButton & hideEmailButton', function () {
+        let windowOpen;
+
+        beforeEach(function () {
+            cleanup();
+            windowOpen = mockWindowOpen();
+        });
+
         afterEach(function () {
+            windowOpen.restore();
             cleanup();
         });
 
-        it('does nothing when the email button is omitted from the page', function () {
+        it('does nothing when the e-mail button is omitted from the page', function () {
             document.documentElement.innerHTML =
                 '<nav><div id="navbar"><ul><li><button id="newbutton" ' +
                 'type="button">New</button></li></ul></div></nav>';
             PrivateBin.TopNav.init();
 
-            const originalShowError = PrivateBin.Alert.showError;
+            // cleanup() reloads PrivateBin, dropping this replacement
             let errorShown = false;
             PrivateBin.Alert.showError = function () {
                 errorShown = true;
             };
 
-            try {
-                assert.doesNotThrow(() => PrivateBin.TopNav.showEmailButton());
-                assert.strictEqual(errorShown, false);
-            } finally {
-                PrivateBin.Alert.showError = originalShowError;
-            }
+            assert.doesNotThrow(() => PrivateBin.TopNav.showEmailButton());
+            assert.strictEqual(errorShown, false);
+        });
+
+        it('sends a single e-mail after the confirmation dialog was dismissed', function () {
+            buildEmailDom(false);
+            PrivateBin.TopNav.init();
+            PrivateBin.TopNav.showEmailButton(3600);
+
+            // open the dialog and dismiss it without choosing a timezone
+            query('#emaillink').click();
+            assert.strictEqual(windowOpen.getCount(), 0, 'no e-mail before choosing a timezone');
+
+            query('#emaillink').click();
+            query('#emailconfirm-timezone-current').click();
+            assert.strictEqual(windowOpen.getCount(), 1, 'exactly one e-mail with the current timezone');
+
+            query('#emaillink').click();
+            query('#emailconfirm-timezone-utc').click();
+            assert.strictEqual(windowOpen.getCount(), 2, 'exactly one more e-mail with UTC');
+        });
+
+        it('sends a single e-mail per click after the button was shown again', function () {
+            buildEmailDom(false);
+            PrivateBin.TopNav.init();
+            // i.e. sending a second document without reloading the page
+            PrivateBin.TopNav.showEmailButton(0);
+            PrivateBin.TopNav.showEmailButton(0);
+
+            query('#emaillink').click();
+            assert.strictEqual(windowOpen.getCount(), 1, 'exactly one e-mail per click');
+        });
+
+        it('uses the expiration of the document the button was last shown for', function () {
+            buildEmailDom(false);
+            PrivateBin.TopNav.init();
+            // first document expires, the second never does
+            PrivateBin.TopNav.showEmailButton(3600);
+            PrivateBin.TopNav.showEmailButton(0);
+
+            query('#emaillink').click();
+            assert.strictEqual(windowOpen.getCount(), 1, 'sent directly, without confirmation dialog');
+            assert.doesNotMatch(mailtoParameter(windowOpen.getUrl(), 'body'), /expire/i, 'body has no expiration');
+
+            query('#emailconfirm-timezone-current').click();
+            assert.strictEqual(windowOpen.getCount(), 1, 'no e-mail from a stale confirmation dialog');
+        });
+
+        it('sends no e-mail from a confirmation dialog opened for an earlier document', function () {
+            buildEmailDom(false);
+            PrivateBin.TopNav.init();
+            PrivateBin.TopNav.showEmailButton(3600);
+
+            // open the dialog, but start a new document instead of choosing
+            query('#emaillink').click();
+            PrivateBin.TopNav.hideEmailButton();
+            PrivateBin.TopNav.showEmailButton(0);
+
+            query('#emailconfirm-timezone-current').click();
+            query('#emailconfirm-timezone-utc').click();
+            assert.strictEqual(windowOpen.getCount(), 0, 'no e-mail from a stale confirmation dialog');
+        });
+
+        it('sends no e-mail after the button was hidden', function () {
+            buildEmailDom(false);
+            PrivateBin.TopNav.init();
+            PrivateBin.TopNav.showEmailButton(0);
+            PrivateBin.TopNav.hideEmailButton();
+
+            // i.e. a keyboard shortcut or assistive technology activating it
+            query('#emaillink').click();
+            assert.strictEqual(windowOpen.getCount(), 0, 'no e-mail from a hidden button');
+        });
+
+        it('sends exactly one e-mail, regardless of how often the button was shown or the dialog dismissed', function () {
+            fc.assert(fc.property(
+                fc.integer({min: 1, max: 5}),
+                fc.integer({min: 0, max: 5}),
+                fc.constantFrom('current', 'utc'),
+                function (shown, dismissed, timezone) {
+                    // replacing the markup is enough, init binds to the new elements
+                    buildEmailDom(false);
+                    PrivateBin.TopNav.init();
+                    for (let i = 0; i < shown; ++i) {
+                        PrivateBin.TopNav.showEmailButton(3600);
+                    }
+                    windowOpen.reset();
+
+                    for (let i = 0; i < dismissed; ++i) {
+                        query('#emaillink').click();
+                    }
+                    query('#emaillink').click();
+                    query('#emailconfirm-timezone-' + timezone).click();
+                    assert.strictEqual(windowOpen.getCount(), 1);
+                }
+            ));
+        });
+    });
+
+    describe('sendEmail', function () {
+        let windowOpen;
+
+        beforeEach(function () {
+            cleanup();
+            windowOpen = mockWindowOpen();
+        });
+
+        afterEach(function () {
+            windowOpen.restore();
+            cleanup();
+        });
+
+        it('puts the short URL into the mail body, if there is one', function () {
+            buildEmailDom(true);
+            PrivateBin.TopNav.init();
+            PrivateBin.TopNav.showEmailButton(0);
+            assert.ok(!query('#emaillink').classList.contains('hidden'), 'e-mail button is shown');
+
+            query('#emaillink').click();
+            assert.ok(windowOpen.getUrl(), 'an e-mail was sent');
+            const body = mailtoParameter(windowOpen.getUrl(), 'body');
+            assert.match(body, /https:\/\/short\.example\/xYz/, 'body contains the short URL');
+            assert.doesNotMatch(body, /undefined/, 'body never contains "undefined"');
+        });
+
+        it('puts the page URL into the mail body, if there is no short URL', function () {
+            buildEmailDom(false);
+            PrivateBin.TopNav.init();
+            PrivateBin.TopNav.showEmailButton(0);
+            assert.ok(!query('#emaillink').classList.contains('hidden'), 'e-mail button is shown');
+
+            query('#emaillink').click();
+            assert.ok(windowOpen.getUrl(), 'an e-mail was sent');
+            const body = mailtoParameter(windowOpen.getUrl(), 'body');
+            assert.match(body, new RegExp(window.location.href), 'body contains the page URL');
+            assert.doesNotMatch(body, /undefined/, 'body never contains "undefined"');
+        });
+
+        it('uses the burn-after-reading state of the viewed document, not the instance default', function () {
+            buildEmailDom(false);
+            const burnAfterReading = document.createElement('input');
+            burnAfterReading.id = 'burnafterreading';
+            burnAfterReading.type = 'checkbox';
+            burnAfterReading.checked = true;
+            document.body.appendChild(burnAfterReading);
+            PrivateBin.TopNav.init();
+            PrivateBin.TopNav.showEmailButton(0, false);
+
+            query('#emaillink').click();
+            assert.doesNotMatch(
+                mailtoParameter(windowOpen.getUrl(), 'body'),
+                /only be accessed once/,
+                'body uses the metadata of the viewed document'
+            );
+        });
+
+        it('names the instance in the mail subject', function () {
+            buildEmailDom(false);
+            // buildEmailDom() replaces the whole document, including its title
+            document.title = 'My PrivateBin Instance';
+            PrivateBin.TopNav.init();
+            PrivateBin.TopNav.showEmailButton(0);
+
+            query('#emaillink').click();
+            assert.ok(windowOpen.getUrl(), 'an e-mail was sent');
+            assert.match(mailtoParameter(windowOpen.getUrl(), 'subject'), /My PrivateBin Instance/);
+        });
+
+        it('names the instance in the mail subject after the expiration was confirmed', function () {
+            buildEmailDom(true);
+            document.title = 'My PrivateBin Instance';
+            PrivateBin.TopNav.init();
+            // a remaining time asks to confirm the timezone of the expiration
+            PrivateBin.TopNav.showEmailButton(3600);
+
+            query('#emaillink').click();
+            query('#emailconfirm-timezone-current').click();
+            assert.ok(windowOpen.getUrl(), 'an e-mail was sent');
+            assert.match(mailtoParameter(windowOpen.getUrl(), 'subject'), /My PrivateBin Instance/);
+        });
+
+        it('percent-encodes the mail subject, i.e. spaces in the instance name', function () {
+            buildEmailDom(false);
+            document.title = 'My Cool Instance';
+            PrivateBin.TopNav.init();
+            PrivateBin.TopNav.showEmailButton(0);
+
+            query('#emaillink').click();
+            const rawSubjectParam = windowOpen.getUrl().split('&body=')[0];
+            assert.strictEqual(
+                rawSubjectParam,
+                `mailto:?subject=${encodeURIComponent('Encrypted note on My Cool Instance')}`,
+                'raw subject parameter is exactly the encoded translated string'
+            );
+            assert.doesNotMatch(rawSubjectParam, / /, 'raw mailto: URL contains no literal space');
+            assert.strictEqual(
+                mailtoParameter(windowOpen.getUrl(), 'subject'),
+                'Encrypted note on My Cool Instance',
+                'decoded subject round-trips exactly'
+            );
         });
     });
 

+ 0 - 254
js/test/emailTemplateTest.js

@@ -1,254 +0,0 @@
-'use strict';
-require('../common');
-
-// DOM builder that mirrors bootstrap5.php navbar
-function buildEmailDomNoShortUrl() {
-    document.documentElement.innerHTML =
-        // TopNav expects initially hidden #emaillink BUTTON.
-        '<nav><div id="navbar"><ul>' +
-          '<li>' +
-            '<button id="clonebutton" type="button" class="btn btn-warning navbar-btn">' +
-              '<span class="glyphicon glyphicon-duplicate" aria-hidden="true"></span> Clone' +
-            '</button>' +
-          '</li>' +
-          '<li>' +
-            '<button id="emaillink" type="button" class="hidden btn btn-secondary">Email</button>' +
-          '</li>' +
-        '</ul></div></nav>' +
-        '<input id="burnafterreadingoption" type="checkbox">' +
-        // include dummy email confirm modal for sendEmail
-        '<div id="emailconfirmmodal" class="hidden">' +
-            '<div id="emailconfirm-timezone-current"></div>' +
-            '<div id="emailconfirm-timezone-utc"></div>' +
-        '</div>'
-}
-
-// DOM builder that adds the shortener result block
-function buildEmailDomWithShortUrl() {
-    buildEmailDomNoShortUrl();
-    document.documentElement.innerHTML =
-        // TopNav expectsinitially hidden #emaillink BUTTON.
-        '<nav><div id="navbar"><ul>' +
-          '<li>' +
-            '<button id="clonebutton" type="button" class="btn btn-warning navbar-btn">' +
-              '<span class="glyphicon glyphicon-duplicate" aria-hidden="true"></span> Clone' +
-            '</button>' +
-          '</li>' +
-          '<li>' +
-            '<button id="emaillink" type="button" class="hidden btn btn-secondary">Email</button>' +
-          '</li>' +
-        '</ul></div></nav>' +
-        '<input id="burnafterreadingoption" type="checkbox">' +
-        '<div id="pastelink">Your document is ' +
-          '<a id="pasteurl" href="https://short.example/xYz">https://short.example/xYz</a> ' +
-          '<span id="copyhint">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>' +
-        '</div>' +
-        // add a minimal email confirmation modal so sendEmail does not crash
-        '<div id="emailconfirmmodal" class="hidden">' +
-            '<div id="emailconfirm-timezone-current"></div>' +
-            '<div id="emailconfirm-timezone-utc"></div>' +
-        '</div>'
-}
-
-
-function makeWindowOpenMock() {
-    const originalOpen = window.open;
-    let openedUrl = null;
-    let mockRestoreFn = null;
-
-    if (typeof jest !== 'undefined' && typeof jest.spyOn === 'function') {
-        const spy = jest.spyOn(window, 'open').mockImplementation((url) => {
-            openedUrl = url;
-            return {};
-        });
-        mockRestoreFn = () => spy.mockRestore();
-    } else {
-        window.open = function (url) {
-            openedUrl = url;
-            return {};
-        };
-        mockRestoreFn = () => { window.open = originalOpen; };
-    }
-
-    return {
-        getUrl: () => openedUrl,
-        restore: () => {
-            if (mockRestoreFn) {
-                mockRestoreFn();
-            }
-        }
-    };
-}
-
-
-// Extract and decode the body from a "mailto:?subject=...&body=..." URL.
-function extractMailtoBody(mailtoUrl) {
-    assert.match(mailtoUrl, /^mailto:\?/, 'expected a mailto: URL');
-    const match = mailtoUrl.match(/[?&]body=([^&]*)/);
-    assert.ok(match, 'expected the mailto: URL to have a body= parameter');
-    return decodeURIComponent(match[1]);
-}
-
-// Extract and decode the subject from a "mailto:?subject=...&body=..." URL.
-function extractMailtoSubject(mailtoUrl) {
-    assert.match(mailtoUrl, /^mailto:\?/, 'expected a mailto: URL');
-    const match = mailtoUrl.match(/[?&]subject=([^&]*)/);
-    assert.ok(match, 'expected the mailto: URL to have a subject= parameter');
-    return decodeURIComponent(match[1]);
-}
-
-describe('Email - mail body content (short URL vs. fallback)', function () {
-    beforeEach(function () {
-        cleanup(); // provided by common
-    });
-
-    it('Uses the short URL when #pasteurl is present and never includes "undefined"', function () {
-        buildEmailDomWithShortUrl(); // with #pastelink/#pasteurl
-        PrivateBin.TopNav.init();
-        PrivateBin.TopNav.showEmailButton(0);
-
-        const emailBtn = document.getElementById('emaillink');
-        assert.ok(!emailBtn.classList.contains('hidden'), '#emaillink should be visible after showEmailButton');
-
-        const { getUrl, restore } = makeWindowOpenMock();
-        try {
-            emailBtn.click();
-            document.getElementById('emailconfirm-timezone-current').click();
-
-            const openedUrl = getUrl();
-            assert.ok(openedUrl, 'window.open should have been called');
-
-            const body = extractMailtoBody(openedUrl);
-            assert.match(body, /https:\/\/short\.example\/xYz/, 'email body should include the short URL');
-            assert.doesNotMatch(body, /undefined/, 'email body must not contain "undefined"');
-        } finally {
-            restore();
-        }
-    });
-
-    it('Falls back to window.location.href when #pasteurl is absent and never includes "undefined"', function () {
-        buildEmailDomNoShortUrl(); // No #pasteurl
-        PrivateBin.TopNav.init();
-        PrivateBin.TopNav.showEmailButton(0);
-
-        const emailBtn = document.getElementById('emaillink');
-        assert.ok(!emailBtn.classList.contains('hidden'), '#emaillink should be visible after showEmailButton');
-
-        const { getUrl, restore } = makeWindowOpenMock();
-        try {
-            emailBtn.click();
-            document.getElementById('emailconfirm-timezone-current').click();
-
-            const openedUrl = getUrl();
-            assert.ok(openedUrl, 'window.open should have been called');
-
-            const body = extractMailtoBody(openedUrl);
-            assert.match(body, new RegExp(window.location.href), 'email body should include the fallback page URL');
-            assert.doesNotMatch(body, /undefined/, 'email body must not contain "undefined"');
-        } finally {
-            restore();
-        }
-    });
-
-    it('Uses the viewed paste burn-after-reading state instead of the instance default', function () {
-        buildEmailDomNoShortUrl();
-        const burnAfterReading = document.createElement('input');
-        burnAfterReading.id = 'burnafterreading';
-        burnAfterReading.type = 'checkbox';
-        burnAfterReading.checked = true;
-        document.body.appendChild(burnAfterReading);
-        PrivateBin.TopNav.init();
-        PrivateBin.TopNav.showEmailButton(0, false);
-
-        const { getUrl, restore } = makeWindowOpenMock();
-        try {
-            document.getElementById('emaillink').click();
-            document.getElementById('emailconfirm-timezone-current').click();
-
-            const body = extractMailtoBody(getUrl());
-            assert.doesNotMatch(
-                body,
-                /only be accessed once/,
-                'email body must use the viewed paste metadata'
-            );
-        } finally {
-            restore();
-        }
-    });
-});
-
-describe('Email - mail subject', function () {
-    beforeEach(function () {
-        cleanup(); // provided by common
-    });
-
-    it('Includes a non-empty subject naming the instance, with no expiration confirmation step', function () {
-        buildEmailDomNoShortUrl();
-        // buildEmailDomNoShortUrl() replaces documentElement.innerHTML (dropping <title>),
-        // so document.title must be (re-)set after calling it, not before.
-        document.title = 'My PrivateBin Instance';
-        PrivateBin.TopNav.init();
-        PrivateBin.TopNav.showEmailButton(0);
-
-        const { getUrl, restore } = makeWindowOpenMock();
-        try {
-            document.getElementById('emaillink').click();
-
-            const openedUrl = getUrl();
-            assert.ok(openedUrl, 'window.open should have been called');
-
-            const subject = extractMailtoSubject(openedUrl);
-            assert.match(subject, /My PrivateBin Instance/, 'subject should name the instance');
-        } finally {
-            restore();
-        }
-    });
-
-    it('Includes the same subject after the expiration confirmation step', function () {
-        buildEmailDomWithShortUrl();
-        document.title = 'My PrivateBin Instance';
-        PrivateBin.TopNav.init();
-        // a non-zero remaining time routes through the timezone confirmation modal
-        PrivateBin.TopNav.showEmailButton(3600);
-
-        const { getUrl, restore } = makeWindowOpenMock();
-        try {
-            document.getElementById('emaillink').click();
-            document.getElementById('emailconfirm-timezone-current').click();
-
-            const openedUrl = getUrl();
-            assert.ok(openedUrl, 'window.open should have been called');
-
-            const subject = extractMailtoSubject(openedUrl);
-            assert.match(subject, /My PrivateBin Instance/, 'subject should name the instance');
-        } finally {
-            restore();
-        }
-    });
-
-    it('Percent-encodes the subject so a space-containing instance name does not break the mailto URL', function () {
-        buildEmailDomNoShortUrl();
-        document.title = 'My Cool Instance';
-        PrivateBin.TopNav.init();
-        PrivateBin.TopNav.showEmailButton(0);
-
-        const { getUrl, restore } = makeWindowOpenMock();
-        try {
-            document.getElementById('emaillink').click();
-
-            const openedUrl = getUrl();
-            const rawSubjectParam = openedUrl.split('&body=')[0];
-            assert.strictEqual(
-                rawSubjectParam,
-                `mailto:?subject=${encodeURIComponent('Encrypted note on My Cool Instance')}`,
-                'raw subject parameter should be exactly the encoded translated string'
-            );
-            assert.doesNotMatch(rawSubjectParam, / /, 'raw mailto URL must not contain a literal space');
-
-            const subject = extractMailtoSubject(openedUrl);
-            assert.strictEqual(subject, 'Encrypted note on My Cool Instance', 'decoded subject should round-trip exactly');
-        } finally {
-            restore();
-        }
-    });
-});

+ 6 - 0
js/types.jsonld

@@ -92,6 +92,12 @@
 		"@type": "dp:Second",
 		"@minimum": 1
 	},
+	"DocumentId": {
+		"@type": "so:name"
+	},
+	"ParentId": {
+		"@type": "so:name"
+	},
 	"CipherParameters": {
 		"@container": "@list",
 		"@value": [

+ 16 - 15
lib/Configuration.php

@@ -35,20 +35,21 @@ class Configuration
      */
     private static $_defaults = [
         'main' => [
-            'name'                     => 'PrivateBin',
-            'basepath'                 => '',
-            'discussion'               => true,
-            'opendiscussion'           => false,
-            'discussiondatedisplay'    => true,
-            'password'                 => true,
-            'fileupload'               => false,
-            'burnafterreadingselected' => false,
-            'defaultformatter'         => 'plaintext',
-            'syntaxhighlightingtheme'  => '',
-            'sizelimit'                => 10485760,
-            'templateselection'        => false,
-            'template'                 => 'bootstrap5',
-            'availabletemplates'       => [
+            'name'                                  => 'PrivateBin',
+            'basepath'                              => '',
+            'discussion'                            => true,
+            'opendiscussion'                        => false,
+            'discussiondatedisplay'                 => true,
+            'discussionrequireauthenticatedids'     => false,
+            'password'                              => true,
+            'fileupload'                            => false,
+            'burnafterreadingselected'              => false,
+            'defaultformatter'                      => 'plaintext',
+            'syntaxhighlightingtheme'               => '',
+            'sizelimit'                             => 10485760,
+            'templateselection'                     => false,
+            'template'                              => 'bootstrap5',
+            'availabletemplates'                    => [
                 'bootstrap5',
             ],
             'info'                     => 'More information on the <a href=\'https://privatebin.info/\'>project page</a>.',
@@ -118,7 +119,7 @@ class Configuration
             'js/kjua-0.10.0.js'      => 'sha512-BYj4xggowR7QD150VLSTRlzH62YPfhpIM+b/1EUEr7RQpdWAGKulxWnOvjFx1FUlba4m6ihpNYuQab51H6XlYg==',
             'js/legacy.js'           => 'sha512-pRofxsrf5UItjiP22Dcjh3FAcBjF/n7h8U9/W5xqJk17U0N2U1oajhXypq/omo9jhwS1iVGOhWrRepoPeFns+w==',
             'js/prettify.js'         => 'sha512-puO0Ogy++IoA2Pb9IjSxV1n4+kQkKXYAEUtVzfZpQepyDPyXk8hokiYDS7ybMogYlyyEIwMLpZqVhCkARQWLMg==',
-            'js/privatebin.js'       => 'sha512-jXV3mKXtezHb3zaJ/Ijhj4EVT9IoB/RQGhrVxFE6ID8zAZIE3otvIL3Nf+L7AR78xNNCTm0jLJSvHeNPVxoJIw==',
+            'js/privatebin.js'       => 'sha512-3OpAPsku1J5vyuedDhIC9o4EKc26NsML2EaVuX/0C9LknU5oG8JYuluK2pgdIqq/A3geYKuP6gRtZoayGS+yKQ==',
             'js/purify-3.4.17.js'    => 'sha512-udF2zPdEyVCAxIGeBbeSGFK1Fj5c4eHGi6NqOSsr8M75lGs39POzgUNxO3SFxOg4cHfAoZJgR2EaXdmDi6v6AQ==',
             'js/showdown-2.1.0.js'   => 'sha512-WYXZgkTR0u/Y9SVIA4nTTOih0kXMEd8RRV6MLFdL6YU8ymhR528NLlYQt1nlJQbYz4EW+ZsS0fx1awhiQJme1Q==',
             'js/zlib-1.3.2.js'       => 'sha512-RAhJgxg9siMIA8ky4c10Rc2zUgnK80olHB8Tt1IOYWY4Eh1WmrviQkDn+sgBlb38ZHq3tzufGC41kP360gmosQ==',

+ 5 - 15
lib/Controller.php

@@ -32,7 +32,7 @@ class Controller
      *
      * @const string
      */
-    const VERSION = '2.1.0';
+    const VERSION = '2.1.1';
 
     /**
      * minimal required PHP version
@@ -92,7 +92,7 @@ class Controller
      * Factory of instance models
      *
      * @access private
-     * @var    model
+     * @var    Model
      */
     private $_model;
 
@@ -100,7 +100,7 @@ class Controller
      * request
      *
      * @access private
-     * @var    request
+     * @var    Request
      */
     private $_request;
 
@@ -280,14 +280,15 @@ class Controller
         ServerSalt::setStore($this->_model->getStore());
         TrafficLimiter::setConfiguration($this->_conf);
         TrafficLimiter::setStore($this->_model->getStore());
+        $data = [];
         try {
             TrafficLimiter::canPass();
+            $data = $this->_request->getData($this->_conf->getKey('sizelimit'));
         } catch (TranslatedException $e) {
             $this->_json_error($e->getMessage());
             return;
         }
 
-        $data = $this->_request->getData();
         $isComment = array_key_exists('pasteid', $data) &&
             !empty($data['pasteid']) &&
             array_key_exists('parentid', $data) &&
@@ -296,17 +297,6 @@ class Controller
             $this->_json_error(I18n::_('Invalid data.'));
             return;
         }
-        $sizelimit = $this->_conf->getKey('sizelimit');
-        // Ensure content is not too big.
-        if (strlen($data['ct']) > $sizelimit) {
-            $this->_json_error(
-                I18n::_(
-                    'Document is limited to %s of encrypted data.',
-                    Filter::formatHumanReadableSize($sizelimit)
-                )
-            );
-            return;
-        }
 
         // The user posts a comment.
         if ($isComment) {

+ 9 - 4
lib/Data/Database.php

@@ -301,10 +301,15 @@ class Database extends AbstractData
      */
     public function readComments($pasteid)
     {
-        $rows = $this->_select(
-            'SELECT * FROM "' . $this->_sanitizeIdentifier('comment') .
-            '" WHERE "pasteid" = ?', [$pasteid]
-        );
+        try {
+            $rows = $this->_select(
+                'SELECT * FROM "' . $this->_sanitizeIdentifier('comment') .
+                '" WHERE "pasteid" = ?', [$pasteid]
+            );
+        } catch (PDOException $e) {
+            error_log('Error while reading comments from the database: ' . $e->getMessage());
+            return [];
+        }
 
         // create comment list
         $comments = [];

+ 27 - 11
lib/FormatV2.php

@@ -39,7 +39,7 @@ class FormatV2
             $required_keys[] = 'meta';
         }
 
-        // Make sure no additionnal keys were added.
+        // Make sure no additional keys were added.
         if (count(array_keys($message)) !== count($required_keys)) {
             return false;
         }
@@ -63,6 +63,32 @@ class FormatV2
             return false;
         }
 
+        // Make sure the adata parameters of pastes are a properly sized array
+        $adataLength = count($message['adata']);
+
+        // Comments may authenticate their document and parent IDs, by
+        // appending them to the cipher parameters. These must match the
+        // unencrypted IDs, which are validated by the comment model.
+        if ($isComment) {
+            if ($adataLength !== 8 && (
+                count($cipherParams) !== 10 ||
+                !is_string($cipherParams[8]) ||
+                !is_string($cipherParams[9]) ||
+                $cipherParams[8] !== $message['pasteid'] ||
+                $cipherParams[9] !== $message['parentid']
+            )) {
+                return false;
+            }
+            // For pastes: adata must have exactly 4 entries,
+            // and meta must contain only the 'expire' key.
+        } elseif ($adataLength !== 4 ||
+            !is_array($message['meta']) ||
+            count($message['meta']) !== 1 ||
+            !array_key_exists('expire', $message['meta'])
+        ) {
+            return false;
+        }
+
         // Make sure the ciphertext and the cipher parameters used in the
         // string operations below are actually strings, so that malformed
         // input yields "Invalid data." instead of a fatal type error.
@@ -131,16 +157,6 @@ class FormatV2
             return false;
         }
 
-        // require only the key 'expire' in the metadata of pastes
-        if (!$isComment && (
-            !is_array($message['meta']) ||
-            count($message['meta']) === 0 ||
-            !array_key_exists('expire', $message['meta']) ||
-            count($message['meta']) > 1
-        )) {
-            return false;
-        }
-
         return true;
     }
 }

+ 35 - 0
lib/Model/Comment.php

@@ -56,6 +56,26 @@ class Comment extends AbstractModel
             throw new TranslatedException(self::COLLISION_ERROR, 69);
         }
 
+        // Comment IDs must be unique within the paste, regardless of the
+        // parent, and the parent must be either the paste or an existing comment.
+        $commentid = $this->getId();
+        $parentid = $this->getParentId();
+        $parentExists = $parentid === $pasteid;
+        if ($commentid === $pasteid) {
+            throw new TranslatedException(self::COLLISION_ERROR, 72);
+        }
+        foreach ($this->_store->readComments($pasteid) as $comment) {
+            if ($comment['id'] === $commentid) {
+                throw new TranslatedException(self::COLLISION_ERROR, 69);
+            }
+            if ($comment['id'] === $parentid) {
+                $parentExists = true;
+            }
+        }
+        if (!$parentExists) {
+            throw new TranslatedException(self::INVALID_DATA_ERROR, 71);
+        }
+
         $this->_data['meta']['created'] = time();
 
         // store comment
@@ -138,6 +158,21 @@ class Comment extends AbstractModel
         return $this->_data['parentid'];
     }
 
+    /**
+     * Validate data.
+     *
+     * @access protected
+     * @param  array $data
+     * @throws TranslatedException
+     */
+    protected function _validate(array &$data)
+    {
+        // reject comments without authenticated IDs, if required
+        if ($this->_conf->getKey('discussionrequireauthenticatedids') && count($data['adata']) !== 10) {
+            throw new TranslatedException(self::INVALID_DATA_ERROR, 77);
+        }
+    }
+
     /**
      * Sanitizes data to conform with current configuration.
      *

+ 20 - 1
lib/Request.php

@@ -12,6 +12,7 @@
 namespace PrivateBin;
 
 use JsonException;
+use PrivateBin\Exception\TranslatedException;
 use PrivateBin\Model\Paste;
 
 /**
@@ -42,6 +43,14 @@ class Request
      */
     const MIME_XHTML = 'application/xhtml+xml';
 
+    /**
+     * Input stream length for size checks
+     *
+     * @access private
+     * @var int
+     */
+    private $_bodyLength = 0;
+
     /**
      * Input stream to use for PUT parameter parsing
      *
@@ -112,6 +121,7 @@ class Request
                 $this->_operation = 'create';
                 try {
                     $data = file_get_contents(self::$_inputStream);
+                    $this->_bodyLength = strlen($data);
                     $this->_params = Json::decode($data);
                     // a valid JSON scalar (number, bool or string) decodes
                     // without error, but is not a usable set of parameters
@@ -196,10 +206,19 @@ class Request
      * Get data of paste or comment
      *
      * @access public
+     * @param  int $sizelimit
+     * @throws JsonException
+     * @throws TranslatedException
      * @return array
      */
-    public function getData()
+    public function getData(int $sizelimit)
     {
+        if ($this->_bodyLength > $sizelimit) {
+            throw new TranslatedException([
+                'Document is limited to %s of encrypted data.',
+                Filter::formatHumanReadableSize($sizelimit),
+            ]);
+        }
         $data = [
             'adata' => $this->getParam('adata'),
         ];

+ 9 - 1
tst/Bootstrap.php

@@ -75,7 +75,14 @@ class Helper
      *
      * @var string
      */
-    private static $commentid = '5a52eebf11c4c94b';
+    private static $commentid = '33a5671d11b29d01';
+
+    /**
+     * example comment cipher text, its hash is the comment ID
+     *
+     * @var string
+     */
+    private static $commentCt = 'XNE9E3AGszrjQfV5bxxSuaflS05MViI3rYtG1Nph0Qc=';
 
     /**
      * example comment meta data
@@ -162,6 +169,7 @@ class Helper
     {
         $example = self::$pasteV2;
         $example['adata'] = $example['adata'][0];
+        $example['ct'] = self::$commentCt;
         $example['pasteid'] = $example['parentid'] = self::getPasteId();
         unset($example['meta']['expire']);
         $example['meta'] = array_merge($example['meta'], self::$commentV2);

+ 30 - 3
tst/ControllerTest.php

@@ -273,6 +273,33 @@ class ControllerTest extends TestCase
         $this->assertFalse($this->_data->exists(Helper::getPasteId()), 'paste exists after posting data');
     }
 
+    /**
+     * @runInSeparateProcess
+     */
+    public function testCreateOverSize()
+    {
+        $options = parse_ini_file(CONF, true);
+        $options['main']['sizelimit'] = 1000;
+        $options['traffic']['limit'] = 0;
+        Helper::createIniFile(CONF, $options);
+        $paste = Helper::getPastePost();
+        // add some extra authenticated data, could be a hypothetical future cryptographic hash, exceeding the sizelimit
+        $paste['adata'][] = str_repeat('0', $options['main']['sizelimit']);
+        $file = Helper::createTempFile();
+        file_put_contents($file, json_encode($paste));
+        Request::setInputStream($file);
+        $_SERVER['HTTP_X_REQUESTED_WITH'] = 'JSONHttpRequest';
+        $_SERVER['REQUEST_METHOD'] = 'POST';
+        $_SERVER['REMOTE_ADDR'] = '::1';
+        ob_start();
+        new Controller;
+        $content = ob_get_contents();
+        ob_end_clean();
+        $response = json_decode($content, true);
+        $this->assertEquals(1, $response['status'], 'outputs error status');
+        $this->assertFalse($this->_data->exists(Helper::getPasteId()), 'paste exists after posting data');
+    }
+
     /**
      * @runInSeparateProcess
      */
@@ -663,8 +690,8 @@ class ControllerTest extends TestCase
         $paste = Helper::getPaste();
         $this->_data->create(Helper::getPasteId(), $paste);
         $comment = Helper::getComment();
-        $this->_data->createComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getPasteId(), $comment);
-        $this->assertTrue($this->_data->existsComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getPasteId()), 'comment exists before posting data');
+        $this->_data->createComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId(), $comment);
+        $this->assertTrue($this->_data->existsComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId()), 'comment exists before posting data');
         $comment = Helper::getCommentJson();
         $file = Helper::createTempFile();
         file_put_contents($file, $comment);
@@ -678,7 +705,7 @@ class ControllerTest extends TestCase
         ob_end_clean();
         $response = json_decode($content, true);
         $this->assertEquals(1, $response['status'], 'outputs error status');
-        $this->assertTrue($this->_data->existsComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getPasteId()), 'paste exists after posting data');
+        $this->assertTrue($this->_data->existsComment(Helper::getPasteId(), Helper::getPasteId(), Helper::getCommentId()), 'paste exists after posting data');
     }
 
     /**

+ 38 - 0
tst/FormatV2Test.php

@@ -19,6 +19,14 @@ class FormatV2Test extends TestCase
         $paste['adata'][0][1] = '$';
         $this->assertFalse(FormatV2::isValid($paste), 'invalid base64 encoding of salt');
 
+        $paste = Helper::getPastePost();
+        $paste['adata'][] = 0;
+        $this->assertFalse(FormatV2::isValid($paste), 'invalid extra adata entry');
+
+        $paste = Helper::getPastePost();
+        $paste['adata'][] = 0;
+        $this->assertFalse(FormatV2::isValid($paste), 'invalid extra adata entry');
+
         $paste = Helper::getPastePost();
         $paste['ct'] = '$';
         $this->assertFalse(FormatV2::isValid($paste), 'invalid base64 encoding of ct');
@@ -108,5 +116,35 @@ class FormatV2Test extends TestCase
         $comment = Helper::getCommentPost();
         $comment['ct'] = 42;
         $this->assertFalse(FormatV2::isValid($comment, true), 'non-string comment ciphertext');
+
+        $comment = Helper::getCommentPost();
+        $comment['adata'][] = $comment['pasteid'];
+        $comment['adata'][] = $comment['parentid'];
+        $this->assertTrue(FormatV2::isValid($comment, true), 'comment with authenticated IDs');
+
+        $comment = Helper::getCommentPost();
+        $comment['adata'][] = $comment['pasteid'];
+        $this->assertFalse(FormatV2::isValid($comment, true), 'comment with incomplete authenticated IDs');
+
+        $comment = Helper::getCommentPost();
+        $comment['adata'][] = $comment['pasteid'];
+        $comment['adata'][] = $comment['parentid'];
+        $comment['adata'][] = $comment['parentid'];
+        $this->assertFalse(FormatV2::isValid($comment, true), 'comment with too many cipher parameters');
+
+        $comment = Helper::getCommentPost();
+        $comment['adata'][] = '1111111111111111';
+        $comment['adata'][] = $comment['parentid'];
+        $this->assertFalse(FormatV2::isValid($comment, true), 'comment with mismatched document ID');
+
+        $comment = Helper::getCommentPost();
+        $comment['adata'][] = $comment['pasteid'];
+        $comment['adata'][] = '1111111111111111';
+        $this->assertFalse(FormatV2::isValid($comment, true), 'comment with mismatched parent ID');
+
+        $comment = Helper::getCommentPost();
+        $comment['adata'][] = $comment['pasteid'];
+        $comment['adata'][] = [];
+        $this->assertFalse(FormatV2::isValid($comment, true), 'comment with non-string parent ID');
     }
 }

+ 102 - 3
tst/ModelTest.php

@@ -91,7 +91,7 @@ class ModelTest extends TestCase
 
         $comments = $this->_model->getPaste(Helper::getPasteId())->get()['comments'];
         $this->assertTrue(count($comments) === 1, 'comment exists after storing it');
-        $commentData['id'] = Helper::getPasteId();
+        $commentData['id'] = Helper::getCommentId();
         $commentData['meta']['created'] = current($comments)['meta']['created'];
         $commentData['meta']['icon'] = current($comments)['meta']['icon'];
         $this->assertEquals($commentData, current($comments));
@@ -221,6 +221,36 @@ class ModelTest extends TestCase
         $comment->store();
     }
 
+    public function testCommentRequireIds()
+    {
+        $options = parse_ini_file(CONF, true);
+        $options['main']['discussionrequireauthenticatedids'] = 'true';
+        Helper::createIniFile(CONF, $options);
+        $model = new Model(new Configuration);
+
+        $pasteData = Helper::getPastePost();
+        $commentData = Helper::getCommentPost();
+        $model->getPaste(Helper::getPasteId())->delete();
+
+        $paste = $model->getPaste();
+        $paste->setData($pasteData);
+        $paste->store();
+
+        $commentData['adata'][] = $commentData['pasteid'];
+        $commentData['adata'][] = $commentData['parentid'];
+        $comment = $paste->getComment(Helper::getPasteId());
+        $comment->setData($commentData);
+        $comment->store();
+        $this->assertTrue($comment->exists(), 'comment with authenticated IDs can be stored');
+
+        $commentData = Helper::getCommentPost();
+        $commentData['ct'] = base64_encode('other ciphertext');
+        $comment = $paste->getComment(Helper::getPasteId());
+        $this->expectException(Exception::class);
+        $this->expectExceptionCode(77);
+        $comment->setData($commentData);
+    }
+
     public function testCommentDuplicate()
     {
         $pasteData = Helper::getPastePost();
@@ -242,6 +272,75 @@ class ModelTest extends TestCase
         $comment->store();
     }
 
+    public function testCommentDuplicateOtherParent()
+    {
+        $pasteData = Helper::getPastePost();
+        $commentData = Helper::getCommentPost();
+        $this->_model->getPaste(Helper::getPasteId())->delete();
+
+        $paste = $this->_model->getPaste();
+        $paste->setData($pasteData);
+        $paste->store();
+
+        $comment = $paste->getComment(Helper::getPasteId());
+        $comment->setData($commentData);
+        $comment->store();
+        $firstCommentId = $comment->getId();
+
+        // reply to the first comment, using a different ciphertext
+        $replyData = $commentData;
+        $replyData['ct'] = base64_encode('reply ciphertext');
+        $replyData['parentid'] = $firstCommentId;
+        $reply = $paste->getComment($firstCommentId);
+        $reply->setData($replyData);
+        $reply->store();
+        $this->assertTrue($reply->exists(), 'reply to existing comment can be stored');
+
+        // replay of the first comment under the reply as parent
+        $commentData['parentid'] = $reply->getId();
+        $comment = $paste->getComment($reply->getId());
+        $comment->setData($commentData);
+        $this->expectException(Exception::class);
+        $this->expectExceptionCode(69);
+        $comment->store();
+    }
+
+    public function testCommentNonexistentParent()
+    {
+        $pasteData = Helper::getPastePost();
+        $commentData = Helper::getCommentPost();
+        $commentData['parentid'] = '1111111111111111';
+        $this->_model->getPaste(Helper::getPasteId())->delete();
+
+        $paste = $this->_model->getPaste();
+        $paste->setData($pasteData);
+        $paste->store();
+
+        $comment = $paste->getComment('1111111111111111');
+        $comment->setData($commentData);
+        $this->expectException(Exception::class);
+        $this->expectExceptionCode(71);
+        $comment->store();
+    }
+
+    public function testCommentEqualsPaste()
+    {
+        $pasteData = Helper::getPastePost();
+        $commentData = Helper::getCommentPost();
+        $commentData['ct'] = $pasteData['ct'];
+        $this->_model->getPaste(Helper::getPasteId())->delete();
+
+        $paste = $this->_model->getPaste();
+        $paste->setData($pasteData);
+        $paste->store();
+
+        $comment = $paste->getComment(Helper::getPasteId());
+        $comment->setData($commentData);
+        $this->expectException(Exception::class);
+        $this->expectExceptionCode(72);
+        $comment->store();
+    }
+
     public function testImplicitDefaults()
     {
         $pasteData = Helper::getPastePost();
@@ -430,14 +529,14 @@ class ModelTest extends TestCase
         $commentData = Helper::getCommentPost();
         unset($commentData['meta']['icon']);
         $paste = $model->getPaste(Helper::getPasteId());
-        $comment = $paste->getComment(Helper::getPasteId(), Helper::getPasteId());
+        $comment = $paste->getComment(Helper::getPasteId(), Helper::getCommentId());
         $this->assertFalse($comment->exists(), 'comment does not yet exist');
 
         $comment = $paste->getComment(Helper::getPasteId());
         $comment->setData($commentData);
         $comment->store();
 
-        $comment = $paste->getComment(Helper::getPasteId(), Helper::getPasteId());
+        $comment = $paste->getComment(Helper::getPasteId(), Helper::getCommentId());
         $this->assertTrue($comment->exists(), 'comment exists after storing it');
 
         $comment = current($this->_model->getPaste(Helper::getPasteId())->get()['comments']);