From 9fdad46444ee7b2787ceb5730d13247cd0079f51 Mon Sep 17 00:00:00 2001 From: Govind Yadav Date: Fri, 28 Aug 2026 11:37:54 +0530 Subject: [PATCH] fix: Decode percent-encoded image data URLs --- CHANGELOG.md | 1 + lib/image.js | 7 ++-- lib/parse-data-url.js | 38 +++++++++++++++++++++ test/image.test.js | 66 +++++++++++++++++++++++++++++++++++++ test/parse-data-url.test.js | 37 +++++++++++++++++++++ 5 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 lib/parse-data-url.js create mode 100644 test/parse-data-url.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 26fb99743..8a8077ec1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ project adheres to [Semantic Versioning](http://semver.org/). ### Changed ### Added ### Fixed +* Decode percent-encoded image data URLs (#2126) * Load images from Node.js object URLs (#2525) 3.2.3 diff --git a/lib/image.js b/lib/image.js index a8713b61f..f266c1cec 100644 --- a/lib/image.js +++ b/lib/image.js @@ -13,6 +13,7 @@ const bindings = require('./bindings') const Image = module.exports = bindings.Image const util = require('util') +const parseDataURL = require('./parse-data-url') const { resolveObjectURL } = require('node:buffer') const { GetSource, SetSource } = bindings @@ -40,11 +41,7 @@ Object.defineProperty(Image.prototype, 'src', { if (typeof val === 'string') { if (/^\s*data:/.test(val)) { // data: URI - const commaI = val.indexOf(',') - // 'base64' must come before the comma - const isBase64 = val.lastIndexOf('base64', commaI) !== -1 - const content = val.slice(commaI + 1) - setSource(this, Buffer.from(content, isBase64 ? 'base64' : 'utf8'), val) + setSource(this, parseDataURL(val), val) } else if (/^\s*blob:nodedata:/.test(val)) { // Node.js object URL const blob = resolveObjectURL(val) if (!blob) { diff --git a/lib/parse-data-url.js b/lib/parse-data-url.js new file mode 100644 index 000000000..ba48b3c18 --- /dev/null +++ b/lib/parse-data-url.js @@ -0,0 +1,38 @@ +'use strict' + +// Decode the payload as bytes: decodeURIComponent would reject binary images +// containing non-UTF-8 bytes and literal percentages in otherwise valid SVGs. +module.exports = function parseDataURL (url) { + const commaI = url.indexOf(',') + const isBase64 = url.lastIndexOf('base64', commaI) !== -1 + const content = url.slice(commaI + 1) + if (!content.includes('%')) { + return Buffer.from(content, isBase64 ? 'base64' : 'utf8') + } + + const buffer = Buffer.from(content) + let length = 0 + for (let i = 0; i < buffer.length; i++) { + if (buffer[i] === 0x25 && i + 2 < buffer.length) { + const high = hexValue(buffer[i + 1]) + const low = hexValue(buffer[i + 2]) + if (high !== -1 && low !== -1) { + buffer[length++] = (high << 4) | low + i += 2 + continue + } + } + buffer[length++] = buffer[i] + } + + const decoded = buffer.subarray(0, length) + // The data-URL processing algorithm percent-decodes before base64 decoding. + return isBase64 ? Buffer.from(decoded.toString('latin1'), 'base64') : decoded +} + +function hexValue (byte) { + if (byte >= 0x30 && byte <= 0x39) return byte - 0x30 + if (byte >= 0x41 && byte <= 0x46) return byte - 0x41 + 10 + if (byte >= 0x61 && byte <= 0x66) return byte - 0x61 + 10 + return -1 +} diff --git a/test/image.test.js b/test/image.test.js index 9dd6610e6..a3e33efa3 100644 --- a/test/image.test.js +++ b/test/image.test.js @@ -110,6 +110,34 @@ describe('Image', function () { } }) + it('loads percent-encoded PNG data URLs as bytes, not UTF-8 text', async function () { + const png = fs.readFileSync(pngCheckers) + const encoded = Array.from(png, byte => '%' + byte.toString(16).padStart(2, '0')).join('') + const dataURL = `data:image/png,${encoded}` + const img = await loadImage(dataURL) + const expected = await loadImage(png) + + assert.strictEqual(img.src, dataURL) + assert.strictEqual(img.width, 2) + assert.strictEqual(img.height, 2) + const pixels = image => { + const ctx = createCanvas(2, 2).getContext('2d') + ctx.drawImage(image, 0, 0) + return ctx.getImageData(0, 0, 2, 2).data + } + assert.deepStrictEqual(pixels(img), pixels(expected)) + }) + + it('percent-decodes base64 image data before base64 decoding', async function () { + const base64 = fs.readFileSync(pngCheckers, 'base64') + const encoded = Array.from(base64, char => '%' + char.charCodeAt(0).toString(16)).join('') + const dataURL = `data:image/png;base64,${encoded}` + const img = await loadImage(dataURL) + assert.strictEqual(img.src, dataURL) + assert.strictEqual(img.width, 2) + assert.strictEqual(img.height, 2) + }) + it('rejects revoked object URLs', async function () { const objectURL = URL.createObjectURL(new Blob([ fs.readFileSync(pngClock) @@ -175,6 +203,44 @@ describe('Image', function () { }) }) + it('loads percent-encoded SVG data URLs (GH-2126)', async function () { + const svg = '' + const payloads = [ + encodeURIComponent(svg), + svg.replace(/#/g, '%23'), + encodeURIComponent(svg).toLowerCase() + ] + for (const payload of payloads) { + const dataURL = `data:image/svg+xml;charset=utf-8,${payload}` + const img = await loadImage(dataURL) + assert.strictEqual(img.src, dataURL) + assert.strictEqual(img.complete, true) + assert.strictEqual(img.onerror, null) + assert.strictEqual(img.onload, null) + const ctx = createCanvas(2, 2).getContext('2d') + ctx.drawImage(img, 0, 0) + assert.deepStrictEqual(Array.from(ctx.getImageData(0, 0, 1, 1).data), [255, 0, 255, 255]) + } + }) + + it('preserves raw SVG percentages and literal invalid escapes', async function () { + const svg = '' + const img = await loadImage(`data:image/svg+xml,${svg}`) + const ctx = createCanvas(2, 2).getContext('2d') + ctx.drawImage(img, 0, 0) + assert.deepStrictEqual(Array.from(ctx.getImageData(0, 0, 1, 1).data), [255, 0, 0, 255]) + }) + + it('reports invalid percent-encoded image data through onerror', function (done) { + const img = new Image() + img.onload = () => done(new Error('Invalid image unexpectedly loaded')) + img.onerror = err => { + assert(err instanceof Error) + done() + } + img.src = 'data:image/png,%FF%00%FE' + }) + it('calls Image#onload multiple times', function () { return loadImage(pngClock).then((img) => { let onloadCalled = 0 diff --git a/test/parse-data-url.test.js b/test/parse-data-url.test.js new file mode 100644 index 000000000..59a77f825 --- /dev/null +++ b/test/parse-data-url.test.js @@ -0,0 +1,37 @@ +/* eslint-env mocha */ + +'use strict' + +const assert = require('assert') +const parseDataURL = require('../lib/parse-data-url') + +describe('Data URL payload decoding', function () { + const cases = [ + ['raw UTF-8', 'data:,café 😀', Buffer.from('café 😀')], + ['encoded UTF-8', 'data:,%63af%C3%A9%20%F0%9F%98%80', Buffer.from('café 😀')], + ['mixed raw and encoded UTF-8', 'data:,café%20😀', Buffer.from('café 😀')], + ['binary bytes', 'data:,%00%80%FF', Buffer.from([0, 128, 255])], + ['hex letter case', 'data:,%aB%Cd%eF', Buffer.from([171, 205, 239])], + ['literal plus', 'data:,a+b%2Bc', Buffer.from('a+b+c')], + ['one decoding pass', 'data:,%2520%2525', Buffer.from('%20%25')], + ['invalid escapes', 'data:,%%2%GG%2G%G2%20%', Buffer.from('%%2%GG%2G%G2 %')], + ['empty payload', 'data:,', Buffer.alloc(0)], + ['raw base64', 'data:;base64,AP/+', Buffer.from([0, 255, 254])], + ['encoded base64', 'data:;base64,AP%2f%2b', Buffer.from([0, 255, 254])], + ['encoded base64 padding', 'data:;base64,/w%3D%3D', Buffer.from([255])], + ['encoded base64 whitespace', 'data:;base64,%20/w%3D%3D%0A', Buffer.from([255])], + ['commas in payload', 'data:text/plain;charset=utf-8,a%2Cb,c', Buffer.from('a,b,c')] + ] + + for (const [name, dataURL, expected] of cases) { + it(name, function () { + assert.deepStrictEqual(parseDataURL(dataURL), expected) + }) + } + + it('decodes every possible byte', function () { + const expected = Buffer.from(Array.from({ length: 256 }, (_, byte) => byte)) + const encoded = Array.from(expected, byte => '%' + byte.toString(16).padStart(2, '0')).join('') + assert.deepStrictEqual(parseDataURL(`data:,${encoded}`), expected) + }) +})