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 '_'.