UNPKG

undici

Version:

An HTTP/1.1 client, written from scratch for Node.js

307 lines (256 loc) 9.47 kB
'use strict' const assert = require('node:assert') /** * @typedef {object} Metadata * @property {SRIHashAlgorithm} alg - The algorithm used for the hash. * @property {string} val - The base64-encoded hash value. */ /** * @typedef {Metadata[]} MetadataList */ /** * @typedef {('sha256' | 'sha384' | 'sha512')} SRIHashAlgorithm */ /** * @type {Map<SRIHashAlgorithm, number>} * * The valid SRI hash algorithm token set is the ordered set « "sha256", * "sha384", "sha512" » (corresponding to SHA-256, SHA-384, and SHA-512 * respectively). The ordering of this set is meaningful, with stronger * algorithms appearing later in the set. * * @see https://w3c.github.io/webappsec-subresource-integrity/#valid-sri-hash-algorithm-token-set */ const validSRIHashAlgorithmTokenSet = new Map([['sha256', 0], ['sha384', 1], ['sha512', 2]]) // https://nodejs.org/api/crypto.html#determining-if-crypto-support-is-unavailable /** @type {import('crypto')} */ let crypto try { crypto = require('node:crypto') const cryptoHashes = crypto.getHashes() // If no hashes are available, we cannot support SRI. if (cryptoHashes.length === 0) { validSRIHashAlgorithmTokenSet.clear() } for (const algorithm of validSRIHashAlgorithmTokenSet.keys()) { // If the algorithm is not supported, remove it from the list. if (cryptoHashes.includes(algorithm) === false) { validSRIHashAlgorithmTokenSet.delete(algorithm) } } /* c8 ignore next 4 */ } catch { // If crypto is not available, we cannot support SRI. validSRIHashAlgorithmTokenSet.clear() } /** * @typedef GetSRIHashAlgorithmIndex * @type {(algorithm: SRIHashAlgorithm) => number} * @param {SRIHashAlgorithm} algorithm * @returns {number} The index of the algorithm in the valid SRI hash algorithm * token set. */ const getSRIHashAlgorithmIndex = /** @type {GetSRIHashAlgorithmIndex} */ (Map.prototype.get.bind( validSRIHashAlgorithmTokenSet)) /** * @typedef IsValidSRIHashAlgorithm * @type {(algorithm: string) => algorithm is SRIHashAlgorithm} * @param {*} algorithm * @returns {algorithm is SRIHashAlgorithm} */ const isValidSRIHashAlgorithm = /** @type {IsValidSRIHashAlgorithm} */ ( Map.prototype.has.bind(validSRIHashAlgorithmTokenSet) ) /** * @param {Uint8Array} bytes * @param {string} metadataList * @returns {boolean} * * @see https://w3c.github.io/webappsec-subresource-integrity/#does-response-match-metadatalist */ const bytesMatch = crypto === undefined || validSRIHashAlgorithmTokenSet.size === 0 // If node is not built with OpenSSL support, we cannot check // a request's integrity, so allow it by default (the spec will // allow requests if an invalid hash is given, as precedence). ? () => true : (bytes, metadataList) => { // 1. Let parsedMetadata be the result of parsing metadataList. const parsedMetadata = parseMetadata(metadataList) // 2. If parsedMetadata is empty set, return true. if (parsedMetadata.length === 0) { return true } // 3. Let metadata be the result of getting the strongest // metadata from parsedMetadata. const metadata = getStrongestMetadata(parsedMetadata) // 4. For each item in metadata: for (const item of metadata) { // 1. Let algorithm be the item["alg"]. const algorithm = item.alg // 2. Let expectedValue be the item["val"]. const expectedValue = item.val // 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. // 3. Let actualValue be the result of applying algorithm to bytes . const actualValue = applyAlgorithmToBytes(algorithm, bytes) // 4. If actualValue is a case-sensitive match for expectedValue, // return true. if (caseSensitiveMatch(actualValue, expectedValue)) { return true } } // 5. Return false. return false } /** * @param {MetadataList} metadataList * @returns {MetadataList} The strongest hash algorithm from the metadata list. */ function getStrongestMetadata (metadataList) { // 1. Let result be the empty set and strongest be the empty string. const result = [] /** @type {Metadata|null} */ let strongest = null // 2. For each item in set: for (const item of metadataList) { // 1. Assert: item["alg"] is a valid SRI hash algorithm token. assert(isValidSRIHashAlgorithm(item.alg), 'Invalid SRI hash algorithm token') // 2. If result is the empty set, then: if (result.length === 0) { // 1. Append item to result. result.push(item) // 2. Set strongest to item. strongest = item // 3. Continue. continue } // 3. Let currentAlgorithm be strongest["alg"], and currentAlgorithmIndex be // the index of currentAlgorithm in the valid SRI hash algorithm token set. const currentAlgorithm = /** @type {Metadata} */ (strongest).alg const currentAlgorithmIndex = getSRIHashAlgorithmIndex(currentAlgorithm) // 4. Let newAlgorithm be the item["alg"], and newAlgorithmIndex be the // index of newAlgorithm in the valid SRI hash algorithm token set. const newAlgorithm = item.alg const newAlgorithmIndex = getSRIHashAlgorithmIndex(newAlgorithm) // 5. If newAlgorithmIndex is less than currentAlgorithmIndex, then continue. if (newAlgorithmIndex < currentAlgorithmIndex) { continue // 6. Otherwise, if newAlgorithmIndex is greater than // currentAlgorithmIndex: } else if (newAlgorithmIndex > currentAlgorithmIndex) { // 1. Set strongest to item. strongest = item // 2. Set result to « item ». result[0] = item result.length = 1 // 7. Otherwise, newAlgorithmIndex and currentAlgorithmIndex are the same // value. Append item to result. } else { result.push(item) } } // 3. Return result. return result } /** * @param {string} metadata * @returns {MetadataList} * * @see https://w3c.github.io/webappsec-subresource-integrity/#parse-metadata */ function parseMetadata (metadata) { // 1. Let result be the empty set. /** @type {MetadataList} */ const result = [] // 2. For each item returned by splitting metadata on spaces: for (const item of metadata.split(' ')) { // 1. Let expression-and-options be the result of splitting item on U+003F (?). const expressionAndOptions = item.split('?', 1) // 2. Let algorithm-expression be expression-and-options[0]. const algorithmExpression = expressionAndOptions[0] // 3. Let base64-value be the empty string. let base64Value = '' // 4. Let algorithm-and-value be the result of splitting algorithm-expression on U+002D (-). const algorithmAndValue = [algorithmExpression.slice(0, 6), algorithmExpression.slice(7)] // 5. Let algorithm be algorithm-and-value[0]. const algorithm = algorithmAndValue[0] // 6. If algorithm is not a valid SRI hash algorithm token, then continue. if (!isValidSRIHashAlgorithm(algorithm)) { continue } // 7. If algorithm-and-value[1] exists, set base64-value to // algorithm-and-value[1]. if (algorithmAndValue[1]) { base64Value = algorithmAndValue[1] } // 8. Let metadata be the ordered map // «["alg" → algorithm, "val" → base64-value]». const metadata = { alg: algorithm, val: base64Value } // 9. Append metadata to result. result.push(metadata) } // 3. Return result. return result } /** * Applies the specified hash algorithm to the given bytes * * @typedef {(algorithm: SRIHashAlgorithm, bytes: Uint8Array) => string} ApplyAlgorithmToBytes * @param {SRIHashAlgorithm} algorithm * @param {Uint8Array} bytes * @returns {string} */ const applyAlgorithmToBytes = (algorithm, bytes) => { return crypto.hash(algorithm, bytes, 'base64') } /** * Compares two base64 strings, allowing for base64url * in the second string. * * @param {string} actualValue base64 encoded string * @param {string} expectedValue base64 or base64url encoded string * @returns {boolean} */ function caseSensitiveMatch (actualValue, expectedValue) { // Ignore padding characters from the end of the strings by // decreasing the length by 1 or 2 if the last characters are `=`. let actualValueLength = actualValue.length if (actualValueLength !== 0 && actualValue[actualValueLength - 1] === '=') { actualValueLength -= 1 } if (actualValueLength !== 0 && actualValue[actualValueLength - 1] === '=') { actualValueLength -= 1 } let expectedValueLength = expectedValue.length if (expectedValueLength !== 0 && expectedValue[expectedValueLength - 1] === '=') { expectedValueLength -= 1 } if (expectedValueLength !== 0 && expectedValue[expectedValueLength - 1] === '=') { expectedValueLength -= 1 } if (actualValueLength !== expectedValueLength) { return false } for (let i = 0; i < actualValueLength; ++i) { if ( actualValue[i] === expectedValue[i] || (actualValue[i] === '+' && expectedValue[i] === '-') || (actualValue[i] === '/' && expectedValue[i] === '_') ) { continue } return false } return true } module.exports = { applyAlgorithmToBytes, bytesMatch, caseSensitiveMatch, isValidSRIHashAlgorithm, getStrongestMetadata, parseMetadata }