diff --git a/lib/commands/config.js b/lib/commands/config.js index 0a8b84aba2666..66eb04b6ec1b9 100644 --- a/lib/commands/config.js +++ b/lib/commands/config.js @@ -61,11 +61,27 @@ const isProtected = (k) => { return false } +// @npmcli/redact masks a URL password but leaves userinfo with an empty +// password in place, so a token carried as the username (`https://token:@host`, +// the usual CI proxy shape) is printed verbatim. Mask that userinfo too, +// matching the `user:***@host` treatment of non-empty passwords. +const redactUserinfo = (value) => { + try { + const url = new URL(value) + if (url.username && !url.password) { + return value.replace(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/@]*@/, '$1***@') + } + return value + } catch { + return value + } +} + // Private fields are either protected or they can redacted info -const isPrivate = (k, v) => isProtected(k) || redact(v) !== v +const isPrivate = (k, v) => isProtected(k) || redactUserinfo(redact(v)) !== v const displayVar = (k, v) => - `${k} = ${isProtected(k, v) ? '(protected)' : JSON.stringify(redact(v))}` + `${k} = ${isProtected(k, v) ? '(protected)' : JSON.stringify(redactUserinfo(redact(v)))}` class Config extends BaseCommand { static description = 'Manage the npm configuration files' @@ -353,7 +369,7 @@ ${defData} const lowerKey = key.toLowerCase() if (proxyEnv.includes(lowerKey) && !foundEnvVars.has(lowerKey)) { foundEnvVars.add(lowerKey) - envVars.push(`; ${key} = ${JSON.stringify(process.env[key])}`) + envVars.push(`; ${key} = ${JSON.stringify(redactUserinfo(redact(process.env[key])))}`) } } diff --git a/test/lib/commands/config.js b/test/lib/commands/config.js index 1ab9bbae67bd1..ec39486652c40 100644 --- a/test/lib/commands/config.js +++ b/test/lib/commands/config.js @@ -142,6 +142,78 @@ t.test('config list with proxy environment variables', async t => { t.match(output, 'environment-related config') }) +t.test('config list redacts credentials in proxy environment variables', async t => { + const saved = {} + for (const key of ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY']) { + saved[key] = process.env[key] + delete process.env[key] + } + t.teardown(() => { + for (const key of ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY']) { + if (saved[key] !== undefined) { + process.env[key] = saved[key] + } else { + delete process.env[key] + } + } + }) + + // issue 9773: password in the proxy userinfo + process.env.HTTP_PROXY = 'http://proxy-user:proxy-password@proxy.example.com:80' + // token as the userinfo with an explicit empty password (CI proxy shape) + process.env.HTTPS_PROXY = 'https://ci-token:@secure-proxy.example.com:8443' + // control: no userinfo, must come through untouched + process.env.NO_PROXY = 'localhost,127.0.0.1' + + const { npm, joinedOutput } = await loadMockNpm(t) + await npm.exec('config', ['list']) + const output = joinedOutput() + + t.notMatch(output, 'proxy-password', 'password is not printed') + t.match(output, 'HTTP_PROXY = "http://proxy-user:***@proxy.example.com:80"') + t.notMatch(output, 'ci-token', 'empty-password userinfo token is not printed') + t.match(output, 'HTTPS_PROXY = "https://***@secure-proxy.example.com:8443"') + t.match(output, 'NO_PROXY = "localhost,127.0.0.1"') +}) + +t.test('config list redacts empty-password userinfo in config values', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: { + '.npmrc': 'proxy=https://mytoken:@proxy.example.com\nhttps-proxy=https://secure-proxy.example.com:8443', + }, + }) + + await npm.exec('config', ['list']) + const output = joinedOutput() + + t.notMatch(output, 'mytoken', 'token-as-username is not printed') + t.match(output, 'proxy = "https://***@proxy.example.com"') + t.match(output, 'https-proxy = "https://secure-proxy.example.com:8443"', 'credential-less value unchanged') + + await t.rejects( + npm.exec('config', ['get', 'proxy']), + /protected/, + 'config get rejects the credential-bearing value like any other private value' + ) +}) + +t.test('config list --json omits empty-password userinfo values', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: { + '.npmrc': 'proxy=https://mytoken:@proxy.example.com\nhttps-proxy=https://secure-proxy.example.com:8443', + }, + config: { + json: true, + }, + }) + + await npm.exec('config', ['list']) + const output = joinedOutput() + + t.notMatch(output, 'mytoken', 'token-as-username is not in the json output') + t.match(output, 'secure-proxy.example.com', 'credential-less value is still listed') +}) + t.test('config list --long', async t => { const { npm, joinedOutput } = await loadMockNpm(t, { prefixDir: {