CVE-2024-30261: Insufficient Information (NVD-CWE-noinfo)
value: Undici is an HTTP/1.1 client, written from scratch for Node.js. An attacker can alter the `integrity` option passed to `fetch()`, allowing `fetch()` to accept requests as valid even if they have been tampered. This vulnerability was patched in version(s) 5.28.4 and 6.11.1.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-04-04T15:15Z
Repository: https://github.com/nodejs/undici
Language: JavaScript
Files changed: benchmarks/fetch/bytes-match.mjs, lib/web/fetch/util.js, test/fetch/integrity.js, test/fetch/util.js
Commit date: 2024-04-02 12:43:48 +0200
Commit message: Merge pull request from GHSA-9qxr-qj54-h672

Co-authored-by: uzlopak <aras.abbasi@googlemail.com>
Diff: diff --git a/benchmarks/fetch/bytes-match.mjs b/benchmarks/fetch/bytes-match.mjs
new file mode 100644
index 000000000..6c6b26349
--- /dev/null
+++ b/benchmarks/fetch/bytes-match.mjs
@@ -0,0 +1,24 @@
+import { createHash } from 'node:crypto'
+import { bench, run } from 'mitata'
+import { bytesMatch } from '../../lib/web/fetch/util.js'
+
+const body = Buffer.from('Hello world!')
+const validSha256Base64 = `sha256-${createHash('sha256').update(body).digest('base64')}`
+const invalidSha256Base64 = `sha256-${createHash('sha256').update(body).digest('base64')}`
+const validSha256Base64Url = `sha256-${createHash('sha256').update(body).digest('base64url')}`
+const invalidSha256Base64Url = `sha256-${createHash('sha256').update(body).digest('base64url')}`
+
+bench('bytesMatch valid sha256 and base64', () => {
+  bytesMatch(body, validSha256Base64)
+})
+bench('bytesMatch invalid sha256 and base64', () => {
+  bytesMatch(body, invalidSha256Base64)
+})
+bench('bytesMatch valid sha256 and base64url', () => {
+  bytesMatch(body, validSha256Base64Url)
+})
+bench('bytesMatch invalid sha256 and base64url', () => {
+  bytesMatch(body, invalidSha256Base64Url)
+})
+
+await run()
diff --git a/lib/web/fetch/util.js b/lib/web/fetch/util.js
index a1ef3f47a..824413bc8 100644
--- a/lib/web/fetch/util.js
+++ b/lib/web/fetch/util.js
@@ -9,15 +9,19 @@ const { performance } = require('node:perf_hooks')
 const { isBlobLike, ReadableStreamFrom, isValidHTTPToken } = require('../../core/util')
 const assert = require('node:assert')
 const { isUint8Array } = require('node:util/types')
 const { webidl } = require('./webidl')
 
+let supportedHashes = []
+
 // https://nodejs.org/api/crypto.html#determining-if-crypto-support-is-unavailable
 /** @type {import('crypto')} */
 let crypto
 try {
   crypto = require('node:crypto')
+  const possibleRelevantHashes = ['sha256', 'sha384', 'sha512']
+  supportedHashes = crypto.getHashes().filter((hash) => possibleRelevantHashes.includes(hash))
 /* c8 ignore next 3 */
 } catch {
 
 }
 
@@ -563,70 +567,60 @@ function bytesMatch (bytes, metadataList) {
   // 2. If parsedMetadata is no metadata, return true.
   if (parsedMetadata === 'no metadata') {
     return true
   }
 
-  // 3. If parsedMetadata is the empty set, return true.
+  // 3. If response is not eligible for integrity validation, return false.
+  // TODO
+
+  // 4. If parsedMetadata is the empty set, return true.
   if (parsedMetadata.length === 0) {
     return true
   }
 
-  // 4. Let metadata be the result of getting the strongest
+  // 5. Let metadata be the result of getting the strongest
   //    metadata from parsedMetadata.
-  const list = parsedMetadata.sort((c, d) => d.algo.localeCompare(c.algo))
-  // get the strongest algorithm
-  const strongest = list[0].algo
-  // get all entries that use the strongest algorithm; ignore weaker
-  const metadata = list.filter((item) => item.algo === strongest)
+  const strongest = getStrongestMetadata(parsedMetadata)
+  const metadata = filterMetadataListByAlgorithm(parsedMetadata, strongest)
 
-  // 5. For each item in metadata:
+  // 6. For each item in metadata:
   for (const item of metadata) {
     // 1. Let algorithm be the alg component of item.
     const algorithm = item.algo
 
     // 2. Let expectedValue be the val component of item.
-    let expectedValue = item.hash
+    const expectedValue = item.hash
 
     // See https://github.com/web-platform-tests/wpt/commit/e4c5cc7a5e48093220528dfdd1c4012dc3837a0e
     // "be liberal with padding". This is annoying, and it's not even in the spec.
 
-    if (expectedValue.endsWith('==')) {
-      expectedValue = expectedValue.slice(0, -2)
-    }
-
     // 3. Let actualValue be the result of applying algorithm to bytes.
     let actualValue = crypto.createHash(algorithm).update(bytes).digest('base64')
 
-    if (actualValue.endsWith('==')) {
-      actualValue = actualValue.slice(0, -2)
+    if (actualValue[actualValue.length - 1] === '=') {
+      if (actualValue[actualValue.length - 2] === '=') {
+        actualValue = actualValue.slice(0, -2)
+      } else {
+        actualValue = actualValue.slice(0, -1)
+      }
     }
 
     // 4. If actualValue is a case-sensitive match for expectedValue,
     //    return true.
-    if (actualValue === expectedValue) {
-      return true
-    }
-
-    let actualBase64URL = crypto.createHash(algorithm).update(bytes).digest('base64url')
-
-    if (actualBase64URL.endsWith('==')) {
-      actualBase64URL = actualBase64URL.slice(0, -2)
-    }
-
-    if (actualBase64URL === expectedValue) {
+    if (compareBase64Mixed(actualValue, expectedValue)) {
       return true
     }
   }
 
-  // 6. Return false.
+  // 7. Return false.
   return false
 }
 
 // https://w3c.github.io/webappsec-subresource-integrity/#grammardef-hash-with-options
 // https://www.w3.org/TR/CSP2/#source-list-syntax
 // https://www.rfc-editor.org/rfc/rfc5234#appendix-B.1
-const parseHashWithOptions = /(?<algo>sha256|sha384|sha512)-(?<hash>[A-Za-z0-9+/]+={0,2}(?=\s|$))( +[!-~]*)?/i
+const parseHashWithOptions = /(?<algo>sha256|sha384|sha512)-((?<hash>[A-Za-z0-9+/]+|[A-Za-z0-9_-]+)={0,2}(?:\s|$)( +[!-~]*)?)?/i
 
 /**
  * @see https://w3c.github.io/webappsec-subresource-integrity/#parse-metadata
  * @param {string} metadata
  */
@@ -636,35 +630,37 @@ function parseMetadata (metadata) {
   const result = []
 
   // 2. Let empty be equal to true.
   let empty = true
 
-  const supportedHashes = crypto.getHashes()
-
   // 3. For each token returned by splitting metadata on spaces:
   for (const token of metadata.split(' ')) {
     // 1. Set empty to false.
     empty = false
 
     // 2. Parse token as a hash-with-options.
     const parsedToken = parseHashWithOptions.exec(token)
 
     // 3. If token does not parse, continue to the next token.
-    if (parsedToken === null || parsedToken.groups === undefined) {
+    if (
+      parsedToken === null ||
+      parsedToken.groups === undefined ||
+      parsedToken.groups.algo === undefined
+    ) {
       // Note: Chromium blocks the request at this point, but Firefox
       // gives a warning that an invalid integrity was given. The
       // correct behavior is to ignore these, and subsequently not
       // check the integrity of the resource.
       continue
     }
 
     // 4. Let algorithm be the hash-algo component of token.
-    const algorithm = parsedToken.groups.algo
+    const algorithm = parsedToken.groups.algo.toLowerCase()
 
     // 5. If algorithm is a hash function recognized by the user
     //    agent, add the parsed token to result.
-    if (supportedHashes.includes(algorithm.toLowerCase())) {
+    if (supportedHashes.includes(algorithm)) {
       result.push(parsedToken.groups)
     }
   }
 
   // 4. Return no metadata if empty is true, otherwise return result.
@@ -673,10 +669,86 @@ function parseMetadata (metadata) {
   }
 
   return result
 }
 
+/**
+ * @param {{ algo: 'sha256' | 'sha384' | 'sha512' }[]} metadataList
+ */
+function getStrongestMetadata (metadataList) {
+  // Let algorithm be the algo component of the first item in metadataList.
+  // Can be sha256
+  let algorithm = metadataList[0].algo
+  // If the algorithm is sha512, then it is the strongest
+  // and we can return immediately
+  if (algorithm[3] === '5') {
+    return algorithm
+  }
+
+  for (let i = 1; i < metadataList.length; ++i) {
+    const metadata = metadataList[i]
+    // If the algorithm is sha512, then it is the strongest
+    // and we can break the loop immediately
+    if (metadata.algo[3] === '5') {
+      algorithm = 'sha512'
+      break
+    // If the algorithm is sha384, then a potential sha256 or sha384 is ignored
+    } else if (algorithm[3] === '3') {
+      continue
+    // algorithm is sha256, check if algorithm is sha384 and if so, set it as
+    // the strongest
+    } else if (metadata.algo[3] === '3') {
+      algorithm = 'sha384'
+    }
+  }
+  return algorithm
+}
+
+function filterMetadataListByAlgorithm (metadataList, algorithm) {
+  if (metadataList.length === 1) {
+    return metadataList
+  }
+
+  let pos = 0
+  for (let i = 0; i < metadataList.length; ++i) {
+    if (metadataList[i].algo === algorithm) {
+      metadataList[pos++] = metadataList[i]
+    }
+  }
+
+  metadataList.length = pos
+
+  return metadataList
+}
+
+/**
+ * Compares two base64 strings, allowing for base64url
+ * in the second string.
+ *
+* @param {string} actualValue always base64
+ * @param {string} expectedValue base64 or base64url
+ * @returns {boolean}
+ */
+function compareBase64Mixed (actualValue, expectedValue) {
+  if (actualValue.length !== expectedValue.length) {
+    return false
+  }
+  for (let i = 0; i < actualValue.length; ++i) {
+    if (actualValue[i] !== expectedValue[i]) {
+      if (
+        (actualValue[i] === '+' && expectedValue[i] === '-') ||
+        (actualValue[i] === '/' && expectedValue[i] === '_')
+      ) {
+        continue
+      }
+      return false
+    }
+  }
+
+  return true
+}
+
 // https://w3c.github.io/webappsec-upgrade-insecure-requests/#upgrade-request
 function tryUpgradeRequestToAPotentiallyTrustworthyURL (request) {
   // TODO
 }
 
diff --git a/test/fetch/integrity.js b/test/fetch/integrity.js
index f3bc27e4d..b88780175 100644
--- a/test/fetch/integrity.js
+++ b/test/fetch/integrity.js
@@ -1,8 +1,9 @@
 'use strict'
 
-const { test } = require('node:test')
+const { test, after } = require('node:test')
+const { tspl } = require('@matteo.collina/tspl')
 const assert = require('node:assert')
 const { createServer } = require('node:http')
 const { createHash, getHashes } = require('node:crypto')
 const { gzipSync } = require('node:zlib')
 const { fetch, setGlobalDispatcher, Agent } = require('../..')
@@ -145,5 +146,204 @@ test('request with sha512 hash', { skip: !supportedHashes.includes('sha512') },
   // request should fail
   await assert.rejects(fetch(`http://localhost:${server.address().port}`, {
     integrity: 'sha512-ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs='
   }))
 })
+
+test('request with correct integrity checksum (base64url)', async (t) => {
+  t = tspl(t, { plan: 1 })
+  const body = 'Hello world!'
+  const hash = createHash('sha256').update(body).digest('base64url')
+
+  const server = createServer((req, res) => {
+    res.end(body)
+  })
+
+  after(closeServerAsPromise(server))
+
+  server.listen(0, async () => {
+    const response = await fetch(`http://localhost:${server.address().port}`, {
+      integrity: `sha256-${hash}`
+    })
+    t.strictEqual(body, await response.text())
+  })
+
+  await t.completed
+})
+
+test('request with incorrect integrity checksum (base64url)', async (t) => {
+  t = tspl(t, { plan: 1 })
+
+  const body = 'Hello world!'
+  const hash = createHash('sha256').update('invalid').digest('base64url')
+
+  const server = createServer((req, res) => {
+    res.end(body)
+  })
+
+  after(closeServerAsPromise(server))
+
+  server.listen(0, async () => {
+    await t.rejects(fetch(`http://localhost:${server.address().port}`, {
+      integrity: `sha256-${hash}`
+    }))
+  })
+
+  await t.completed
+})
+
+test('request with incorrect integrity checksum (only dash)', async (t) => {
+  t = tspl(t, { plan: 1 })
+
+  const body = 'Hello world!'
+
+  const server = createServer((req, res) => {
+    res.end(body)
+  })
+
+  after(closeServerAsPromise(server))
+
+  server.listen(0, async () => {
+    await t.rejects(fetch(`http://localhost:${server.address().port}`, {
+      integrity: 'sha256--'
+    }))
+  })
+
+  await t.completed
+})
+
+test('request with incorrect integrity checksum (non-ascii character)', async (t) => {
+  t = tspl(t, { plan: 1 })
+
+  const body = 'Hello world!'
+
+  const server = createServer((req, res) => {
+    res.end(body)
+  })
+
+  after(closeServerAsPromise(server))
+
+  server.listen(0, async () => {
+    await t.rejects(() => fetch(`http://localhost:${server.address().port}`, {
+      integrity: 'sha256-ä'
+    }))
+  })
+
+  await t.completed
+})
+
+test('request with incorrect stronger integrity checksum (non-ascii character)', async (t) => {
+  t = tspl(t, { plan: 2 })
+
+  const body = 'Hello world!'
+  const sha256 = createHash('sha256').update(body).digest('base64')
+  const sha384 = 'ä'
+
+  const server = createServer((req, res) => {
+    res.end(body)
+  })
+
+  after(closeServerAsPromise(server))
+
+  server.listen(0, async () => {
+    await t.rejects(() => fetch(`http://localhost:${server.address().port}`, {
+      integrity: `sha256-${sha256} sha384-${sha384}`
+    }))
+    await t.rejects(() => fetch(`http://localhost:${server.address().port}`, {
+      integrity: `sha384-${sha384} sha256-${sha256}`
+    }))
+  })
+
+  await t.completed
+})
+
+test('request with correct integrity checksum (base64). mixed', async (t) => {
+  t = tspl(t, { plan: 6 })
+  const body = 'Hello world!'
+  const sha256 = createHash('sha256').update(body).digest('base64')
+  const sha384 = createHash('sha384').update(body).digest('base64')
+  const sha512 = createHash('sha512').update(body).digest('base64')
+
+  const server = createServer((req, res) => {
+    res.end(body)
+  })
+
+  after(closeServerAsPromise(server))
+
+  server.listen(0, async () => {
+    let response
+    response = await fetch(`http://localhost:${server.address().port}`, {
+      integrity: `sha256-${sha256} sha512-${sha512}`
+    })
+    t.strictEqual(body, await response.text())
+    response = await fetch(`http://localhost:${server.address().port}`, {
+      integrity: `sha512-${sha512} sha256-${sha256}`
+    })
+
+    t.strictEqual(body, await response.text())
+    response = await fetch(`http://localhost:${server.address().port}`, {
+      integrity: `sha384-${sha384} sha512-${sha512}`
+    })
+    t.strictEqual(body, await response.text())
+    response = await fetch(`http://localhost:${server.address().port}`, {
+      integrity: `sha384-${sha384} sha512-${sha512}`
+    })
+    t.strictEqual(body, await response.text())
+
+    response = await fetch(`http://localhost:${server.address().port}`, {
+      integrity: `sha256-${sha256} sha384-${sha384}`
+    })
+    t.strictEqual(body, await response.text())
+    response = await fetch(`http://localhost:${server.address().port}`, {
+      integrity: `sha384-${sha384} sha256-${sha256}`
+    })
+    t.strictEqual(body, await response.text())
+  })
+
+  await t.completed
+})
+
+test('request with correct integrity checksum (base64url). mixed', async (t) => {
+  t = tspl(t, { plan: 6 })
+  const body = 'Hello world!'
+  const sha256 = createHash('sha256').update(body).digest('base64url')
+  const sha384 = createHash('sha384').update(body).digest('base64url')
+  const sha512 = createHash('sha512').update(body).digest('base64url')
+
+  const server = createServer((req, res) => {
+    res.end(body)
+  })
+
+  after(closeServerAsPromise(server))
+
+  server.listen(0, async () => {
+    let response
+    response = await fetch(`http://localhost:${server.address().port}`, {
+      integrity: `sha256-${sha256} sha512-${sha512}`
+    })
+    t.strictEqual(body, await response.text())
+    response = await fetch(`http://localhost:${server.address().port}`, {
+      integrity: `sha512-${sha512} sha256-${sha256}`
+    })
+
+    t.strictEqual(body, await response.text())
+    response = await fetch(`http://localhost:${server.address().port}`, {
+      integrity: `sha384-${sha384} sha512-${sha512}`
+    })
+    t.strictEqual(body, await response.text())
+    response = await fetch(`http://localhost:${server.address().port}`, {
+      integrity: `sha384-${sha384} sha512-${sha512}`
+    })
+    t.strictEqual(body, await response.text())
+
+    response = await fetch(`http://localhost:${server.address().port}`, {
+      integrity: `sha256-${sha256} sha384-${sha384}`
+    })
+    t.strictEqual(body, await response.text())
+    response = await fetch(`http://localhost:${server.address().port}`, {
+      integrity: `sha384-${sha384} sha256-${sha256}`
+    })
+    t.strictEqual(body, await response.text())
+  })
+
+  await t.completed
+})
diff --git a/test/fetch/util.js b/test/fetch/util.js
index a5c33d649..7e3c90c1f 100644
--- a/test/fetch/util.js
+++ b/test/fetch/util.js
@@ -282,13 +282,13 @@ test('parseMetadata', async (t) => {
 
     const validMetadata = `sha256-${hash256} !@ sha384-${hash384} !@ sha512-${hash512} !@`
     const result = util.parseMetadata(validMetadata)
 
     assert.deepEqual(result, [
-      { algo: 'sha256', hash: hash256 },
-      { algo: 'sha384', hash: hash384 },
-      { algo: 'sha512', hash: hash512 }
+      { algo: 'sha256', hash: hash256.replace(/=/g, '') },
+      { algo: 'sha384', hash: hash384.replace(/=/g, '') },
+      { algo: 'sha512', hash: hash512.replace(/=/g, '') }
     ])
   })
 
   await t.test('should parse valid metadata with non ASCII chars option', () => {
     const body = 'Hello world!'
@@ -298,13 +298,13 @@ test('parseMetadata', async (t) => {
 
     const validMetadata = `sha256-${hash256} !© sha384-${hash384} !€ sha512-${hash512} !µ`
     const result = util.parseMetadata(validMetadata)
 
     assert.deepEqual(result, [
-      { algo: 'sha256', hash: hash256 },
-      { algo: 'sha384', hash: hash384 },
-      { algo: 'sha512', hash: hash512 }
+      { algo: 'sha256', hash: hash256.replace(/=/g, '') },
+      { algo: 'sha384', hash: hash384.replace(/=/g, '') },
+      { algo: 'sha512', hash: hash512.replace(/=/g, '') }
     ])
   })
 
   await t.test('should parse valid metadata without option', () => {
     const body = 'Hello world!'
@@ -314,26 +314,27 @@ test('parseMetadata', async (t) => {
 
     const validMetadata = `sha256-${hash256} sha384-${hash384} sha512-${hash512}`
     const result = util.parseMetadata(validMetadata)
 
     assert.deepEqual(result, [
-      { algo: 'sha256', hash: hash256 },
-      { algo: 'sha384', hash: hash384 },
-      { algo: 'sha512', hash: hash512 }
+      { algo: 'sha256', hash: hash256.replace(/=/g, '') },
+      { algo: 'sha384', hash: hash384.replace(/=/g, '') },
+      { algo: 'sha512', hash: hash512.replace(/=/g, '') }
     ])
   })
 
-  await t.test('should ignore invalid metadata with invalid base64 chars', () => {
+  await t.test('should set hash as undefined when invalid base64 chars are provided', () => {
     const body = 'Hello world!'
     const hash256 = createHash('sha256').update(body).digest('base64')
     const invalidHash384 = 'zifp5hE1Xl5LQQqQz[]Bq/iaq9Wb6jVb//T7EfTmbXD2aEP5c2ZdJr9YTDfcTE1ZH+'
     const hash512 = createHash('sha512').update(body).digest('base64')
 
     const validMetadata = `sha256-${hash256} sha384-${invalidHash384} sha512-${hash512}`
     const result = util.parseMetadata(validMetadata)
 
     assert.deepEqual(result, [
-      { algo: 'sha256', hash: hash256 },
-      { algo: 'sha512', hash: hash512 }
+      { algo: 'sha256', hash: hash256.replace(/=/g, '') },
+      { algo: 'sha384', hash: undefined },
+      { algo: 'sha512', hash: hash512.replace(/=/g, '') }
     ])
   })
 })

Vulnerable code:   // 3. If parsedMetadata is the empty set, return true.
  // 4. Let metadata be the result of getting the strongest
  const list = parsedMetadata.sort((c, d) => d.algo.localeCompare(c.algo))
  // get the strongest algorithm
  const strongest = list[0].algo
  // get all entries that use the strongest algorithm; ignore weaker
  const metadata = list.filter((item) => item.algo === strongest)
  // 5. For each item in metadata:
    let expectedValue = item.hash
    if (expectedValue.endsWith('==')) {
      expectedValue = expectedValue.slice(0, -2)
    }

    if (actualValue.endsWith('==')) {
      actualValue = actualValue.slice(0, -2)
    if (actualValue === expectedValue) {
      return true
    }

    let actualBase64URL = crypto.createHash(algorithm).update(bytes).digest('base64url')

    if (actualBase64URL.endsWith('==')) {
      actualBase64URL = actualBase64URL.slice(0, -2)
    }

    if (actualBase64URL === expectedValue) {
  // 6. Return false.
const parseHashWithOptions = /(?<algo>sha256|sha384|sha512)-(?<hash>[A-Za-z0-9+/]+={0,2}(?=\s|$))( +[!-~]*)?/i
  const supportedHashes = crypto.getHashes()

    if (parsedToken === null || parsedToken.groups === undefined) {
    const algorithm = parsedToken.groups.algo
    if (supportedHashes.includes(algorithm.toLowerCase())) {
const { test } = require('node:test')
      { algo: 'sha256', hash: hash256 },
      { algo: 'sha384', hash: hash384 },
      { algo: 'sha512', hash: hash512 }
      { algo: 'sha256', hash: hash256 },
      { algo: 'sha384', hash: hash384 },
      { algo: 'sha512', hash: hash512 }
      { algo: 'sha256', hash: hash256 },
      { algo: 'sha384', hash: hash384 },
      { algo: 'sha512', hash: hash512 }
  await t.test('should ignore invalid metadata with invalid base64 chars', () => {
      { algo: 'sha256', hash: hash256 },
      { algo: 'sha512', hash: hash512 }
Fixed code: import { createHash } from 'node:crypto'
import { bench, run } from 'mitata'
import { bytesMatch } from '../../lib/web/fetch/util.js'

const body = Buffer.from('Hello world!')
const validSha256Base64 = `sha256-${createHash('sha256').update(body).digest('base64')}`
const invalidSha256Base64 = `sha256-${createHash('sha256').update(body).digest('base64')}`
const validSha256Base64Url = `sha256-${createHash('sha256').update(body).digest('base64url')}`
const invalidSha256Base64Url = `sha256-${createHash('sha256').update(body).digest('base64url')}`

bench('bytesMatch valid sha256 and base64', () => {
  bytesMatch(body, validSha256Base64)
})
bench('bytesMatch invalid sha256 and base64', () => {
  bytesMatch(body, invalidSha256Base64)
})
bench('bytesMatch valid sha256 and base64url', () => {
  bytesMatch(body, validSha256Base64Url)
})
bench('bytesMatch invalid sha256 and base64url', () => {
  bytesMatch(body, invalidSha256Base64Url)
})

await run()
let supportedHashes = []

  const possibleRelevantHashes = ['sha256', 'sha384', 'sha512']
  supportedHashes = crypto.getHashes().filter((hash) => possibleRelevantHashes.includes(hash))
  // 3. If response is not eligible for integrity validation, return false.
  // TODO

  // 4. If parsedMetadata is the empty set, return true.
  // 5. Let metadata be the result of getting the strongest
  const strongest = getStrongestMetadata(parsedMetadata)
  const metadata = filterMetadataListByAlgorithm(parsedMetadata, strongest)
  // 6. For each item in metadata:
    const expectedValue = item.hash
    if (actualValue[actualValue.length - 1] === '=') {
      if (actualValue[actualValue.length - 2] === '=') {
        actualValue = actualValue.slice(0, -2)
      } else {
        actualValue = actualValue.slice(0, -1)
      }
    if (compareBase64Mixed(actualValue, expectedValue)) {
  // 7. Return false.
const parseHashWithOptions = /(?<algo>sha256|sha384|sha512)-((?<hash>[A-Za-z0-9+/]+|[A-Za-z0-9_-]+)={0,2}(?:\s|$)( +[!-~]*)?)?/i
    if (
      parsedToken === null ||
      parsedToken.groups === undefined ||
      parsedToken.groups.algo === undefined
    ) {
    const algorithm = parsedToken.groups.algo.toLowerCase()
    if (supportedHashes.includes(algorithm)) {
/**
 * @param {{ algo: 'sha256' | 'sha384' | 'sha512' }[]} metadataList
 */
function getStrongestMetadata (metadataList) {
  // Let algorithm be the algo component of the first item in metadataList.
  // Can be sha256
  let algorithm = metadataList[0].algo
  // If the algorithm is sha512, then it is the strongest
  // and we can return immediately
  if (algorithm[3] === '5') {
    return algorithm
  }

  for (let i = 1; i < metadataList.length; ++i) {
    const metadata = metadataList[i]
    // If the algorithm is sha512, then it is the strongest
    // and we can break the loop immediately
    if (metadata.algo[3] === '5') {
      algorithm = 'sha512'
      break
    // If the algorithm is sha384, then a potential sha256 or sha384 is ignored
    } else if (algorithm[3] === '3') {
      continue
    // algorithm is sha256, check if algorithm is sha384 and if so, set it as
    // the strongest
    } else if (metadata.algo[3] === '3') {
      algorithm = 'sha384'
    }
  }
  return algorithm
}

function filterMetadataListByAlgorithm (metadataList, algorithm) {
  if (metadataList.length === 1) {
    return metadataList
  }

  let pos = 0
  for (let i = 0; i < metadataList.length; ++i) {
    if (metadataList[i].algo === algorithm) {
      metadataList[pos++] = metadataList[i]
    }
  }

  metadataList.length = pos

  return metadataList
}

/**
 * Compares two base64 strings, allowing for base64url
 * in the second string.
 *
* @param {string} actualValue always base64
 * @param {string} expectedValue base64 or base64url
 * @returns {boolean}
 */
function compareBase64Mixed (actualValue, expectedValue) {
  if (actualValue.length !== expectedValue.length) {
    return false
  }
  for (let i = 0; i < actualValue.length; ++i) {
    if (actualValue[i] !== expectedValue[i]) {
      if (
        (actualValue[i] === '+' && expectedValue[i] === '-') ||
        (actualValue[i] === '/' && expectedValue[i] === '_')
      ) {
        continue
      }
      return false
    }
  }

  return true
}

const { test, after } = require('node:test')
const { tspl } = require('@matteo.collina/tspl')

test('request with correct integrity checksum (base64url)', async (t) => {
  t = tspl(t, { plan: 1 })
  const body = 'Hello world!'
  const hash = createHash('sha256').update(body).digest('base64url')

  const server = createServer((req, res) => {
    res.end(body)
  })

  after(closeServerAsPromise(server))

  server.listen(0, async () => {
    const response = await fetch(`http://localhost:${server.address().port}`, {
      integrity: `sha256-${hash}`
    })
    t.strictEqual(body, await response.text())
  })

  await t.completed
})

test('request with incorrect integrity checksum (base64url)', async (t) => {
  t = tspl(t, { plan: 1 })

  const body = 'Hello world!'
  const hash = createHash('sha256').update('invalid').digest('base64url')

  const server = createServer((req, res) => {
    res.end(body)
  })

  after(closeServerAsPromise(server))

  server.listen(0, async () => {
    await t.rejects(fetch(`http://localhost:${server.address().port}`, {
      integrity: `sha256-${hash}`
    }))
  })

  await t.completed
})

test('request with incorrect integrity checksum (only dash)', async (t) => {
  t = tspl(t, { plan: 1 })

  const body = 'Hello world!'

  const server = createServer((req, res) => {
    res.end(body)
  })

  after(closeServerAsPromise(server))

  server.listen(0, async () => {
    await t.rejects(fetch(`http://localhost:${server.address().port}`, {
      integrity: 'sha256--'
    }))
  })

  await t.completed
})

test('request with incorrect integrity checksum (non-ascii character)', async (t) => {
  t = tspl(t, { plan: 1 })

  const body = 'Hello world!'

  const server = createServer((req, res) => {
    res.end(body)
  })

  after(closeServerAsPromise(server))

  server.listen(0, async () => {
    await t.rejects(() => fetch(`http://localhost:${server.address().port}`, {
      integrity: 'sha256-ä'
    }))
  })

  await t.completed
})

test('request with incorrect stronger integrity checksum (non-ascii character)', async (t) => {
  t = tspl(t, { plan: 2 })

  const body = 'Hello world!'
  const sha256 = createHash('sha256').update(body).digest('base64')
  const sha384 = 'ä'

  const server = createServer((req, res) => {
    res.end(body)
  })

  after(closeServerAsPromise(server))

  server.listen(0, async () => {
    await t.rejects(() => fetch(`http://localhost:${server.address().port}`, {
      integrity: `sha256-${sha256} sha384-${sha384}`
    }))
    await t.rejects(() => fetch(`http://localhost:${server.address().port}`, {
      integrity: `sha384-${sha384} sha256-${sha256}`
    }))
  })

  await t.completed
})

test('request with correct integrity checksum (base64). mixed', async (t) => {
  t = tspl(t, { plan: 6 })
  const body = 'Hello world!'
  const sha256 = createHash('sha256').update(body).digest('base64')
  const sha384 = createHash('sha384').update(body).digest('base64')
  const sha512 = createHash('sha512').update(body).digest('base64')

  const server = createServer((req, res) => {
    res.end(body)
  })

  after(closeServerAsPromise(server))

  server.listen(0, async () => {
    let response
    response = await fetch(`http://localhost:${server.address().port}`, {
      integrity: `sha256-${sha256} sha512-${sha512}`
    })
    t.strictEqual(body, await response.text())
    response = await fetch(`http://localhost:${server.address().port}`, {
      integrity: `sha512-${sha512} sha256-${sha256}`
    })

    t.strictEqual(body, await response.text())
    response = await fetch(`http://localhost:${server.address().port}`, {
      integrity: `sha384-${sha384} sha512-${sha512}`
    })
    t.strictEqual(body, await response.text())
    response = await fetch(`http://localhost:${server.address().port}`, {
      integrity: `sha384-${sha384} sha512-${sha512}`
    })
    t.strictEqual(body, await response.text())

    response = await fetch(`http://localhost:${server.address().port}`, {
      integrity: `sha256-${sha256} sha384-${sha384}`
    })
    t.strictEqual(body, await response.text())
    response = await fetch(`http://localhost:${server.address().port}`, {
      integrity: `sha384-${sha384} sha256-${sha256}`
    })
    t.strictEqual(body, await response.text())
  })

  await t.completed
})

test('request with correct integrity checksum (base64url). mixed', async (t) => {
  t = tspl(t, { plan: 6 })
  const body = 'Hello world!'
  const sha256 = createHash('sha256').update(body).digest('base64url')
  const sha384 = createHash('sha384').update(body).digest('base64url')
  const sha512 = createHash('sha512').update(body).digest('base64url')

  const server = createServer((req, res) => {
    res.end(body)
  })

  after(closeServerAsPromise(server))

  server.listen(0, async () => {
    let response
    response = await fetch(`http://localhost:${server.address().port}`, {
      integrity: `sha256-${sha256} sha512-${sha512}`
    })
    t.strictEqual(body, await response.text())
    response = await fetch(`http://localhost:${server.address().port}`, {
      integrity: `sha512-${sha512} sha256-${sha256}`
    })

    t.strictEqual(body, await response.text())
    response = await fetch(`http://localhost:${server.address().port}`, {
      integrity: `sha384-${sha384} sha512-${sha512}`
    })
    t.strictEqual(body, await response.text())
    response = await fetch(`http://localhost:${server.address().port}`, {
      integrity: `sha384-${sha384} sha512-${sha512}`
    })
    t.strictEqual(body, await response.text())

    response = await fetch(`http://localhost:${server.address().port}`, {
      integrity: `sha256-${sha256} sha384-${sha384}`
    })
    t.strictEqual(body, await response.text())
    response = await fetch(`http://localhost:${server.address().port}`, {
      integrity: `sha384-${sha384} sha256-${sha256}`
    })
    t.strictEqual(body, await response.text())
  })

  await t.completed
})
      { algo: 'sha256', hash: hash256.replace(/=/g, '') },
      { algo: 'sha384', hash: hash384.replace(/=/g, '') },
      { algo: 'sha512', hash: hash512.replace(/=/g, '') }
      { algo: 'sha256', hash: hash256.replace(/=/g, '') },
      { algo: 'sha384', hash: hash384.replace(/=/g, '') },
      { algo: 'sha512', hash: hash512.replace(/=/g, '') }
      { algo: 'sha256', hash: hash256.replace(/=/g, '') },
      { algo: 'sha384', hash: hash384.replace(/=/g, '') },
      { algo: 'sha512', hash: hash512.replace(/=/g, '') }
  await t.test('should set hash as undefined when invalid base64 chars are provided', () => {
      { algo: 'sha256', hash: hash256.replace(/=/g, '') },
      { algo: 'sha384', hash: undefined },
      { algo: 'sha512', hash: hash512.replace(/=/g, '') }