Skip to content

Commit 2b39440

Browse files
mcollinaUzlopak
andcommittedApr 2, 2024··
Merge pull request from GHSA-9qxr-qj54-h672
Co-authored-by: uzlopak <aras.abbasi@googlemail.com> Signed-off-by: Matteo Collina <hello@matteocollina.com>
1 parent 64e3402 commit 2b39440

File tree

4 files changed

+402
-35
lines changed

4 files changed

+402
-35
lines changed
 

‎benchmarks/fetch/bytes-match.mjs

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { createHash } from 'node:crypto'
2+
import { bench, run } from 'mitata'
3+
import { bytesMatch } from '../../lib/web/fetch/util.js'
4+
5+
const body = Buffer.from('Hello world!')
6+
const validSha256Base64 = `sha256-${createHash('sha256').update(body).digest('base64')}`
7+
const invalidSha256Base64 = `sha256-${createHash('sha256').update(body).digest('base64')}`
8+
const validSha256Base64Url = `sha256-${createHash('sha256').update(body).digest('base64url')}`
9+
const invalidSha256Base64Url = `sha256-${createHash('sha256').update(body).digest('base64url')}`
10+
11+
bench('bytesMatch valid sha256 and base64', () => {
12+
bytesMatch(body, validSha256Base64)
13+
})
14+
bench('bytesMatch invalid sha256 and base64', () => {
15+
bytesMatch(body, invalidSha256Base64)
16+
})
17+
bench('bytesMatch valid sha256 and base64url', () => {
18+
bytesMatch(body, validSha256Base64Url)
19+
})
20+
bench('bytesMatch invalid sha256 and base64url', () => {
21+
bytesMatch(body, invalidSha256Base64Url)
22+
})
23+
24+
await run()

‎lib/fetch/util.js

+108-35
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,18 @@ const { isBlobLike, toUSVString, ReadableStreamFrom } = require('../core/util')
77
const assert = require('assert')
88
const { isUint8Array } = require('util/types')
99

10+
let supportedHashes = []
11+
1012
// https://nodejs.org/api/crypto.html#determining-if-crypto-support-is-unavailable
1113
/** @type {import('crypto')|undefined} */
1214
let crypto
1315

1416
try {
1517
crypto = require('crypto')
18+
const possibleRelevantHashes = ['sha256', 'sha384', 'sha512']
19+
supportedHashes = crypto.getHashes().filter((hash) => possibleRelevantHashes.includes(hash))
20+
/* c8 ignore next 3 */
1621
} catch {
17-
1822
}
1923

2024
function responseURL (response) {
@@ -542,66 +546,56 @@ function bytesMatch (bytes, metadataList) {
542546
return true
543547
}
544548

545-
// 3. If parsedMetadata is the empty set, return true.
549+
// 3. If response is not eligible for integrity validation, return false.
550+
// TODO
551+
552+
// 4. If parsedMetadata is the empty set, return true.
546553
if (parsedMetadata.length === 0) {
547554
return true
548555
}
549556

550-
// 4. Let metadata be the result of getting the strongest
557+
// 5. Let metadata be the result of getting the strongest
551558
// metadata from parsedMetadata.
552-
const list = parsedMetadata.sort((c, d) => d.algo.localeCompare(c.algo))
553-
// get the strongest algorithm
554-
const strongest = list[0].algo
555-
// get all entries that use the strongest algorithm; ignore weaker
556-
const metadata = list.filter((item) => item.algo === strongest)
559+
const strongest = getStrongestMetadata(parsedMetadata)
560+
const metadata = filterMetadataListByAlgorithm(parsedMetadata, strongest)
557561

558-
// 5. For each item in metadata:
562+
// 6. For each item in metadata:
559563
for (const item of metadata) {
560564
// 1. Let algorithm be the alg component of item.
561565
const algorithm = item.algo
562566

563567
// 2. Let expectedValue be the val component of item.
564-
let expectedValue = item.hash
568+
const expectedValue = item.hash
565569

566570
// See https://github.com/web-platform-tests/wpt/commit/e4c5cc7a5e48093220528dfdd1c4012dc3837a0e
567571
// "be liberal with padding". This is annoying, and it's not even in the spec.
568572

569-
if (expectedValue.endsWith('==')) {
570-
expectedValue = expectedValue.slice(0, -2)
571-
}
572-
573573
// 3. Let actualValue be the result of applying algorithm to bytes.
574574
let actualValue = crypto.createHash(algorithm).update(bytes).digest('base64')
575575

576-
if (actualValue.endsWith('==')) {
577-
actualValue = actualValue.slice(0, -2)
576+
if (actualValue[actualValue.length - 1] === '=') {
577+
if (actualValue[actualValue.length - 2] === '=') {
578+
actualValue = actualValue.slice(0, -2)
579+
} else {
580+
actualValue = actualValue.slice(0, -1)
581+
}
578582
}
579583

580584
// 4. If actualValue is a case-sensitive match for expectedValue,
581585
// return true.
582-
if (actualValue === expectedValue) {
583-
return true
584-
}
585-
586-
let actualBase64URL = crypto.createHash(algorithm).update(bytes).digest('base64url')
587-
588-
if (actualBase64URL.endsWith('==')) {
589-
actualBase64URL = actualBase64URL.slice(0, -2)
590-
}
591-
592-
if (actualBase64URL === expectedValue) {
586+
if (compareBase64Mixed(actualValue, expectedValue)) {
593587
return true
594588
}
595589
}
596590

597-
// 6. Return false.
591+
// 7. Return false.
598592
return false
599593
}
600594

601595
// https://w3c.github.io/webappsec-subresource-integrity/#grammardef-hash-with-options
602596
// https://www.w3.org/TR/CSP2/#source-list-syntax
603597
// https://www.rfc-editor.org/rfc/rfc5234#appendix-B.1
604-
const parseHashWithOptions = /((?<algo>sha256|sha384|sha512)-(?<hash>[A-z0-9+/]{1}.*={0,2}))( +[\x21-\x7e]?)?/i
598+
const parseHashWithOptions = /(?<algo>sha256|sha384|sha512)-((?<hash>[A-Za-z0-9+/]+|[A-Za-z0-9_-]+)={0,2}(?:\s|$)( +[!-~]*)?)?/i
605599

606600
/**
607601
* @see https://w3c.github.io/webappsec-subresource-integrity/#parse-metadata
@@ -615,8 +609,6 @@ function parseMetadata (metadata) {
615609
// 2. Let empty be equal to true.
616610
let empty = true
617611

618-
const supportedHashes = crypto.getHashes()
619-
620612
// 3. For each token returned by splitting metadata on spaces:
621613
for (const token of metadata.split(' ')) {
622614
// 1. Set empty to false.
@@ -626,7 +618,11 @@ function parseMetadata (metadata) {
626618
const parsedToken = parseHashWithOptions.exec(token)
627619

628620
// 3. If token does not parse, continue to the next token.
629-
if (parsedToken === null || parsedToken.groups === undefined) {
621+
if (
622+
parsedToken === null ||
623+
parsedToken.groups === undefined ||
624+
parsedToken.groups.algo === undefined
625+
) {
630626
// Note: Chromium blocks the request at this point, but Firefox
631627
// gives a warning that an invalid integrity was given. The
632628
// correct behavior is to ignore these, and subsequently not
@@ -635,11 +631,11 @@ function parseMetadata (metadata) {
635631
}
636632

637633
// 4. Let algorithm be the hash-algo component of token.
638-
const algorithm = parsedToken.groups.algo
634+
const algorithm = parsedToken.groups.algo.toLowerCase()
639635

640636
// 5. If algorithm is a hash function recognized by the user
641637
// agent, add the parsed token to result.
642-
if (supportedHashes.includes(algorithm.toLowerCase())) {
638+
if (supportedHashes.includes(algorithm)) {
643639
result.push(parsedToken.groups)
644640
}
645641
}
@@ -652,6 +648,82 @@ function parseMetadata (metadata) {
652648
return result
653649
}
654650

651+
/**
652+
* @param {{ algo: 'sha256' | 'sha384' | 'sha512' }[]} metadataList
653+
*/
654+
function getStrongestMetadata (metadataList) {
655+
// Let algorithm be the algo component of the first item in metadataList.
656+
// Can be sha256
657+
let algorithm = metadataList[0].algo
658+
// If the algorithm is sha512, then it is the strongest
659+
// and we can return immediately
660+
if (algorithm[3] === '5') {
661+
return algorithm
662+
}
663+
664+
for (let i = 1; i < metadataList.length; ++i) {
665+
const metadata = metadataList[i]
666+
// If the algorithm is sha512, then it is the strongest
667+
// and we can break the loop immediately
668+
if (metadata.algo[3] === '5') {
669+
algorithm = 'sha512'
670+
break
671+
// If the algorithm is sha384, then a potential sha256 or sha384 is ignored
672+
} else if (algorithm[3] === '3') {
673+
continue
674+
// algorithm is sha256, check if algorithm is sha384 and if so, set it as
675+
// the strongest
676+
} else if (metadata.algo[3] === '3') {
677+
algorithm = 'sha384'
678+
}
679+
}
680+
return algorithm
681+
}
682+
683+
function filterMetadataListByAlgorithm (metadataList, algorithm) {
684+
if (metadataList.length === 1) {
685+
return metadataList
686+
}
687+
688+
let pos = 0
689+
for (let i = 0; i < metadataList.length; ++i) {
690+
if (metadataList[i].algo === algorithm) {
691+
metadataList[pos++] = metadataList[i]
692+
}
693+
}
694+
695+
metadataList.length = pos
696+
697+
return metadataList
698+
}
699+
700+
/**
701+
* Compares two base64 strings, allowing for base64url
702+
* in the second string.
703+
*
704+
* @param {string} actualValue always base64
705+
* @param {string} expectedValue base64 or base64url
706+
* @returns {boolean}
707+
*/
708+
function compareBase64Mixed (actualValue, expectedValue) {
709+
if (actualValue.length !== expectedValue.length) {
710+
return false
711+
}
712+
for (let i = 0; i < actualValue.length; ++i) {
713+
if (actualValue[i] !== expectedValue[i]) {
714+
if (
715+
(actualValue[i] === '+' && expectedValue[i] === '-') ||
716+
(actualValue[i] === '/' && expectedValue[i] === '_')
717+
) {
718+
continue
719+
}
720+
return false
721+
}
722+
}
723+
724+
return true
725+
}
726+
655727
// https://w3c.github.io/webappsec-upgrade-insecure-requests/#upgrade-request
656728
function tryUpgradeRequestToAPotentiallyTrustworthyURL (request) {
657729
// TODO
@@ -1067,5 +1139,6 @@ module.exports = {
10671139
urlHasHttpsScheme,
10681140
urlIsHttpHttpsScheme,
10691141
readAllBytes,
1070-
normalizeMethodRecord
1142+
normalizeMethodRecord,
1143+
parseMetadata
10711144
}

‎test/fetch/integrity.js

+197
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ const { test } = require('tap')
44
const { createServer } = require('http')
55
const { createHash, getHashes } = require('crypto')
66
const { gzipSync } = require('zlib')
7+
/*
8+
=======
9+
const { test, after } = require('node:test')
10+
const { tspl } = require('@matteo.collina/tspl')
11+
const assert = require('node:assert')
12+
const { createServer } = require('node:http')
13+
const { createHash, getHashes } = require('node:crypto')
14+
const { gzipSync } = require('node:zlib')
15+
>>>>>>> d542b8cd (Merge pull request from GHSA-9qxr-qj54-h672)
16+
*/
717
const { fetch, setGlobalDispatcher, Agent } = require('../..')
818
const { once } = require('events')
919

@@ -148,3 +158,190 @@ test('request with sha512 hash', { skip: !supportedHashes.includes('sha512') },
148158
integrity: 'sha512-ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs='
149159
}))
150160
})
161+
162+
test('request with correct integrity checksum (base64url)', (t) => {
163+
t.plan(1)
164+
const body = 'Hello world!'
165+
const hash = createHash('sha256').update(body).digest('base64url')
166+
167+
const server = createServer((req, res) => {
168+
res.end(body)
169+
})
170+
171+
t.teardown(server.close.bind(server))
172+
173+
server.listen(0, async () => {
174+
const response = await fetch(`http://localhost:${server.address().port}`, {
175+
integrity: `sha256-${hash}`
176+
})
177+
t.equal(body, await response.text())
178+
})
179+
})
180+
181+
test('request with incorrect integrity checksum (base64url)', (t) => {
182+
t.plan(1)
183+
184+
const body = 'Hello world!'
185+
const hash = createHash('sha256').update('invalid').digest('base64url')
186+
187+
const server = createServer((req, res) => {
188+
res.end(body)
189+
})
190+
191+
t.teardown(server.close.bind(server))
192+
193+
server.listen(0, async () => {
194+
await t.rejects(fetch(`http://localhost:${server.address().port}`, {
195+
integrity: `sha256-${hash}`
196+
}))
197+
})
198+
})
199+
200+
test('request with incorrect integrity checksum (only dash)', (t) => {
201+
t.plan(1)
202+
203+
const body = 'Hello world!'
204+
205+
const server = createServer((req, res) => {
206+
res.end(body)
207+
})
208+
209+
t.teardown(server.close.bind(server))
210+
211+
server.listen(0, async () => {
212+
await t.rejects(fetch(`http://localhost:${server.address().port}`, {
213+
integrity: 'sha256--'
214+
}))
215+
})
216+
})
217+
218+
test('request with incorrect integrity checksum (non-ascii character)', (t) => {
219+
t.plan(1)
220+
221+
const body = 'Hello world!'
222+
223+
const server = createServer((req, res) => {
224+
res.end(body)
225+
})
226+
227+
t.teardown(server.close.bind(server))
228+
229+
server.listen(0, async () => {
230+
await t.rejects(() => fetch(`http://localhost:${server.address().port}`, {
231+
integrity: 'sha256-ä'
232+
}))
233+
})
234+
})
235+
236+
test('request with incorrect stronger integrity checksum (non-ascii character)', (t) => {
237+
t.plan(2)
238+
239+
const body = 'Hello world!'
240+
const sha256 = createHash('sha256').update(body).digest('base64')
241+
const sha384 = 'ä'
242+
243+
const server = createServer((req, res) => {
244+
res.end(body)
245+
})
246+
247+
t.teardown(server.close.bind(server))
248+
249+
server.listen(0, async () => {
250+
await t.rejects(() => fetch(`http://localhost:${server.address().port}`, {
251+
integrity: `sha256-${sha256} sha384-${sha384}`
252+
}))
253+
await t.rejects(() => fetch(`http://localhost:${server.address().port}`, {
254+
integrity: `sha384-${sha384} sha256-${sha256}`
255+
}))
256+
})
257+
})
258+
259+
test('request with correct integrity checksum (base64). mixed', (t) => {
260+
t.plan(6)
261+
262+
const body = 'Hello world!'
263+
const sha256 = createHash('sha256').update(body).digest('base64')
264+
const sha384 = createHash('sha384').update(body).digest('base64')
265+
const sha512 = createHash('sha512').update(body).digest('base64')
266+
267+
const server = createServer((req, res) => {
268+
res.end(body)
269+
})
270+
271+
t.teardown(server.close.bind(server))
272+
273+
server.listen(0, async () => {
274+
let response
275+
response = await fetch(`http://localhost:${server.address().port}`, {
276+
integrity: `sha256-${sha256} sha512-${sha512}`
277+
})
278+
t.equal(body, await response.text())
279+
response = await fetch(`http://localhost:${server.address().port}`, {
280+
integrity: `sha512-${sha512} sha256-${sha256}`
281+
})
282+
283+
t.equal(body, await response.text())
284+
response = await fetch(`http://localhost:${server.address().port}`, {
285+
integrity: `sha384-${sha384} sha512-${sha512}`
286+
})
287+
t.equal(body, await response.text())
288+
response = await fetch(`http://localhost:${server.address().port}`, {
289+
integrity: `sha384-${sha384} sha512-${sha512}`
290+
})
291+
t.equal(body, await response.text())
292+
293+
response = await fetch(`http://localhost:${server.address().port}`, {
294+
integrity: `sha256-${sha256} sha384-${sha384}`
295+
})
296+
t.equal(body, await response.text())
297+
response = await fetch(`http://localhost:${server.address().port}`, {
298+
integrity: `sha384-${sha384} sha256-${sha256}`
299+
})
300+
t.equal(body, await response.text())
301+
})
302+
})
303+
304+
test('request with correct integrity checksum (base64url). mixed', (t) => {
305+
t.plan(6)
306+
307+
const body = 'Hello world!'
308+
const sha256 = createHash('sha256').update(body).digest('base64url')
309+
const sha384 = createHash('sha384').update(body).digest('base64url')
310+
const sha512 = createHash('sha512').update(body).digest('base64url')
311+
312+
const server = createServer((req, res) => {
313+
res.end(body)
314+
})
315+
316+
t.teardown(server.close.bind(server))
317+
318+
server.listen(0, async () => {
319+
let response
320+
response = await fetch(`http://localhost:${server.address().port}`, {
321+
integrity: `sha256-${sha256} sha512-${sha512}`
322+
})
323+
t.equal(body, await response.text())
324+
response = await fetch(`http://localhost:${server.address().port}`, {
325+
integrity: `sha512-${sha512} sha256-${sha256}`
326+
})
327+
328+
t.equal(body, await response.text())
329+
response = await fetch(`http://localhost:${server.address().port}`, {
330+
integrity: `sha384-${sha384} sha512-${sha512}`
331+
})
332+
t.equal(body, await response.text())
333+
response = await fetch(`http://localhost:${server.address().port}`, {
334+
integrity: `sha384-${sha384} sha512-${sha512}`
335+
})
336+
t.equal(body, await response.text())
337+
338+
response = await fetch(`http://localhost:${server.address().port}`, {
339+
integrity: `sha256-${sha256} sha384-${sha384}`
340+
})
341+
t.equal(body, await response.text())
342+
response = await fetch(`http://localhost:${server.address().port}`, {
343+
integrity: `sha384-${sha384} sha256-${sha256}`
344+
})
345+
t.equal(body, await response.text())
346+
})
347+
})

‎test/fetch/util.js

+73
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const { test } = t
55

66
const util = require('../../lib/fetch/util')
77
const { HeadersList } = require('../../lib/fetch/headers')
8+
const { createHash } = require('crypto')
89

910
test('responseURL', (t) => {
1011
t.plan(2)
@@ -279,3 +280,75 @@ test('setRequestReferrerPolicyOnRedirect', nested => {
279280
t.equal(request.referrerPolicy, initial)
280281
})
281282
})
283+
284+
test('parseMetadata', (t) => {
285+
t.test('should parse valid metadata with option', (t) => {
286+
const body = 'Hello world!'
287+
const hash256 = createHash('sha256').update(body).digest('base64')
288+
const hash384 = createHash('sha384').update(body).digest('base64')
289+
const hash512 = createHash('sha512').update(body).digest('base64')
290+
291+
const validMetadata = `sha256-${hash256} !@ sha384-${hash384} !@ sha512-${hash512} !@`
292+
const result = util.parseMetadata(validMetadata)
293+
294+
t.same(result, [
295+
{ algo: 'sha256', hash: hash256.replace(/=/g, '') },
296+
{ algo: 'sha384', hash: hash384.replace(/=/g, '') },
297+
{ algo: 'sha512', hash: hash512.replace(/=/g, '') }
298+
])
299+
t.end()
300+
})
301+
302+
t.test('should parse valid metadata with non ASCII chars option', (t) => {
303+
const body = 'Hello world!'
304+
const hash256 = createHash('sha256').update(body).digest('base64')
305+
const hash384 = createHash('sha384').update(body).digest('base64')
306+
const hash512 = createHash('sha512').update(body).digest('base64')
307+
308+
const validMetadata = `sha256-${hash256} !© sha384-${hash384} !€ sha512-${hash512} !µ`
309+
const result = util.parseMetadata(validMetadata)
310+
311+
t.same(result, [
312+
{ algo: 'sha256', hash: hash256.replace(/=/g, '') },
313+
{ algo: 'sha384', hash: hash384.replace(/=/g, '') },
314+
{ algo: 'sha512', hash: hash512.replace(/=/g, '') }
315+
])
316+
t.end()
317+
})
318+
319+
t.test('should parse valid metadata without option', (t) => {
320+
const body = 'Hello world!'
321+
const hash256 = createHash('sha256').update(body).digest('base64')
322+
const hash384 = createHash('sha384').update(body).digest('base64')
323+
const hash512 = createHash('sha512').update(body).digest('base64')
324+
325+
const validMetadata = `sha256-${hash256} sha384-${hash384} sha512-${hash512}`
326+
const result = util.parseMetadata(validMetadata)
327+
328+
t.same(result, [
329+
{ algo: 'sha256', hash: hash256.replace(/=/g, '') },
330+
{ algo: 'sha384', hash: hash384.replace(/=/g, '') },
331+
{ algo: 'sha512', hash: hash512.replace(/=/g, '') }
332+
])
333+
t.end()
334+
})
335+
336+
t.test('should set hash as undefined when invalid base64 chars are provided', (t) => {
337+
const body = 'Hello world!'
338+
const hash256 = createHash('sha256').update(body).digest('base64')
339+
const invalidHash384 = 'zifp5hE1Xl5LQQqQz[]Bq/iaq9Wb6jVb//T7EfTmbXD2aEP5c2ZdJr9YTDfcTE1ZH+'
340+
const hash512 = createHash('sha512').update(body).digest('base64')
341+
342+
const validMetadata = `sha256-${hash256} sha384-${invalidHash384} sha512-${hash512}`
343+
const result = util.parseMetadata(validMetadata)
344+
345+
t.same(result, [
346+
{ algo: 'sha256', hash: hash256.replace(/=/g, '') },
347+
{ algo: 'sha384', hash: undefined },
348+
{ algo: 'sha512', hash: hash512.replace(/=/g, '') }
349+
])
350+
t.end()
351+
})
352+
353+
t.end()
354+
})

0 commit comments

Comments
 (0)
Please sign in to comment.