CVE-2024-30261 (JavaScript, published 2024-04-04T15:15Z)
Description: 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.
Severity: nan (CVSS v3 base score: )
Weakness: NVD-CWE-noinfo \u2014 Insufficient Information
There is insufficient information about the issue to classify it; details are unkown or unspecified.
Repository: https://github.com/nodejs/undici
Files changed:
benchmarks/fetch/bytes-match.mjs
lib/web/fetch/util.js
test/fetch/integrity.js
test/fetch/util.js
Commit message: Merge pull request from GHSA-9qxr-qj54-h672

Co-authored-by: uzlopak <aras.abbasi@googlemail.com>

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, '') }