Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 2 additions & 5 deletions lib/image.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
38 changes: 38 additions & 0 deletions lib/parse-data-url.js
Original file line number Diff line number Diff line change
@@ -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
}
66 changes: 66 additions & 0 deletions test/image.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -175,6 +203,44 @@ describe('Image', function () {
})
})

it('loads percent-encoded SVG data URLs (GH-2126)', async function () {
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2"><rect width="100%" height="100%" fill="#ff00ff"/></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 = '<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2"><!-- %, %2, %GG, %2G, %G2 --><rect width="100%" height="100%" fill="red"/></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
Expand Down
37 changes: 37 additions & 0 deletions test/parse-data-url.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
})