diff --git a/lib/utils/allow-scripts-writer.js b/lib/utils/allow-scripts-writer.js index 6964279f2f2e0..a777b2c3964bd 100644 --- a/lib/utils/allow-scripts-writer.js +++ b/lib/utils/allow-scripts-writer.js @@ -3,6 +3,7 @@ const { log } = require('proc-log') const { getTrustedRegistryIdentity, resolvedSourceSpecs, + normalizeFileSpec, } = require('@npmcli/arborist/lib/script-allowed.js') // Pure helpers that implement the RFC's pin-mismatch table for @@ -56,8 +57,8 @@ const versionedKeyFor = (node) => { ) return null } - /* istanbul ignore next: 'file:' and '/' branches are each covered separately */ - if (resolved.startsWith('file:') || resolved.startsWith('/')) { + /* istanbul ignore next: 'file:', '/', and Windows drive letter branches are covered */ + if (resolved.startsWith('file:') || resolved.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(resolved)) { return resolved } // No trusted source. Refuse to compose a key from attacker-controlled @@ -86,7 +87,7 @@ const nameKeyFor = (node) => { } return null } - if (resolved.startsWith('file:') || resolved.startsWith('/')) { + if (resolved.startsWith('file:') || resolved.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(resolved)) { return resolved } // Registry deps: only the URL-derived (or edges-derived, in the @@ -179,6 +180,16 @@ const keyTargetsNode = (key, node) => { } case 'file': case 'directory': + return resolvedSourceSpecs(node) + .some(resolved => { + const normResolved = normalizeFileSpec(resolved) + const normSave = normalizeFileSpec(parsed.saveSpec) + const normFetch = normalizeFileSpec(parsed.fetchSpec) + return ( + (normSave !== '' && normResolved === normSave) || + (normFetch !== '' && normResolved === normFetch) + ) + }) case 'remote': return resolvedSourceSpecs(node) .some(resolved => resolved === parsed.saveSpec || resolved === parsed.fetchSpec) diff --git a/workspaces/arborist/lib/script-allowed.js b/workspaces/arborist/lib/script-allowed.js index 8c9b3fe118a8e..152f6ed05cc50 100644 --- a/workspaces/arborist/lib/script-allowed.js +++ b/workspaces/arborist/lib/script-allowed.js @@ -327,9 +327,25 @@ const matchGit = (node, parsed) => { return nodeCommittish.startsWith(keyCommittish) } +const normalizeFileSpec = (spec) => { + if (typeof spec !== 'string' || spec === '') { + return '' + } + const withoutFile = spec.startsWith('file:') ? spec.slice(5) : spec + return withoutFile.replace(/\\/g, '/') +} + const matchFileOrDir = (node, parsed) => { + const normSave = normalizeFileSpec(parsed.saveSpec) + const normFetch = normalizeFileSpec(parsed.fetchSpec) return resolvedSourceSpecs(node) - .some(resolved => resolved === parsed.saveSpec || resolved === parsed.fetchSpec) + .some(resolved => { + const normResolved = normalizeFileSpec(resolved) + return ( + (normSave !== '' && normResolved === normSave) || + (normFetch !== '' && normResolved === normFetch) + ) + }) } const matchRemote = (node, parsed) => { @@ -382,3 +398,4 @@ module.exports.isExactVersionDisjunction = isExactVersionDisjunction module.exports.getTrustedRegistryIdentity = getTrustedRegistryIdentity module.exports.resolvedSourceSpecs = resolvedSourceSpecs module.exports.trustedDisplay = trustedDisplay +module.exports.normalizeFileSpec = normalizeFileSpec diff --git a/workspaces/arborist/test/script-allowed.js b/workspaces/arborist/test/script-allowed.js index 218ccf1e28888..78abcda9c545b 100644 --- a/workspaces/arborist/test/script-allowed.js +++ b/workspaces/arborist/test/script-allowed.js @@ -196,6 +196,42 @@ t.test('local tarball key — npa parses *.tgz paths as type=file', t => { t.end() }) +t.test('local tarball key — Windows backslashes and file: prefix matching', t => { + const winTgzNode = node({ + name: 'electron-winstaller', + packageName: 'electron-winstaller', + version: '5.3.0', + resolved: 'file:C:\\absolute\\path\\to\\electron-winstaller-5.3.0.tgz', + }) + + // Matches forward slash file: URI + t.equal(isScriptAllowed(winTgzNode, { + 'file:C:/absolute/path/to/electron-winstaller-5.3.0.tgz': true, + }), true) + + // Matches backslash file: URI + t.equal(isScriptAllowed(winTgzNode, { + 'file:C:\\absolute\\path\\to\\electron-winstaller-5.3.0.tgz': true, + }), true) + + // Matches absolute path without file: prefix + t.equal(isScriptAllowed(winTgzNode, { + 'C:/absolute/path/to/electron-winstaller-5.3.0.tgz': true, + }), true) + + // Deny wins + t.equal(isScriptAllowed(winTgzNode, { + 'file:C:/absolute/path/to/electron-winstaller-5.3.0.tgz': false, + }), false) + + // Unrelated path does not match + t.equal(isScriptAllowed(winTgzNode, { + 'file:C:/other/path/to/pkg.tgz': true, + }), null) + + t.end() +}) + t.test('remote tarball — exact resolved match', t => { const remoteNode = node({ name: 'pkg',