diff --git a/lib/web/fetch/index.js b/lib/web/fetch/index.js index ce5a17340f6..d7bb32e47c1 100644 --- a/lib/web/fetch/index.js +++ b/lib/web/fetch/index.js @@ -14,7 +14,6 @@ const { HeadersList } = require('./headers') const { Request, cloneRequest, getRequestDispatcher, getRequestState } = require('./request') const zlib = require('node:zlib') const { - bytesMatch, makePolicyContainer, clonePolicyContainer, requestBadPort, @@ -62,6 +61,7 @@ const { dataURLProcessor, serializeAMimeType, minimizeSupportedMimeType } = requ const { getGlobalDispatcher } = require('../../global') const { webidl } = require('../webidl') const { STATUS_CODES } = require('node:http') +const { bytesMatch } = require('../subresource-integrity/subresource-integrity') const { createDeferredPromise } = require('../../util/promise') const GET_OR_HEAD = ['GET', 'HEAD'] diff --git a/lib/web/fetch/util.js b/lib/web/fetch/util.js index 27e25a67a31..d71126ca883 100644 --- a/lib/web/fetch/util.js +++ b/lib/web/fetch/util.js @@ -11,20 +11,6 @@ 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 { - -} - function responseURL (response) { // https://fetch.spec.whatwg.org/#responses // A response has an associated URL. It is a pointer to the last URL @@ -698,206 +684,6 @@ function isURLPotentiallyTrustworthy (url) { return isOriginPotentiallyTrustworthy(url.origin) } -/** - * @see https://w3c.github.io/webappsec-subresource-integrity/#does-response-match-metadatalist - * @param {Uint8Array} bytes - * @param {string} metadataList - */ -function bytesMatch (bytes, metadataList) { - // 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). - /* istanbul ignore if: only if node is built with --without-ssl */ - if (crypto === undefined) { - return true - } - - // 1. Let parsedMetadata be the result of parsing metadataList. - const parsedMetadata = parseMetadata(metadataList) - - // 2. If parsedMetadata is no metadata, return true. - if (parsedMetadata === 'no metadata') { - 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 - } - - // 5. Let metadata be the result of getting the strongest - // metadata from parsedMetadata. - const strongest = getStrongestMetadata(parsedMetadata) - const metadata = filterMetadataListByAlgorithm(parsedMetadata, strongest) - - // 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. - 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. - - // 3. Let actualValue be the result of applying algorithm to bytes. - let actualValue = crypto.createHash(algorithm).update(bytes).digest('base64') - - 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 (compareBase64Mixed(actualValue, expectedValue)) { - return true - } - } - - // 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 = /(?sha256|sha384|sha512)-((?[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 - */ -function parseMetadata (metadata) { - // 1. Let result be the empty set. - /** @type {{ algo: string, hash: string }[]} */ - const result = [] - - // 2. Let empty be equal to true. - let empty = true - - // 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 || - 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.toLowerCase() - - // 5. If algorithm is a hash function recognized by the user - // agent, add the parsed token to result. - if (supportedHashes.includes(algorithm)) { - result.push(parsedToken.groups) - } - } - - // 4. Return no metadata if empty is true, otherwise return result. - if (empty === true) { - return 'no 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 @@ -1761,7 +1547,6 @@ module.exports = { isValidHeaderValue, isErrorLike, fullyReadBody, - bytesMatch, readableStreamClose, isomorphicEncode, urlIsLocal, @@ -1770,7 +1555,6 @@ module.exports = { readAllBytes, simpleRangeHeaderValue, buildContentRange, - parseMetadata, createInflate, extractMimeType, getDecodeSplit, diff --git a/lib/web/subresource-integrity/Readme.md b/lib/web/subresource-integrity/Readme.md new file mode 100644 index 00000000000..289a2b84d46 --- /dev/null +++ b/lib/web/subresource-integrity/Readme.md @@ -0,0 +1,9 @@ +# Subresource Integrity + +based on Editor’s Draft, 12 June 2025 + +This module provides support for Subresource Integrity (SRI) in the context of web fetch operations. SRI is a security feature that allows clients to verify that fetched resources are delivered without unexpected manipulation. + +## Links + +- [Subresource Integrity](https://w3c.github.io/webappsec-subresource-integrity/) \ No newline at end of file diff --git a/lib/web/subresource-integrity/subresource-integrity.js b/lib/web/subresource-integrity/subresource-integrity.js new file mode 100644 index 00000000000..fccdda67892 --- /dev/null +++ b/lib/web/subresource-integrity/subresource-integrity.js @@ -0,0 +1,306 @@ +'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} + * + * 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 +} diff --git a/package.json b/package.json index 3f205435cd0..b228da8bd0d 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "lint:fix": "eslint --fix --cache", "test": "npm run test:javascript && cross-env NODE_V8_COVERAGE= npm run test:typescript", "test:javascript": "npm run test:javascript:no-jest && npm run test:jest", - "test:javascript:no-jest": "npm run generate-pem && npm run test:unit && npm run test:node-fetch && npm run test:cache && npm run test:cache-interceptor && npm run test:interceptors && npm run test:fetch && npm run test:cookies && npm run test:eventsource && npm run test:wpt && npm run test:websocket && npm run test:node-test && npm run test:cache-tests", + "test:javascript:no-jest": "npm run generate-pem && npm run test:unit && npm run test:node-fetch && npm run test:cache && npm run test:cache-interceptor && npm run test:interceptors && npm run test:fetch && npm run test:cookies && npm run test:eventsource && npm run test:subresource-integrity && npm run test:wpt && npm run test:websocket && npm run test:node-test && npm run test:cache-tests", "test:javascript:without-intl": "npm run test:javascript:no-jest", "test:busboy": "borp -p \"test/busboy/*.js\"", "test:cache": "borp -p \"test/cache/*.js\"", @@ -79,6 +79,7 @@ "test:eventsource": "npm run build:node && borp --expose-gc -p \"test/eventsource/*.js\"", "test:fuzzing": "node test/fuzzing/fuzzing.test.js", "test:fetch": "npm run build:node && borp --timeout 180000 --expose-gc --concurrency 1 -p \"test/fetch/*.js\" && npm run test:webidl && npm run test:busboy", + "test:subresource-integrity": "borp -p \"test/subresource-integrity/*.js\"", "test:h2": "npm run test:h2:core && npm run test:h2:fetch", "test:h2:core": "borp -p \"test/+(http2|h2)*.js\"", "test:h2:fetch": "npm run build:node && borp -p \"test/fetch/http2*.js\"", diff --git a/test/fetch/integrity.js b/test/fetch/integrity.js index 5c34a87e20f..5e2bcde1c56 100644 --- a/test/fetch/integrity.js +++ b/test/fetch/integrity.js @@ -10,7 +10,7 @@ const { fetch, setGlobalDispatcher, Agent } = require('../..') const { once } = require('node:events') const { closeServerAsPromise } = require('../utils/node-http') -const supportedHashes = getHashes() +const supportedHashAlgorithms = getHashes() setGlobalDispatcher(new Agent({ keepAliveTimeout: 1, @@ -105,7 +105,7 @@ test('request with mixed in/valid integrities', async (t) => { })) }) -test('request with sha384 hash', { skip: !supportedHashes.includes('sha384') }, async (t) => { +test('request with sha384 hash', { skip: !supportedHashAlgorithms.includes('sha384') }, async (t) => { const body = 'Hello world!' const hash = createHash('sha384').update(body).digest('base64') @@ -127,7 +127,7 @@ test('request with sha384 hash', { skip: !supportedHashes.includes('sha384') }, })) }) -test('request with sha512 hash', { skip: !supportedHashes.includes('sha512') }, async (t) => { +test('request with sha512 hash', { skip: !supportedHashAlgorithms.includes('sha512') }, async (t) => { const body = 'Hello world!' const hash = createHash('sha512').update(body).digest('base64') @@ -149,6 +149,23 @@ test('request with sha512 hash', { skip: !supportedHashes.includes('sha512') }, })) }) +test('request with sha512 hash', { skip: !supportedHashAlgorithms.includes('sha512') || !supportedHashAlgorithms.includes('sha384') }, async (t) => { + const body = 'Hello world!' + const hash384 = createHash('sha384').update(body).digest('base64') + + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + res.end(body) + }).listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + // request should fail + await assert.rejects(fetch(`http://localhost:${server.address().port}`, { + integrity: `sha512-${hash384} sha384-${hash384}` + })) +}) + test('request with correct integrity checksum (base64url)', async (t) => { t = tspl(t, { plan: 1 }) const body = 'Hello world!' diff --git a/test/fetch/util.js b/test/fetch/util.js index 0000d6afc0d..c0f8aefaa74 100644 --- a/test/fetch/util.js +++ b/test/fetch/util.js @@ -5,7 +5,6 @@ const assert = require('node:assert') const { tspl } = require('@matteo.collina/tspl') const util = require('../../lib/web/fetch/util') const { HeadersList } = require('../../lib/web/fetch/headers') -const { createHash } = require('node:crypto') test('responseURL', (t) => { const { ok } = tspl(t, { plan: 2 }) @@ -226,72 +225,6 @@ describe('setRequestReferrerPolicyOnRedirect', () => { }) }) -test('parseMetadata', async (t) => { - await t.test('should parse valid metadata with option', () => { - const body = 'Hello world!' - const hash256 = createHash('sha256').update(body).digest('base64') - const hash384 = createHash('sha384').update(body).digest('base64') - const hash512 = createHash('sha512').update(body).digest('base64') - - const validMetadata = `sha256-${hash256} !@ sha384-${hash384} !@ sha512-${hash512} !@` - const result = util.parseMetadata(validMetadata) - - assert.deepEqual(result, [ - { 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!' - const hash256 = createHash('sha256').update(body).digest('base64') - const hash384 = createHash('sha384').update(body).digest('base64') - const hash512 = createHash('sha512').update(body).digest('base64') - - const validMetadata = `sha256-${hash256} !© sha384-${hash384} !€ sha512-${hash512} !µ` - const result = util.parseMetadata(validMetadata) - - assert.deepEqual(result, [ - { 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!' - const hash256 = createHash('sha256').update(body).digest('base64') - const hash384 = createHash('sha384').update(body).digest('base64') - const hash512 = createHash('sha512').update(body).digest('base64') - - const validMetadata = `sha256-${hash256} sha384-${hash384} sha512-${hash512}` - const result = util.parseMetadata(validMetadata) - - assert.deepEqual(result, [ - { 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', () => { - 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.replace(/=/g, '') }, - { algo: 'sha384', hash: undefined }, - { algo: 'sha512', hash: hash512.replace(/=/g, '') } - ]) - }) -}) - describe('urlHasHttpsScheme', () => { const { urlHasHttpsScheme } = util diff --git a/test/subresource-integrity/apply-algorith-to-bytes.js b/test/subresource-integrity/apply-algorith-to-bytes.js new file mode 100644 index 00000000000..aa46852f321 --- /dev/null +++ b/test/subresource-integrity/apply-algorith-to-bytes.js @@ -0,0 +1,37 @@ +'use strict' + +const { test, describe } = require('node:test') +const assert = require('node:assert') + +const { applyAlgorithmToBytes } = require('../../lib/web/subresource-integrity/subresource-integrity') + +let crypto = null + +let skip +try { + crypto = require('node:crypto') + skip = false +} catch { + skip = 'crypto not available' +} + +describe('applyAlgorithmToBytes', () => { + test('valid sha256', { skip }, () => { + const bytes = Buffer.from('Hello world!') + const validSha256Base64 = crypto.createHash('sha256').update(bytes).digest('base64') + const result = applyAlgorithmToBytes('sha256', Buffer.from('Hello world!')) + assert.strictEqual(result, validSha256Base64) + }) + test('valid sha384', { skip }, () => { + const bytes = Buffer.from('Hello world!') + const validSha384Base64 = crypto.createHash('sha384').update(bytes).digest('base64') + const result = applyAlgorithmToBytes('sha384', Buffer.from('Hello world!')) + assert.strictEqual(result, validSha384Base64) + }) + test('valid sha512', { skip }, () => { + const bytes = Buffer.from('Hello world!') + const validSha512Base64 = crypto.createHash('sha512').update(bytes).digest('base64') + const result = applyAlgorithmToBytes('sha512', Buffer.from('Hello world!')) + assert.strictEqual(result, validSha512Base64) + }) +}) diff --git a/test/subresource-integrity/bytes-match.js b/test/subresource-integrity/bytes-match.js new file mode 100644 index 00000000000..cae063b1827 --- /dev/null +++ b/test/subresource-integrity/bytes-match.js @@ -0,0 +1,25 @@ +'use strict' + +const assert = require('node:assert') +const { test, describe } = require('node:test') + +const { bytesMatch } = require('../../lib/web/subresource-integrity/subresource-integrity') + +let crypto = null + +let skip +try { + crypto = require('node:crypto') + skip = false +} catch { + skip = 'crypto not available' +} + +describe('bytesMatch', () => { + test('valid sha256 and base64', { skip }, () => { + const data = Buffer.from('Hello world!') + + const validSha256Base64 = `sha256-${crypto.hash('sha256', data, 'base64')}` + assert.ok(bytesMatch(data, validSha256Base64)) + }) +}) diff --git a/test/subresource-integrity/case-sensitive-match.js b/test/subresource-integrity/case-sensitive-match.js new file mode 100644 index 00000000000..61117b01be9 --- /dev/null +++ b/test/subresource-integrity/case-sensitive-match.js @@ -0,0 +1,74 @@ +'use strict' + +const { test, describe } = require('node:test') +const assert = require('node:assert') + +const { caseSensitiveMatch } = require('../../lib/web/subresource-integrity/subresource-integrity') + +describe('caseSensitiveMatch', () => { + test('identical strings', () => { + const actualValue = 'ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs' + const expectedValue = 'ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs' + assert.ok(caseSensitiveMatch(actualValue, expectedValue)) + }) + + test('identical strings, actualValue has one padding char', () => { + const actualValue = 'ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs=' + const expectedValue = 'ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs' + assert.ok(caseSensitiveMatch(actualValue, expectedValue)) + }) + + test('identical strings, expectedValue has one padding char', () => { + const actualValue = 'ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs' + const expectedValue = 'ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs=' + assert.ok(caseSensitiveMatch(actualValue, expectedValue)) + }) + + test('identical strings, expectedValue has two padding chars', () => { + const actualValue = 'ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs' + const expectedValue = 'ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs==' + assert.ok(caseSensitiveMatch(actualValue, expectedValue)) + }) + + test('identical strings, both have one padding char', () => { + const actualValue = 'ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs=' + const expectedValue = 'ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs=' + assert.ok(caseSensitiveMatch(actualValue, expectedValue)) + }) + + test('identical strings, both have two padding chars', () => { + const actualValue = 'ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs==' + const expectedValue = 'ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs==' + assert.ok(caseSensitiveMatch(actualValue, expectedValue)) + }) + + test('identical strings, expectedValue has invalid third padding char', () => { + const actualValue = 'ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs==' + const expectedValue = 'ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs===' + assert.ok(caseSensitiveMatch(actualValue, expectedValue) === false) + }) + + test('expectedValue can be base64Url - match `_`', () => { + const actualValue = 'ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs' + const expectedValue = 'ypeBEsobvcr6wjGzmiPcTaeG7_gUfE5yuYB3ha/uSLs' + assert.ok(caseSensitiveMatch(actualValue, expectedValue)) + }) + + test('expectedValue can be base64Url - match `+`', () => { + const actualValue = 'ypeBEsobvcr6wjGzmiPcTaeG7+gUfE5yuYB3ha/uSLs' + const expectedValue = 'ypeBEsobvcr6wjGzmiPcTaeG7-gUfE5yuYB3ha/uSLs' + assert.ok(caseSensitiveMatch(actualValue, expectedValue)) + }) + + test('should be case sensitive', () => { + const actualValue = 'ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs' + const expectedValue = 'ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLS' + assert.ok(caseSensitiveMatch(actualValue, expectedValue) === false) + }) + + test('empty string should return true', () => { + const actualValue = '' + const expectedValue = '' + assert.ok(caseSensitiveMatch(actualValue, expectedValue)) + }) +}) diff --git a/test/subresource-integrity/get-strongest-metadata.js b/test/subresource-integrity/get-strongest-metadata.js new file mode 100644 index 00000000000..4da88db2f20 --- /dev/null +++ b/test/subresource-integrity/get-strongest-metadata.js @@ -0,0 +1,75 @@ +'use strict' + +const assert = require('node:assert') +const { test, describe } = require('node:test') + +const { getStrongestMetadata } = require('../../lib/web/subresource-integrity/subresource-integrity') + +describe('getStrongestMetadata', () => { + test('should return strongest sha512 /1', () => { + const result = getStrongestMetadata([ + { alg: 'sha256', val: 'sha256-abc' }, + { alg: 'sha384', val: 'sha384-def' }, + { alg: 'sha512', val: 'sha512-ghi' } + ]) + assert.deepEqual(result, [ + { alg: 'sha512', val: 'sha512-ghi' } + ]) + }) + + test('should return strongest sha512 /2', () => { + const result = getStrongestMetadata([ + { alg: 'sha512', val: 'sha512-ghi' }, + { alg: 'sha256', val: 'sha256-abc' }, + { alg: 'sha384', val: 'sha384-def' } + ]) + assert.deepEqual(result, [ + { alg: 'sha512', val: 'sha512-ghi' } + ]) + }) + + test('should return strongest sha384', () => { + const result = getStrongestMetadata([ + { alg: 'sha256', val: 'sha256-abc' }, + { alg: 'sha384', val: 'sha384-def' } + ]) + assert.deepEqual(result, [ + { alg: 'sha384', val: 'sha384-def' } + ]) + }) + + test('should return both strongest sha384', () => { + const result = getStrongestMetadata([ + { alg: 'sha384', val: 'sha384-abc' }, + { alg: 'sha256', val: 'sha256-def' }, + { alg: 'sha384', val: 'sha384-ghi' } + ]) + assert.deepEqual(result, [ + { alg: 'sha384', val: 'sha384-abc' }, + { alg: 'sha384', val: 'sha384-ghi' } + ]) + }) + + test('should return multiple metadata with the same strength', () => { + const result = getStrongestMetadata([ + { alg: 'sha256', val: 'sha256-abc' } + ]) + assert.deepEqual(result, [ + { alg: 'sha256', val: 'sha256-abc' } + ]) + }) + + test('should return empty array when no metadata is provided', () => { + const result = getStrongestMetadata([]) + assert.deepEqual(result, []) + }) + + test('should throw when invalid hash algorithm is provided', () => { + assert.throws(() => getStrongestMetadata([ + { alg: 'sha1024', val: 'sha1024-xyz' } + ]), { + name: 'AssertionError', + message: 'Invalid SRI hash algorithm token' + }) + }) +}) diff --git a/test/subresource-integrity/is-valid-sri-hash-algorithm.js b/test/subresource-integrity/is-valid-sri-hash-algorithm.js new file mode 100644 index 00000000000..1127248a9b9 --- /dev/null +++ b/test/subresource-integrity/is-valid-sri-hash-algorithm.js @@ -0,0 +1,29 @@ +'use strict' + +const { test, describe } = require('node:test') +const assert = require('node:assert') + +const { isValidSRIHashAlgorithm } = require('../../lib/web/subresource-integrity/subresource-integrity') + +let skip +try { + require('node:crypto') + skip = false +} catch { + skip = 'crypto not available' +} + +describe('isValidSRIHashAlgorithm', () => { + test('valid sha256', { skip }, () => { + assert.ok(isValidSRIHashAlgorithm('sha256')) + }) + test('valid sha384', { skip }, () => { + assert.ok(isValidSRIHashAlgorithm('sha384')) + }) + test('valid sha512', { skip }, () => { + assert.ok(isValidSRIHashAlgorithm('sha512')) + }) + test('invalid sha1024', () => { + assert.ok(isValidSRIHashAlgorithm('sha1024') === false) + }) +}) diff --git a/test/subresource-integrity/parse-metadata.js b/test/subresource-integrity/parse-metadata.js new file mode 100644 index 00000000000..beea9eaecb1 --- /dev/null +++ b/test/subresource-integrity/parse-metadata.js @@ -0,0 +1,81 @@ +'use strict' + +const assert = require('node:assert') +const { createHash } = require('node:crypto') +const { test, describe } = require('node:test') + +const { parseMetadata } = require('../../lib/web/subresource-integrity/subresource-integrity') + +let skip +try { + require('node:crypto') + skip = false +} catch { + skip = 'crypto not available' +} + +describe('parseMetadata', () => { + test('should parse valid metadata with option', { skip }, () => { + const body = 'Hello world!' + const hash256 = createHash('sha256').update(body).digest('base64') + const hash384 = createHash('sha384').update(body).digest('base64') + const hash512 = createHash('sha512').update(body).digest('base64') + + const validMetadata = `sha256-${hash256} !@ sha384-${hash384} !@ sha512-${hash512} !@` + const result = parseMetadata(validMetadata) + + assert.deepEqual(result, [ + { alg: 'sha256', val: hash256 }, + { alg: 'sha384', val: hash384 }, + { alg: 'sha512', val: hash512 } + ]) + }) + + test('should parse valid metadata with non ASCII chars option', { skip }, () => { + const body = 'Hello world!' + const hash256 = createHash('sha256').update(body).digest('base64') + const hash384 = createHash('sha384').update(body).digest('base64') + const hash512 = createHash('sha512').update(body).digest('base64') + + const validMetadata = `sha256-${hash256} !© sha384-${hash384} !€ sha512-${hash512} !µ` + const result = parseMetadata(validMetadata) + + assert.deepEqual(result, [ + { alg: 'sha256', val: hash256 }, + { alg: 'sha384', val: hash384 }, + { alg: 'sha512', val: hash512 } + ]) + }) + + test('should parse valid metadata without option', { skip }, () => { + const body = 'Hello world!' + const hash256 = createHash('sha256').update(body).digest('base64') + const hash384 = createHash('sha384').update(body).digest('base64') + const hash512 = createHash('sha512').update(body).digest('base64') + + const validMetadata = `sha256-${hash256} sha384-${hash384} sha512-${hash512}` + const result = parseMetadata(validMetadata) + + assert.deepEqual(result, [ + { alg: 'sha256', val: hash256 }, + { alg: 'sha384', val: hash384 }, + { alg: 'sha512', val: hash512 } + ]) + }) + + test('should not set hash as undefined when invalid base64 chars are provided', { skip }, () => { + 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 = parseMetadata(validMetadata) + + assert.deepEqual(result, [ + { alg: 'sha256', val: hash256 }, + { alg: 'sha384', val: invalidHash384 }, + { alg: 'sha512', val: hash512 } + ]) + }) +})