Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions lib/commands/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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])))}`)
}
}

Expand Down
72 changes: 72 additions & 0 deletions test/lib/commands/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down