소스 검색

fix: prevent truncating characters above U+00FF to their lowest byte

Converting strings into byte arrays via charCodeAt truncates any
character above U+00FF to its lowest byte. This is lossless only for
binary strings, i.e. the output of atob, but got applied to unicode
strings in two places.

SVG attachment previews:
- The DOMPurify sanitized SVG got truncated when creating its blob, so
characters like ļ/ľ (U+013C/U+013E), including ones decoded from
character references, got turned into "<" and ">" after sanitization,
rebuilding script tags. Opening the preview in a new tab executed them
in the instance's origin.
- The attachment data is now converted into a byte array once, directly
after base64 decoding, and getBlobUrl only accepts byte arrays.
- The sanitized SVG preview now uses a data URL instead of a blob URL.
Unlike blob URLs, data URLs don't inherit the instance's origin when
opened in a new tab, so even a sanitizer bypass can no longer execute
scripts in it.
- Checking for already displayed previews now compares the src
attributes directly, instead of using a selector with the URL, which
breaks on data URLs containing apostrophes.

Passwords:
- Characters of passwords above U+00FF got truncated before key
derivation, i.e. "ļ" and "<" derived the same key.
- Passwords are now UTF-8 encoded by the callers of deriveKey, which now
takes the encoded password.
- If deciphering fails and the password contains characters above
U+007F, it gets retried using the previous, truncating encoding, so
existing documents remain readable. Removing that retry will drop
support for this legacy encoding.

The tests added in the preceding two commits now pass. The SVG preview
tests now read the preview's data URL instead of a blob, and a test
ensures that sanitized SVGs get previewed using a data URL.
El RIDO 1 주 전
부모
커밋
11299594b9
3개의 변경된 파일과 128개의 추가작업 그리고 55개의 파일을 삭제
  1. 3 0
      CHANGELOG.md
  2. 74 47
      js/privatebin.js
  3. 51 8
      js/test/AttachmentViewer.js

+ 3 - 0
CHANGELOG.md

@@ -8,6 +8,9 @@
 * 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: 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)
 ## 2.1.0 (2026-10-18)
 * ADDED: Added `shortenviachhoto` endpoint with an `chhoto` configuration section
 * ADDED: Added `shortenviachhoto` endpoint with an `chhoto` configuration section

+ 74 - 47
js/privatebin.js

@@ -1046,14 +1046,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
          * @name   CryptTool.stringToArraybuffer
          * @function
          * @function
          * @private
          * @private
-         * @param  {string} message UTF-8 string
+         * @param  {string} message binary string
          * @return {Uint8Array} array
          * @return {Uint8Array} array
          */
          */
         function stringToArraybuffer(message)
         function stringToArraybuffer(message)
@@ -1140,16 +1142,15 @@ window.PrivateBin = (function () {
          * @async
          * @async
          * @function
          * @function
          * @private
          * @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);
             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(keyArray, 0);
                 newKeyArray.set(passwordArray, keyArray.length);
                 newKeyArray.set(passwordArray, keyArray.length);
                 keyArray = newKeyArray;
                 keyArray = newKeyArray;
@@ -1249,7 +1250,11 @@ window.PrivateBin = (function () {
                     arraybufferToString(
                     arraybufferToString(
                         await window.crypto.subtle.encrypt(
                         await window.crypto.subtle.encrypt(
                             cryptoSettings(JSON.stringify(adata), spec),
                             cryptoSettings(JSON.stringify(adata), spec),
-                            await deriveKey(key, password, spec),
+                            await deriveKey(
+                                key,
+                                new TextEncoder().encode(password),
+                                spec
+                            ),
                             await compress(message, compression, zlib)
                             await compress(message, compression, zlib)
                         ).catch(Alert.showError)
                         ).catch(Alert.showError)
                     )
                     )
@@ -1288,17 +1293,34 @@ window.PrivateBin = (function () {
                     throw 'Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.';
                     throw 'Error decompressing document, your browser does not support WebAssembly. Please use another browser to view this document.';
                 }
                 }
             }
             }
+            const settings = cryptoSettings(adataString, spec),
+                cipherArray = stringToArraybuffer(
+                    atob(cipherMessage)
+                ),
+                decrypt = async passwordArray => window.crypto.subtle.decrypt(
+                    settings,
+                    await deriveKey(key, passwordArray, spec),
+                    cipherArray
+                );
             try {
             try {
-                plaintext = await window.crypto.subtle.decrypt(
-                    cryptoSettings(adataString, spec),
-                    await deriveKey(key, password, spec),
-                    stringToArraybuffer(
-                        atob(cipherMessage)
-                    )
+                plaintext = await decrypt(
+                    new TextEncoder().encode(password)
                 );
                 );
             } catch (err) {
             } catch (err) {
-                console.error(err);
-                return '';
+                // documents created by earlier versions truncated the
+                // characters of non-ASCII passwords to their lowest byte
+                if (!/[\u0080-\uffff]/.test(password)) {
+                    console.error(err);
+                    return '';
+                }
+                try {
+                    plaintext = await decrypt(
+                        stringToArraybuffer(password)
+                    );
+                } catch (legacyErr) {
+                    console.error(legacyErr);
+                    return '';
+                }
             }
             }
             try {
             try {
                 return await decompress(plaintext, spec[7], zlib);
                 return await decompress(plaintext, spec[7], zlib);
@@ -2823,23 +2845,19 @@ window.PrivateBin = (function () {
             dropzone;
             dropzone;
 
 
         /**
         /**
-         * get blob URL from string data and mime type
+         * get blob URL from binary data and mime type
          *
          *
          * @name   AttachmentViewer.getBlobUrl
          * @name   AttachmentViewer.getBlobUrl
          * @private
          * @private
          * @function
          * @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
          * @return {string} objectURL
          */
          */
         function getBlobUrl(data, mimeType) {
         function getBlobUrl(data, mimeType) {
             // Transform into a Blob
             // Transform into a Blob
-            const buf = Uint8Array.from(
-                data,
-                character => character.charCodeAt(0)
-            );
             const blob = new window.Blob(
             const blob = new window.Blob(
-                [buf],
+                [data],
                 {
                 {
                     type: mimeType
                     type: mimeType
                 }
                 }
@@ -2878,12 +2896,19 @@ window.PrivateBin = (function () {
                 sanitizedMimeType = mimeType;
                 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 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);
             attachmentLink.setAttribute('href', blobUrl);
+            let previewUrl = blobUrl;
 
 
             if (typeof fileName !== 'undefined') {
             if (typeof fileName !== 'undefined') {
                 attachmentLink.setAttribute('download', fileName);
                 attachmentLink.setAttribute('download', fileName);
@@ -2895,39 +2920,39 @@ window.PrivateBin = (function () {
             }
             }
 
 
             // sanitize SVG preview
             // 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')) {
             if (mimeType.startsWith('image/svg')) {
                 try {
                 try {
                     // attempt to UTF-8 decode the SVG data
                     // attempt to UTF-8 decode the SVG data
-                    const svgBuffer = Uint8Array.from(
-                        plainData,
-                        character => character.charCodeAt(0)
-                    );
                     const utf8ValidatedSvgString = new TextDecoder(
                     const utf8ValidatedSvgString = new TextDecoder(
                         'utf-8',
                         'utf-8',
                         {fatal: true}
                         {fatal: true}
-                    ).decode(svgBuffer);
+                    ).decode(plainData);
                     const sanitizedData = DOMPurify.sanitize(
                     const sanitizedData = DOMPurify.sanitize(
                         utf8ValidatedSvgString,
                         utf8ValidatedSvgString,
                         purifySvgConfig
                         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';
                     sanitizedMimeType = 'image/svg+xml';
-                    blobUrl = getBlobUrl(sanitizedData, sanitizedMimeType);
                 } catch (e) {
                 } 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');
             template.classList.remove('hidden');
             attachment.appendChild(template);
             attachment.appendChild(template);
 
 
-            me.handleBlobAttachmentPreview(attachmentPreview, blobUrl, sanitizedMimeType);
+            me.handleBlobAttachmentPreview(attachmentPreview, previewUrl, sanitizedMimeType);
         };
         };
 
 
 
 
@@ -3242,7 +3267,9 @@ window.PrivateBin = (function () {
                 return;
                 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 (blobUrl && !alreadyIncludesCurrentAttachment) {
                 if (mimeType.startsWith('image/')) {
                 if (mimeType.startsWith('image/')) {

+ 51 - 8
js/test/AttachmentViewer.js

@@ -322,15 +322,32 @@ describe('AttachmentViewer', function () {
             return new Uint8Array(buffer);
             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
         // runs setAttachment on the given SVG source and returns the bytes of
-        // the sanitized image/svg+xml blob, used for the preview
+        // the sanitized SVG, used for the preview
         async function getSvgPreviewBytes(svg) {
         async function getSvgPreviewBytes(svg) {
-            const blobs = getAttachmentBlobs(
-                    'data:image/svg+xml;base64,' + Buffer.from(svg, 'utf8').toString('base64')
-                ),
-                svgBlobs = blobs.filter(blob => blob.type === 'image/svg+xml');
-            assert.strictEqual(svgBlobs.length, 1, 'creates one sanitized SVG blob');
-            return readBlob(svgBlobs[0]);
+            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 = {
         const lookalikeVectors = {
@@ -344,7 +361,7 @@ describe('AttachmentViewer', function () {
                     latin1 = Buffer.from(bytes).toString('latin1');
                     latin1 = Buffer.from(bytes).toString('latin1');
                 assert.ok(
                 assert.ok(
                     !/<\/?script/i.test(latin1),
                     !/<\/?script/i.test(latin1),
-                    'sanitized SVG blob must not contain script tags, got: ' + latin1
+                    'sanitized SVG preview must not contain script tags, got: ' + latin1
                 );
                 );
             });
             });
         }
         }
@@ -371,6 +388,28 @@ describe('AttachmentViewer', function () {
             {numRuns: 100});
             {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 () {
         it('does not preview SVGs that are not valid UTF-8', async function () {
             // 0xC4 followed by 0x3C ("<") is not a valid UTF-8 sequence
             // 0xC4 followed by 0x3C ("<") is not a valid UTF-8 sequence
             const svg = Buffer.concat([
             const svg = Buffer.concat([
@@ -385,6 +424,10 @@ describe('AttachmentViewer', function () {
                 !blobs.some(blob => blob.type === 'image/svg+xml'),
                 !blobs.some(blob => blob.type === 'image/svg+xml'),
                 'creates no SVG blob for preview'
                 'creates no SVG blob for preview'
             );
             );
+            assert.strictEqual(
+                document.querySelectorAll('#attachmentPreview img').length, 0,
+                'shows no preview'
+            );
             assert.strictEqual(
             assert.strictEqual(
                 blobs[blobs.length - 1].type, 'application/octet-stream',
                 blobs[blobs.length - 1].type, 'application/octet-stream',
                 'offers download only'
                 'offers download only'