Преглед изворни кода

Merge branch 'master' into email-handlers

El RIDO пре 1 недеља
родитељ
комит
46db5edd18

+ 3 - 2
CHANGELOG.md

@@ -2,13 +2,14 @@
 
 
 ## 2.1.1 (not yet released)
 ## 2.1.1 (not yet released)
 * ADDED: Translation for Korean (#2071)
 * ADDED: Translation for Korean (#2071)
-* CHANGED: Database backend now logs errors when reading comments, like it already did for comment existence checks
+* 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: Check `sizelimit` over full request, before attempting decoding
-* FIXED: Comments must now be unique in cipher text within a document, not reuse existing cipher texts
+* 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: 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: 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: 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: 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: E-mail button sent several e-mails after being shown again or after its confirmation dialog was dismissed, possibly with the expiration of an earlier document
 * FIXED: E-mail button sent several e-mails after being shown again or after its confirmation dialog was dismissed, possibly with the expiration of an earlier document
 
 
 ## 2.1.0 (2026-10-18)
 ## 2.1.0 (2026-10-18)

+ 5 - 0
cfg/conf.sample.php

@@ -23,6 +23,11 @@ opendiscussion = false
 ; the comments by creation time, but you can choose not to display them.
 ; the comments by creation time, but you can choose not to display them.
 ; discussiondatedisplay = false
 ; 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
 ; enable or disable the password feature, defaults to true
 password = true
 password = true
 
 

+ 1 - 1
js/comment.jsonld

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

+ 37 - 0
js/commentmeta.jsonld

@@ -3,6 +3,43 @@
 		"so": "https://schema.org/",
 		"so": "https://schema.org/",
 		"pb": "?jsonld=types#"
 		"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": {
 	"MetaData": {
 		"created": {
 		"created": {
 			"@type": "CreationTime"
 			"@type": "CreationTime"

+ 57 - 67
js/package-lock.json

@@ -10,7 +10,7 @@
       "license": "zlib-acknowledgement",
       "license": "zlib-acknowledgement",
       "devDependencies": {
       "devDependencies": {
         "@eslint/js": "^10.0.1",
         "@eslint/js": "^10.0.1",
-        "@peculiar/webcrypto": "^1.5.0",
+        "@peculiar/webcrypto": "^2.0.0",
         "eslint": "^10.13.0",
         "eslint": "^10.13.0",
         "fast-check": "^4.10.2",
         "fast-check": "^4.10.2",
         "globals": "^17.13.0",
         "globals": "^17.13.0",
@@ -930,51 +930,59 @@
       "license": "MIT"
       "license": "MIT"
     },
     },
     "node_modules/@peculiar/asn1-schema": {
     "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,
       "dev": true,
+      "license": "MIT",
+      "peer": true,
       "dependencies": {
       "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": {
     "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,
       "dev": true,
-      "dependencies": {
-        "tslib": "^2.0.0"
-      },
+      "license": "MIT",
+      "peer": true,
       "engines": {
       "engines": {
-        "node": ">=8.0.0"
+        "node": "^20.19.0 || >=22.12.0"
       }
       }
     },
     },
     "node_modules/@peculiar/utils": {
     "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,
       "dev": true,
-      "dependencies": {
-        "tslib": "^2.8.1"
+      "license": "MIT",
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
       }
       }
     },
     },
     "node_modules/@peculiar/webcrypto": {
     "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,
       "dev": true,
+      "license": "MIT",
       "dependencies": {
       "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": {
       "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": {
     "node_modules/@types/esrecurse": {
@@ -1092,17 +1100,17 @@
       "dev": true
       "dev": true
     },
     },
     "node_modules/asn1js": {
     "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,
       "dev": true,
+      "license": "BSD-3-Clause",
+      "peer": true,
       "dependencies": {
       "dependencies": {
-        "pvtsutils": "^1.3.6",
-        "pvutils": "^1.1.5",
-        "tslib": "^2.8.1"
+        "@peculiar/utils": "^4.1.0"
       },
       },
       "engines": {
       "engines": {
-        "node": ">=12.0.0"
+        "node": "^20.19.0 || >=22.12.0"
       }
       }
     },
     },
     "node_modules/balanced-match": {
     "node_modules/balanced-match": {
@@ -3358,24 +3366,6 @@
       ],
       ],
       "license": "MIT"
       "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": {
     "node_modules/qified": {
       "version": "0.10.1",
       "version": "0.10.1",
       "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz",
       "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz",
@@ -3761,12 +3751,6 @@
       "integrity": "sha512-93WeyHNuRggPEsfCe+yHxCgM2s6H3Q8Wmlt6b6ObJL8qc7eZlRaFjQxwTrB+zbvGtlDRnAkMoYYo3+2uH/fEwA==",
       "integrity": "sha512-93WeyHNuRggPEsfCe+yHxCgM2s6H3Q8Wmlt6b6ObJL8qc7eZlRaFjQxwTrB+zbvGtlDRnAkMoYYo3+2uH/fEwA==",
       "dev": true
       "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": {
     "node_modules/type-check": {
       "version": "0.4.0",
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -3871,16 +3855,22 @@
       }
       }
     },
     },
     "node_modules/webcrypto-core": {
     "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,
       "dev": true,
+      "license": "MIT",
+      "peer": true,
       "dependencies": {
       "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": {
     "node_modules/webidl-conversions": {

+ 1 - 1
js/package.json

@@ -8,7 +8,7 @@
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@eslint/js": "^10.0.1",
     "@eslint/js": "^10.0.1",
-    "@peculiar/webcrypto": "^1.5.0",
+    "@peculiar/webcrypto": "^2.0.0",
     "eslint": "^10.13.0",
     "eslint": "^10.13.0",
     "fast-check": "^4.10.2",
     "fast-check": "^4.10.2",
     "globals": "^17.13.0",
     "globals": "^17.13.0",

+ 36 - 11
js/privatebin.js

@@ -229,6 +229,25 @@ window.PrivateBin = (function () {
         this.getIcon = function () {
         this.getIcon = function () {
             return this.meta.icon || '';
             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
+            );
+        };
     }
     }
 
 
     /**
     /**
@@ -1235,12 +1254,12 @@ window.PrivateBin = (function () {
             for (let i = 0; i < spec.length; ++i) {
             for (let i = 0; i < spec.length; ++i) {
                 encodedSpec[i] = i < 2 ? btoa(spec[i]) : spec[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
                 // paste
                 adata[0] = encodedSpec;
                 adata[0] = encodedSpec;
+            } else {
+                // comment, optionally followed by its document and parent IDs
+                adata = encodedSpec.concat(adata);
             }
             }
 
 
             // finally, encrypt message
             // finally, encrypt message
@@ -5315,14 +5334,16 @@ window.PrivateBin = (function () {
             });
             });
 
 
             // fill it with unencrypted params
             // fill it with unencrypted params
-            ServerInteraction.setUnencryptedData('pasteid', Model.getPasteId());
+            const pasteid = Model.getPasteId();
             if (typeof parentid === 'undefined') {
             if (typeof parentid === 'undefined') {
                 // if parent id is not set, this is the top-most comment, so use
                 // 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
                 // 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
             // prepare cypher message
             let cipherMessage = {
             let cipherMessage = {
@@ -5578,11 +5599,15 @@ window.PrivateBin = (function () {
             const commentDecryptionPromises = [];
             const commentDecryptionPromises = [];
             // iterate over comments
             // iterate over comments
             for (let i = 0; i < paste.comments.length; ++i) {
             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;
                 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(
                 commentDecryptionPromises.push(
-                    commentPromise.then(function (commentJson) {
+                    CryptTool.decipher(key, password, comment.getCipherData()).then(function (commentJson) {
                         const commentMessage = JSON.parse(commentJson);
                         const commentMessage = JSON.parse(commentJson);
                         return [
                         return [
                             commentMessage.comment || '',
                             commentMessage.comment || '',

+ 29 - 0
js/test/CryptTool.js

@@ -70,6 +70,35 @@ describe('CryptTool', function () {
             assert.strictEqual(message, plaintext);
             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 () {
         it('can en- and decrypt a particular message (#260)', async function () {
             await fc.assert(fc.asyncProperty(
             await fc.assert(fc.asyncProperty(
                 fc.string(),
                 fc.string(),

+ 6 - 0
js/types.jsonld

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

+ 15 - 14
lib/Configuration.php

@@ -35,20 +35,21 @@ class Configuration
      */
      */
     private static $_defaults = [
     private static $_defaults = [
         'main' => [
         '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',
                 'bootstrap5',
             ],
             ],
             'info'                     => 'More information on the <a href=\'https://privatebin.info/\'>project page</a>.',
             'info'                     => 'More information on the <a href=\'https://privatebin.info/\'>project page</a>.',

+ 23 - 12
lib/FormatV2.php

@@ -63,8 +63,29 @@ class FormatV2
             return false;
             return false;
         }
         }
 
 
-        // Make sure the adata parameters of pastes are a properly sized array,
-        if (!$isComment && count($message['adata']) !== 4) {
+        // 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;
             return false;
         }
         }
 
 
@@ -136,16 +157,6 @@ class FormatV2
             return false;
             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;
         return true;
     }
     }
 }
 }

+ 15 - 0
lib/Model/Comment.php

@@ -158,6 +158,21 @@ class Comment extends AbstractModel
         return $this->_data['parentid'];
         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.
      * Sanitizes data to conform with current configuration.
      *
      *

+ 2 - 2
lib/Request.php

@@ -12,8 +12,8 @@
 namespace PrivateBin;
 namespace PrivateBin;
 
 
 use JsonException;
 use JsonException;
-use PrivateBin\Model\Paste;
 use PrivateBin\Exception\TranslatedException;
 use PrivateBin\Exception\TranslatedException;
+use PrivateBin\Model\Paste;
 
 
 /**
 /**
  * Request
  * Request
@@ -216,7 +216,7 @@ class Request
         if ($this->_bodyLength > $sizelimit) {
         if ($this->_bodyLength > $sizelimit) {
             throw new TranslatedException([
             throw new TranslatedException([
                 'Document is limited to %s of encrypted data.',
                 'Document is limited to %s of encrypted data.',
-                Filter::formatHumanReadableSize($sizelimit)
+                Filter::formatHumanReadableSize($sizelimit),
             ]);
             ]);
         }
         }
         $data = [
         $data = [

+ 5 - 5
tst/ControllerTest.php

@@ -278,19 +278,19 @@ class ControllerTest extends TestCase
      */
      */
     public function testCreateOverSize()
     public function testCreateOverSize()
     {
     {
-        $options                      = parse_ini_file(CONF, true);
+        $options = parse_ini_file(CONF, true);
         $options['main']['sizelimit'] = 1000;
         $options['main']['sizelimit'] = 1000;
-        $options['traffic']['limit']  = 0;
+        $options['traffic']['limit'] = 0;
         Helper::createIniFile(CONF, $options);
         Helper::createIniFile(CONF, $options);
         $paste = Helper::getPastePost();
         $paste = Helper::getPastePost();
         // add some extra authenticated data, could be a hypothetical future cryptographic hash, exceeding the sizelimit
         // add some extra authenticated data, could be a hypothetical future cryptographic hash, exceeding the sizelimit
         $paste['adata'][] = str_repeat('0', $options['main']['sizelimit']);
         $paste['adata'][] = str_repeat('0', $options['main']['sizelimit']);
-        $file  = Helper::createTempFile();
+        $file = Helper::createTempFile();
         file_put_contents($file, json_encode($paste));
         file_put_contents($file, json_encode($paste));
         Request::setInputStream($file);
         Request::setInputStream($file);
         $_SERVER['HTTP_X_REQUESTED_WITH'] = 'JSONHttpRequest';
         $_SERVER['HTTP_X_REQUESTED_WITH'] = 'JSONHttpRequest';
-        $_SERVER['REQUEST_METHOD']        = 'POST';
-        $_SERVER['REMOTE_ADDR']           = '::1';
+        $_SERVER['REQUEST_METHOD'] = 'POST';
+        $_SERVER['REMOTE_ADDR'] = '::1';
         ob_start();
         ob_start();
         new Controller;
         new Controller;
         $content = ob_get_contents();
         $content = ob_get_contents();

+ 30 - 0
tst/FormatV2Test.php

@@ -116,5 +116,35 @@ class FormatV2Test extends TestCase
         $comment = Helper::getCommentPost();
         $comment = Helper::getCommentPost();
         $comment['ct'] = 42;
         $comment['ct'] = 42;
         $this->assertFalse(FormatV2::isValid($comment, true), 'non-string comment ciphertext');
         $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');
     }
     }
 }
 }

+ 30 - 0
tst/ModelTest.php

@@ -221,6 +221,36 @@ class ModelTest extends TestCase
         $comment->store();
         $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()
     public function testCommentDuplicate()
     {
     {
         $pasteData = Helper::getPastePost();
         $pasteData = Helper::getPastePost();