From 03ad3279ab99312227da1fec17dc849ec6a4bd13 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:56:20 +0000 Subject: [PATCH 1/3] start: security fixes for prototype pollution and URL scheme validation --- package-lock.json | 95 ++++++++++++++++++++++------------------------- 1 file changed, 45 insertions(+), 50 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5d725bf..a938f50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1982,6 +1982,16 @@ "node": ">=8" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -1996,6 +2006,20 @@ "node": ">=8" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -2980,9 +3004,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2997,9 +3018,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3014,9 +3032,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3031,9 +3046,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3048,9 +3060,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3065,9 +3074,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3082,9 +3088,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3099,9 +3102,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3116,9 +3116,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3133,9 +3130,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3549,14 +3543,11 @@ } }, "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } + "license": "Python-2.0" }, "node_modules/babel-jest": { "version": "30.4.1", @@ -5886,17 +5877,28 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz", + "integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", + "optional": true, + "peer": true, "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { - "js-yaml": "bin/js-yaml.js" + "js-yaml": "bin/js-yaml.mjs" } }, "node_modules/js2xmlparser": { @@ -6205,13 +6207,6 @@ "markdown-it": "*" } }, - "node_modules/markdown-it/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/marked": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", From 313d91625ee5c1653bb18407c9b2d2d7e91ca8c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:58:28 +0000 Subject: [PATCH 2/3] fix: prototype pollution in mergeObj and URL scheme validation in Drafty entity props --- src/drafty.js | 38 +++++++++++++++++++++++++++++--------- src/drafty.test.js | 28 ++++++++++++++++++++++++++++ src/utils.js | 18 ++++++++++-------- src/utils.test.js | 8 ++++++++ 4 files changed, 75 insertions(+), 17 deletions(-) diff --git a/src/drafty.js b/src/drafty.js index 838b347..c94b0b1 100644 --- a/src/drafty.js +++ b/src/drafty.js @@ -286,6 +286,23 @@ function base64toDataUrl(b64, contentType) { return 'data:' + contentType + ';base64,' + b64; } +// Allow only URLs with safe schemes to prevent javascript: and data: injection. +// Returns null for URLs with disallowed schemes, the original value otherwise. +function sanitizeUrl(url) { + if (!url || typeof url != 'string') { + return url; + } + // Relative URLs have no scheme and are safe. + if (!/^\s*([a-z][a-z0-9+.-]*:|\/\/)/im.test(url)) { + return url; + } + // Among absolute URLs allow only http, https, and ftp. + if (/^(https?|ftp):\/\//i.test(url)) { + return url; + } + return null; +} + // Helpers for converting Drafty to HTML. const DECORATORS = { // Visial styles @@ -328,7 +345,7 @@ const DECORATORS = { close: _ => '', props: (data) => { return data ? { - href: data.url, + href: sanitizeUrl(data.url), target: '_blank' } : null; }, @@ -366,7 +383,7 @@ const DECORATORS = { 'data-act': data.act, 'data-val': data.val, 'data-name': data.name, - 'data-ref': data.ref + 'data-ref': sanitizeUrl(data.ref) } : null; }, }, @@ -379,10 +396,11 @@ const DECORATORS = { close: _ => '', props: (data) => { if (!data) return null; + const safeRef = sanitizeUrl(data.ref); return { // Embedded data or external link. - src: data.ref || base64toObjectUrl(data.val, data.mime, Drafty.logger), - 'data-preload': data.ref ? 'metadata' : 'auto', + src: safeRef || base64toObjectUrl(data.val, data.mime, Drafty.logger), + 'data-preload': safeRef ? 'metadata' : 'auto', 'data-duration': data.duration, 'data-name': data.name, 'data-size': data.val ? ((data.val.length * 0.75) | 0) : (data.size | 0), @@ -410,7 +428,7 @@ const DECORATORS = { return { // Temporary preview, or permanent preview, or external link. src: base64toDataUrl(data._tempPreview, data.mime) || - data.ref || base64toObjectUrl(data.val, data.mime, Drafty.logger), + sanitizeUrl(data.ref) || base64toObjectUrl(data.val, data.mime, Drafty.logger), title: data.name, alt: data.name, 'data-width': data.width, @@ -474,14 +492,16 @@ const DECORATORS = { close: _ => '', props: data => { if (!data) return null; - const poster = data.preref || base64toObjectUrl(data.preview, data.premime || 'image/jpeg', Drafty.logger); + const safePreref = sanitizeUrl(data.preref); + const safeRef = sanitizeUrl(data.ref); + const poster = safePreref || base64toObjectUrl(data.preview, data.premime || 'image/jpeg', Drafty.logger); return { // Embedded data or external link. src: poster, - 'data-src': data.ref || base64toObjectUrl(data.val, data.mime, Drafty.logger), + 'data-src': safeRef || base64toObjectUrl(data.val, data.mime, Drafty.logger), 'data-width': data.width, 'data-height': data.height, - 'data-preload': data.ref ? 'metadata' : 'auto', + 'data-preload': safeRef ? 'metadata' : 'auto', 'data-preview': poster, 'data-duration': data.duration | 0, 'data-name': data.name, @@ -1913,7 +1933,7 @@ Drafty.getDownloadUrl = function(entData) { if (!Drafty.isFormResponseType(entData.mime) && entData.val) { url = base64toObjectUrl(entData.val, entData.mime, Drafty.logger); } else if (typeof entData.ref == 'string') { - url = entData.ref; + url = sanitizeUrl(entData.ref); } return url; } diff --git a/src/drafty.test.js b/src/drafty.test.js index 7198f51..d2bd147 100644 --- a/src/drafty.test.js +++ b/src/drafty.test.js @@ -1598,3 +1598,31 @@ const quote_this = [ test.each(quote_this)('Drafty.quote %j', (src, exp) => { expect(Drafty.quote("tinode-user", "usrbzV_721mIW0", src)).toEqual(exp); }) + +// Tests for URL scheme validation via Drafty.attrValue (item 3: no URL scheme validation). +test('Drafty.attrValue LN sanitizeUrl', () => { + // Safe schemes are passed through. + expect(Drafty.attrValue('LN', {url: 'https://example.com/path'})).toEqual({href: 'https://example.com/path', target: '_blank'}); + expect(Drafty.attrValue('LN', {url: 'http://example.com'})).toEqual({href: 'http://example.com', target: '_blank'}); + expect(Drafty.attrValue('LN', {url: 'ftp://files.example.com'})).toEqual({href: 'ftp://files.example.com', target: '_blank'}); + // Relative URLs are safe. + expect(Drafty.attrValue('LN', {url: '/v0/file/s/abc.jpg'})).toEqual({href: '/v0/file/s/abc.jpg', target: '_blank'}); + expect(Drafty.attrValue('LN', {url: 'relative/path.html'})).toEqual({href: 'relative/path.html', target: '_blank'}); + // Unsafe schemes are blocked. + expect(Drafty.attrValue('LN', {url: "javascript:alert('XSS')"})).toEqual({href: null, target: '_blank'}); + expect(Drafty.attrValue('LN', {url: 'data:text/html,'})).toEqual({href: null, target: '_blank'}); + expect(Drafty.attrValue('LN', {url: 'vbscript:msgbox(1)'})).toEqual({href: null, target: '_blank'}); +}); + +test('Drafty.attrValue BN sanitizeUrl', () => { + expect(Drafty.attrValue('BN', {act: 'url', ref: 'https://example.com', name: 'btn', val: 'v'})) + .toEqual({'data-act': 'url', 'data-val': 'v', 'data-name': 'btn', 'data-ref': 'https://example.com'}); + expect(Drafty.attrValue('BN', {act: 'url', ref: "javascript:alert(1)", name: 'btn', val: 'v'})) + .toEqual({'data-act': 'url', 'data-val': 'v', 'data-name': 'btn', 'data-ref': null}); +}); + +test('Drafty.getDownloadUrl sanitizeUrl', () => { + expect(Drafty.getDownloadUrl({mime: 'image/jpeg', ref: 'https://example.com/img.jpg'})).toBe('https://example.com/img.jpg'); + expect(Drafty.getDownloadUrl({mime: 'image/jpeg', ref: "javascript:alert(1)"})).toBeNull(); + expect(Drafty.getDownloadUrl({mime: 'image/jpeg', ref: '/v0/file/s/abc.jpg'})).toBe('/v0/file/s/abc.jpg'); +}); diff --git a/src/utils.js b/src/utils.js index c535843..08f0695 100644 --- a/src/utils.js +++ b/src/utils.js @@ -90,16 +90,18 @@ export function mergeObj(dst, src) { } if (!dst || dst === DEL_CHAR) { - dst = src.constructor(); + dst = {}; } - for (let prop in src) { - if (src.hasOwnProperty(prop) && (prop != '_noForwarding')) { - try { - dst[prop] = mergeObj(dst[prop], src[prop]); - } catch (err) { - console.warn("Error merging property:", prop, err); - } + for (const prop of Object.keys(src)) { + // Skip prototype-polluting keys and internal forwarding flag. + if (prop === '__proto__' || prop === 'constructor' || prop === '_noForwarding') { + continue; + } + try { + dst[prop] = mergeObj(dst[prop], src[prop]); + } catch (err) { + console.warn("Error merging property:", prop, err); } } return dst; diff --git a/src/utils.test.js b/src/utils.test.js index acb7364..74fafe0 100644 --- a/src/utils.test.js +++ b/src/utils.test.js @@ -110,6 +110,14 @@ test('mergeObj', () => { a: 1, b: 2 }, 1)).toEqual(1); + // Prototype pollution: __proto__ key must not pollute Object.prototype. + const polluted = mergeObj({}, JSON.parse('{"__proto__":{"injected":true}}')); + expect(({}).injected).toBeUndefined(); + expect(polluted.injected).toBeUndefined(); + // Prototype pollution: constructor key must not overwrite constructor. + const withCtor = mergeObj({}, JSON.parse('{"constructor":{"prototype":{"injected2":true}}}')); + expect(({}).injected2).toBeUndefined(); + expect(typeof withCtor.constructor).not.toBe('object'); }); // Strips all values from an object of they evaluate to false or if their name starts with '_'. From 0eecb6df7c96b4ec0c3e5c712ebaa8e67578e39d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 14:00:02 +0000 Subject: [PATCH 3/3] fix: address code review comments on sanitizeUrl and mergeObj tests --- src/drafty.test.js | 2 ++ src/utils.test.js | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/drafty.test.js b/src/drafty.test.js index d2bd147..d4a5219 100644 --- a/src/drafty.test.js +++ b/src/drafty.test.js @@ -1612,6 +1612,8 @@ test('Drafty.attrValue LN sanitizeUrl', () => { expect(Drafty.attrValue('LN', {url: "javascript:alert('XSS')"})).toEqual({href: null, target: '_blank'}); expect(Drafty.attrValue('LN', {url: 'data:text/html,'})).toEqual({href: null, target: '_blank'}); expect(Drafty.attrValue('LN', {url: 'vbscript:msgbox(1)'})).toEqual({href: null, target: '_blank'}); + // Protocol-relative URLs are also blocked (scheme is inherited from page context). + expect(Drafty.attrValue('LN', {url: '//evil.com/path'})).toEqual({href: null, target: '_blank'}); }); test('Drafty.attrValue BN sanitizeUrl', () => { diff --git a/src/utils.test.js b/src/utils.test.js index 74fafe0..6d61a06 100644 --- a/src/utils.test.js +++ b/src/utils.test.js @@ -117,7 +117,7 @@ test('mergeObj', () => { // Prototype pollution: constructor key must not overwrite constructor. const withCtor = mergeObj({}, JSON.parse('{"constructor":{"prototype":{"injected2":true}}}')); expect(({}).injected2).toBeUndefined(); - expect(typeof withCtor.constructor).not.toBe('object'); + expect(typeof withCtor.constructor).toBe('function'); }); // Strips all values from an object of they evaluate to false or if their name starts with '_'.