diff --git a/.github/workflows/nuget-response-security.yml b/.github/workflows/nuget-response-security.yml new file mode 100644 index 00000000..5ab6437f --- /dev/null +++ b/.github/workflows/nuget-response-security.yml @@ -0,0 +1,38 @@ +name: NuGet Response Security + +on: + push: + branches: [main, latest, fix/brotli-metadata-main, fix/brotli-metadata-latest] + paths: + - 'node_modules/@microsoft/security-devops-actions-toolkit/**' + - 'test/nuget-response.security.cjs' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/nuget-response-security.yml' + pull_request: + branches: [main, latest] + paths: + - 'node_modules/@microsoft/security-devops-actions-toolkit/**' + - 'test/nuget-response.security.cjs' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/nuget-response-security.yml' + +permissions: + contents: read + +jobs: + verify-shipped-toolkit: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + node: ['20.13.1', '24.22.1'] + steps: + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + - name: Verify vendored NuGet response handling + run: npm run test:nuget-responses diff --git a/.github/workflows/on-push-verification.yml b/.github/workflows/on-push-verification.yml index c45e79fb..4bd35a88 100644 --- a/.github/workflows/on-push-verification.yml +++ b/.github/workflows/on-push-verification.yml @@ -22,7 +22,7 @@ jobs: steps: # Checkout your code repository to scan - - uses: actions/checkout@v3 + - uses: actions/checkout@v6.0.2 # Run analyzers - uses: ./ @@ -30,13 +30,13 @@ jobs: # Upload alerts to the Security tab - name: Upload alerts to Security tab - uses: github/codeql-action/upload-sarif@v2 + uses: github/codeql-action/upload-sarif@v4 with: sarif_file: ${{ steps.msdo.outputs.sarifFile }} # Upload alerts file as a workflow artifact - name: Upload alerts file as a workflow artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v7 with: - name: alerts + name: alerts-${{ matrix.os }} path: ${{ steps.msdo.outputs.sarifFile }} diff --git a/README.md b/README.md index 1ec09319..75e7859e 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,14 @@ This action runs the [Microsoft Security DevOps CLI](https://aka.ms/msdo-nuget) See [action.yml](action.yml) +## NuGet response safety + +The checked-in toolkit includes the Brotli response rejection from [toolkit PR #24](https://github.com/microsoft/security-devops-actions-toolkit/pull/24), merged as `433e1090696fec074f5fe066e209ece22a50102c`. NuGet metadata requests decline Brotli and reject unsupported encodings before decompression. Existing gzip/deflate decoding, package transfer, and authentication are preserved; this hotfix does not import unrelated proxy or runtime upgrades from `main`. + +This is a targeted patch to the vendored runtime, not a new toolkit package or Node.js release. Reinstalling toolkit version `1.11.0` or replacing the vendored files can remove it; future package adoption must retain this mitigation. + +Run `npm run test:nuget-responses` to test the actual checked-in dependency with local fixtures, without package-feed credentials or a remote NuGet service. The dedicated CI checks Windows and Linux on Node.js 20.13.1 and 24.22.1. Node 20 is a compatibility-test target, not a recommendation to deploy an end-of-life runtime. + ## Basic Run **Microsoft Security DevOps (MSDO)** with the default policy and recommended tools. diff --git a/node_modules/@microsoft/security-devops-actions-toolkit/msdo-nuget-client.js b/node_modules/@microsoft/security-devops-actions-toolkit/msdo-nuget-client.js index 4cef11b7..045d3174 100644 --- a/node_modules/@microsoft/security-devops-actions-toolkit/msdo-nuget-client.js +++ b/node_modules/@microsoft/security-devops-actions-toolkit/msdo-nuget-client.js @@ -361,16 +361,31 @@ function requestJson(url, options) { return __awaiter(this, void 0, void 0, function* () { return new Promise((resolve, reject) => { core.debug(`${options['method'].toUpperCase()} ${url}`); - const req = https.request(url, options, (res) => __awaiter(this, void 0, void 0, function* () { + const requestOptions = Object.assign(Object.assign({}, options), { headers: Object.assign(Object.assign({}, options['headers']), { 'Accept-Encoding': 'gzip, deflate, identity, br;q=0' }) }); + const req = https.request(url, requestOptions, (res) => __awaiter(this, void 0, void 0, function* () { + res.on('error', reject); + if (res.statusCode !== 200) { + res.resume(); + reject(new Error(`Failed to call: ${url}. Status code: ${res.statusCode}`)); + return; + } + const encoding = (res.headers['content-encoding'] || '').trim().toLowerCase(); + if (['', 'identity', 'gzip', 'deflate'].indexOf(encoding) === -1) { + res.destroy(); + reject(new Error(`Unsupported NuGet Content-Encoding: ${encoding}. Brotli decoding is disabled for CVE-2026-111497.`)); + return; + } try { - const decompressResponse = yield Promise.resolve().then(() => __importStar(require('decompress-response'))); - res = decompressResponse.default(res); + const decompressResponse = yield import('decompress-response'); + const decompressed = decompressResponse.default(res); + if (decompressed !== res) { + decompressed.on('error', reject); + res = decompressed; + } } catch (error) { - core.debug(`Failed to add response decompression: ${error.message}`); - } - if (res.statusCode !== 200) { - reject(new Error(`Failed to call: ${url}. Status code: ${res.statusCode}`)); + res.destroy(); + reject(error); return; } let data = ''; diff --git a/package.json b/package.json index 0de9e27a..ddb71c1f 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "build": "npx gulp", "buildTests": "npx gulp buildTests", "test": "npx mocha **/*.tests.js", + "test:nuget-responses": "node --test test/nuget-response.security.cjs", "buildAndTest": "npx gulp buildTests & npx mocha **/*.tests.js" }, "author": "Microsoft Corporation", diff --git a/test/nuget-response.security.cjs b/test/nuget-response.security.cjs new file mode 100644 index 00000000..d2c0f313 --- /dev/null +++ b/test/nuget-response.security.cjs @@ -0,0 +1,214 @@ +const assert = require('node:assert/strict'); +const { EventEmitter } = require('node:events'); +const fs = require('node:fs'); +const { IncomingMessage } = require('node:http'); +const https = require('node:https'); +const { createRequire } = require('node:module'); +const os = require('node:os'); +const path = require('node:path'); +const { PassThrough } = require('node:stream'); +const { test } = require('node:test'); +const zlib = require('node:zlib'); +const toolkitPath = require.resolve('@microsoft/security-devops-actions-toolkit/msdo-nuget-client'); +const toolkitRequire = createRequire(toolkitPath); +const core = toolkitRequire('@actions/core'); +const AdmZip = toolkitRequire('adm-zip'); +const nuget = require(toolkitPath); +const usesProxyAgents = false; + +function createFeed(t, configuration = {}) { + const outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'msdo-action-nuget-')); + t.after(() => fs.rmSync(outputDirectory, { recursive: true, force: true })); + t.mock.method(core, 'debug', () => {}); + const proxyKeys = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'NO_PROXY', 'no_proxy']; + const previousProxy = Object.fromEntries(proxyKeys.map(key => [key, process.env[key]])); + for (const key of proxyKeys) { + delete process.env[key]; + } + Object.assign(process.env, configuration.proxy || {}); + t.after(() => { + for (const key of proxyKeys) { + if (previousProxy[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = previousProxy[key]; + } + } + }); + + const archive = new AdmZip(); + archive.addFile('guardian.txt', Buffer.from('benign package fixture')); + const requests = []; + const responses = []; + const decoder = t.mock.method(zlib, 'createBrotliDecompress', () => new PassThrough()); + const previousLatest = process.env.MSDO_FIXTUREPACKAGE_LATESTVERSION; + delete process.env.MSDO_FIXTUREPACKAGE_LATESTVERSION; + t.after(() => { + if (previousLatest === undefined) { + delete process.env.MSDO_FIXTUREPACKAGE_LATESTVERSION; + } else { + process.env.MSDO_FIXTUREPACKAGE_LATESTVERSION = previousLatest; + } + }); + t.mock.method(core, 'exportVariable', (name, value) => { + process.env[name] = value; + }); + + t.mock.method(https, 'request', (url, options, callback) => { + requests.push({ url, auth: options.auth, agent: options.agent, headers: { ...options.headers } }); + const isPackage = url.endsWith('.nupkg'); + const isRegistration = url.includes('/registration/'); + const encoding = isPackage ? undefined + : isRegistration && configuration.registrationEncoding !== undefined + ? configuration.registrationEncoding : configuration.encoding; + const response = new IncomingMessage(new PassThrough()); + response.statusCode = isPackage ? 200 : configuration.statusCode || 200; + response.headers = encoding === undefined ? {} : { 'content-encoding': encoding }; + responses.push(response); + const document = isRegistration + ? { items: [{ items: [{ catalogEntry: { listed: true, version: '1.0.0' } }] }] } + : { + resources: [ + { '@id': 'https://nuget.test/registration/', '@type': 'RegistrationsBaseUrl/3.6.0' }, + { '@id': 'https://nuget.test/flat/', '@type': 'PackageBaseAddress/3.0.0' } + ] + }; + let body = isPackage ? archive.toBuffer() : Buffer.from(JSON.stringify(document)); + if (!isPackage) { + if (encoding && encoding.toLowerCase() === 'gzip') { + body = configuration.invalidCompression ? Buffer.from('not gzip') : zlib.gzipSync(body); + } else if (encoding === 'deflate') { + body = configuration.rawDeflate ? zlib.deflateRawSync(body, { level: 0 }) : zlib.deflateSync(body); + } + } + + const request = new EventEmitter(); + request.end = () => { + queueMicrotask(() => { + callback(response); + setImmediate(() => { + if (configuration.responseError && !isPackage) { + response.destroy(new Error('fixture response failure')); + } else if (!response.destroyed) { + response.complete = true; + response.push(body); + response.push(null); + } + }); + }); + }; + return request; + }); + + return { + outputDirectory, requests, responses, decoder, + install: version => nuget.install( + 'https://nuget.test/index.json', 'Fixture.Package', version || '1.0.0', outputDirectory, 'fixture-only' + ) + }; +} + +for (const encoding of [undefined, 'identity', 'gzip', 'GZIP', 'deflate']) { + test(`installs with ${encoding || 'unencoded'} metadata`, { timeout: 3000 }, async t => { + const feed = createFeed(t, { encoding }); + const result = await feed.install(); + assert.equal(result.success, true); + assert.equal(result.resolvedVersion, '1.0.0'); + assert.equal(fs.readFileSync(path.join(result.packageFolder, 'guardian.txt'), 'utf8'), 'benign package fixture'); + assert.equal(feed.decoder.mock.callCount(), 0); + }); +} + +test('preserves the existing raw-deflate decoding path', { timeout: 3000 }, async t => { + const feed = createFeed(t, { encoding: 'deflate', rawDeflate: true }); + assert.equal((await feed.install()).success, true); + assert.equal(feed.decoder.mock.callCount(), 0); +}); + +for (const encoding of ['br', 'BR', ' br ', 'gzip, br', 'br, gzip', 'zstd', 'gzip, deflate']) { + test(`rejects unsupported encoding "${encoding}" before decoding`, { timeout: 3000 }, async t => { + const feed = createFeed(t, { encoding }); + await assert.rejects(feed.install(), /Unsupported NuGet Content-Encoding/); + assert.equal(feed.decoder.mock.callCount(), 0); + assert.equal(feed.requests.length, 1); + assert.equal(feed.responses[0].destroyed, true); + assert.deepEqual(fs.readdirSync(feed.outputDirectory), []); + }); +} + +for (const encoding of ['br', 'gzip, br']) { + test(`rejects Brotli in registration metadata: ${encoding}`, { timeout: 3000 }, async t => { + const feed = createFeed(t, { registrationEncoding: encoding }); + await assert.rejects(feed.install('Latest'), /Unsupported NuGet Content-Encoding/); + assert.equal(feed.requests.length, 2); + assert.equal(feed.decoder.mock.callCount(), 0); + assert.deepEqual(fs.readdirSync(feed.outputDirectory), []); + }); +} + +test('declines Brotli without changing package transfer or authentication', { timeout: 3000 }, async t => { + const feed = createFeed(t); + await feed.install(); + assert.equal(feed.requests[0].headers['Accept-Encoding'], 'gzip, deflate, identity, br;q=0'); + assert.equal(feed.requests[1].headers['Accept-Encoding'], undefined); + assert.equal(feed.requests[0].auth, ':fixture-only'); + assert.equal(feed.requests[1].auth, ':fixture-only'); +}); + +test('resolves Latest through compressed registration metadata', { timeout: 3000 }, async t => { + const feed = createFeed(t, { encoding: 'gzip' }); + const result = await feed.install('Latest'); + assert.equal(result.resolvedVersion, '1.0.0'); + assert.equal(feed.requests.length, 3); + assert.equal(feed.requests[1].headers['Accept-Encoding'], 'gzip, deflate, identity, br;q=0'); + assert.equal(feed.decoder.mock.callCount(), 0); +}); + +test('preserves cached installations without another request', { timeout: 3000 }, async t => { + const feed = createFeed(t); + await feed.install(); + const count = feed.requests.length; + const cached = await feed.install(); + assert.equal(cached.success, true); + assert.equal(cached.inCache, true); + assert.equal(feed.requests.length, count); +}); + +test('rejects non-success responses before decompression', { timeout: 3000 }, async t => { + const feed = createFeed(t, { encoding: 'br', statusCode: 503 }); + await assert.rejects(feed.install(), /Status code: 503/); + assert.equal(feed.decoder.mock.callCount(), 0); +}); + +test('propagates decompression errors', { timeout: 3000 }, async t => { + const feed = createFeed(t, { encoding: 'gzip', invalidCompression: true }); + await assert.rejects(feed.install(), /header check|gzip|decompress/i); +}); + +test('propagates response stream errors', { timeout: 3000 }, async t => { + const feed = createFeed(t, { responseError: true }); + await assert.rejects(feed.install(), /fixture response failure/); +}); + +for (const scenario of [ + { name: 'HTTPS_PROXY', proxy: { HTTPS_PROXY: 'http://proxy.test:3128' }, enabled: true }, + { name: 'https_proxy', proxy: { https_proxy: 'http://proxy.test:3128' }, enabled: true }, + { name: 'HTTPS proxy URL', proxy: { HTTPS_PROXY: 'https://proxy.test:3128' }, enabled: true }, + { name: 'matching NO_PROXY', proxy: { HTTPS_PROXY: 'http://proxy.test:3128', NO_PROXY: 'nuget.test' }, enabled: false }, + { name: 'nonmatching NO_PROXY', proxy: { HTTPS_PROXY: 'http://proxy.test:3128', NO_PROXY: 'other.test' }, enabled: true }, + { name: 'HTTP_PROXY only', proxy: { HTTP_PROXY: 'http://proxy.test:3128' }, enabled: false } +]) { + test(`preserves existing proxy behavior for ${scenario.name}`, { timeout: 3000 }, async t => { + const feed = createFeed(t, { proxy: scenario.proxy }); + assert.equal((await feed.install()).success, true); + for (const request of feed.requests) { + if (usesProxyAgents && scenario.enabled) { + assert.ok(request.agent); + assert.equal(request.agent.proxyOptions.host, 'proxy.test'); + assert.equal(request.agent.proxyOptions.port, '3128'); + } else { + assert.equal(request.agent, undefined); + } + } + }); +}