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
26 changes: 25 additions & 1 deletion lib/commands/token.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,38 @@
const { log, output, META } = require('proc-log')
const fetch = require('npm-registry-fetch')
const { URL } = require('node:url')
const { otplease } = require('../utils/auth.js')
const readUserInfo = require('../utils/read-user-info.js')
const BaseCommand = require('../base-cmd.js')

// The pagination link comes from the registry response, so it can point
// anywhere. npm-registry-fetch attaches the registry credentials to any
// request whose host matches the registry host, which means a link that
// switches the scheme (an https registry answering with an http link) would
// send the bearer token in cleartext, and a link pointing at another origin
// turns the client into a request generator for a host the user never
// configured. Only follow links that stay on the configured registry origin.
const nextUrl = (href, { registry } = {}) => {
if (typeof href !== 'string' || !href) {
return null
}
try {
const next = new URL(href, registry)
return next.origin === new URL(registry).origin ? next.toString() : null
} catch {
return null
}
}

async function paginate (href, opts, items = []) {
while (href) {
const result = await fetch.json(href, opts)
items = items.concat(result.objects)
href = result.urls.next
const link = result.urls?.next
href = nextUrl(link, opts)
if (link && !href) {
log.warn('token', `ignoring pagination link that leaves the registry origin: ${link}`)
}
}
return items
}
Expand Down
111 changes: 111 additions & 0 deletions test/lib/commands/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,117 @@ t.test('token list', async t => {
])
})

t.test('token list follows pagination links on the registry origin', async t => {
const { npm, outputs } = await loadMockNpm(t, {
config: { ...auth },
})
const registryOrigin = 'https://registry.npmjs.org'
const registry = new MockRegistry({
tap: t,
registry: npm.config.get('registry'),
authorization: authToken,
})
registry.nock.get('/-/npm/v1/tokens')
.reply(200, {
objects: [tokens[0]],
urls: {
next: `${registryOrigin}/-/npm/v1/tokens?next=abcd1234abcd1234`,
},
total: tokens.length,
userHasOldFormatToken: false,
})
registry.nock.get('/-/npm/v1/tokens')
.query({ next: 'abcd1234abcd1234' })
.reply(200, {
objects: [tokens[1]],
urls: {},
total: tokens.length,
userHasOldFormatToken: false,
})
await npm.exec('token', [])
t.strictSame(outputs, [
`Token efgh5678efgh5678… with id abcd123 name abcd001 created ${now.slice(0, 10)}`,
'',
`Token hgfe8765… with id abcd125 name abcd002 created ${now.slice(0, 10)}`,
'with IP whitelist: 192.168.1.1/32',
'',
])
})

t.test('token list does not follow a pagination link off the registry origin', async t => {
const { npm, outputs, logs } = await loadMockNpm(t, {
config: { ...auth },
})
const registry = new MockRegistry({
tap: t,
registry: npm.config.get('registry'),
authorization: authToken,
})
// a scheme downgrade to http leaves the registry origin even though the
// host is unchanged, and would carry the bearer token in cleartext
registry.nock.get('/-/npm/v1/tokens')
.reply(200, {
objects: [tokens[0]],
urls: {
next: 'http://registry.npmjs.org/-/npm/v1/tokens?next=abcd1234abcd1234',
},
total: tokens.length,
userHasOldFormatToken: false,
})
await npm.exec('token', [])
t.strictSame(outputs, [
`Token efgh5678efgh5678… with id abcd12 name abcd001 created ${now.slice(0, 10)}`,
'',
])
t.match(logs.warn, /ignoring pagination link that leaves the registry origin/)
})

t.test('token list does not follow a pagination link to another host', async t => {
const { npm, outputs, logs } = await loadMockNpm(t, {
config: { ...auth },
})
const registry = new MockRegistry({
tap: t,
registry: npm.config.get('registry'),
authorization: authToken,
})
registry.nock.get('/-/npm/v1/tokens')
.reply(200, {
objects: [tokens[0]],
urls: {
next: 'https://tokens.example.com/-/npm/v1/tokens?next=abcd1234abcd1234',
},
total: tokens.length,
userHasOldFormatToken: false,
})
await npm.exec('token', [])
t.strictSame(outputs, [
`Token efgh5678efgh5678… with id abcd12 name abcd001 created ${now.slice(0, 10)}`,
'',
])
t.match(logs.warn, /ignoring pagination link that leaves the registry origin/)
})

t.test('token list ignores a missing pagination url object', async t => {
const { npm, outputs } = await loadMockNpm(t, {
config: { ...auth },
})
const registry = new MockRegistry({
tap: t,
registry: npm.config.get('registry'),
authorization: authToken,
})
// a registry that omits urls entirely used to crash the command
registry.nock.get('/-/npm/v1/tokens')
.reply(200, {
objects: tokens,
total: tokens.length,
userHasOldFormatToken: false,
})
await npm.exec('token', [])
t.equal(outputs.length, 5)
})

t.test('token list json output', async t => {
const { npm, joinedOutput } = await loadMockNpm(t, {
config: {
Expand Down