@@ -4065,6 +4065,128 @@ t.test('workspace installs retain existing versions with newer package specs', a
40654065 'another-cool-package package.json should be updated to abbrev@1.0.4' )
40664066} )
40674067
4068+ for ( const poisoned of [ '../../../escape-target' , '@evil/../../../escape-target' ] ) {
4069+ t . test ( `install strategy linked sanitizes traversal in lockfile name (${ poisoned } )` , async t => {
4070+ // a poisoned lockfile name field would otherwise escape node_modules/.store
4071+ const testDir = t . testdir ( {
4072+ 'package.json' : JSON . stringify ( {
4073+ dependencies : {
4074+ abbrev : '1.1.1' ,
4075+ } ,
4076+ } ) ,
4077+ 'package-lock.json' : JSON . stringify ( {
4078+ lockfileVersion : 3 ,
4079+ requires : true ,
4080+ packages : {
4081+ '' : {
4082+ dependencies : {
4083+ abbrev : '1.1.1' ,
4084+ } ,
4085+ } ,
4086+ 'node_modules/abbrev' : {
4087+ name : poisoned ,
4088+ version : '1.1.1' ,
4089+ resolved : 'https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz' ,
4090+ integrity : 'sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==' ,
4091+ } ,
4092+ } ,
4093+ } ) ,
4094+ } )
4095+
4096+ const arb = new Arborist ( {
4097+ path : testDir ,
4098+ registry : 'https://registry.npmjs.org' ,
4099+ cache : resolve ( testDir , 'cache' ) ,
4100+ installStrategy : 'linked' ,
4101+ packageLockOnly : true ,
4102+ } )
4103+ await arb . reify ( { installStrategy : 'linked' , packageLockOnly : true } )
4104+
4105+ const external = arb . idealGraph . external
4106+ t . equal ( external . length , 1 , 'one external dep planned' )
4107+
4108+ const pkgName = external [ 0 ] . packageName
4109+ t . notMatch ( pkgName , / \. \. / , 'packageName has no traversal segments' )
4110+ t . ok ( ! pkgName . includes ( '/' ) || pkgName . startsWith ( '@' ) ,
4111+ 'packageName is a single segment (or @scope/name)' )
4112+
4113+ // joining the sanitized name into the .store layout must not escape
4114+ const storePrefix = resolve ( testDir , 'node_modules/.store/key/node_modules' )
4115+ const projected = resolve ( storePrefix , pkgName )
4116+ t . ok ( projected . startsWith ( storePrefix ) , 'projected path stays inside .store' )
4117+
4118+ // belt-and-suspenders: nothing should have been written outside testDir,
4119+ // even if a future change starts materializing paths during reify
4120+ t . notOk ( fs . existsSync ( resolve ( testDir , '..' , 'escape-target' ) ) ,
4121+ 'no escape-target leaked one level above testDir' )
4122+ t . notOk ( fs . existsSync ( resolve ( testDir , '..' , '..' , 'escape-target' ) ) ,
4123+ 'no escape-target leaked two levels above testDir' )
4124+ t . notOk ( fs . existsSync ( resolve ( testDir , '..' , '..' , '..' , 'escape-target' ) ) ,
4125+ 'no escape-target leaked three levels above testDir' )
4126+ } )
4127+ }
4128+
4129+ for ( const poisoned of [ '../../../escape-target' , '@evil/../../../escape-target' ] ) {
4130+ t . test ( `install strategy linked sanitizes traversal in shrinkwrapped lockfile name (${ poisoned } )` , async t => {
4131+ // the hasShrinkwrap branch in #externalProxy materializes
4132+ // node_modules/.store/<name>@<version> via mkdirSync before any other
4133+ // check, so a poisoned name in a shrinkwrapped entry would escape
4134+ // node_modules/.store on disk without sanitization
4135+ const testDir = t . testdir ( {
4136+ 'package.json' : JSON . stringify ( {
4137+ dependencies : {
4138+ abbrev : '1.1.1' ,
4139+ } ,
4140+ } ) ,
4141+ 'package-lock.json' : JSON . stringify ( {
4142+ lockfileVersion : 3 ,
4143+ requires : true ,
4144+ packages : {
4145+ '' : {
4146+ dependencies : {
4147+ abbrev : '1.1.1' ,
4148+ } ,
4149+ } ,
4150+ 'node_modules/abbrev' : {
4151+ name : poisoned ,
4152+ version : '1.1.1' ,
4153+ hasShrinkwrap : true ,
4154+ resolved : 'https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz' ,
4155+ integrity : 'sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==' ,
4156+ } ,
4157+ } ,
4158+ } ) ,
4159+ } )
4160+
4161+ const arb = new Arborist ( {
4162+ path : testDir ,
4163+ registry : 'https://registry.npmjs.org' ,
4164+ cache : resolve ( testDir , 'cache' ) ,
4165+ installStrategy : 'linked' ,
4166+ packageLockOnly : true ,
4167+ } )
4168+ // pacote.extract may fail in this offline test setup; the mkdirSync that
4169+ // runs before it is what we care about and runs unconditionally
4170+ try {
4171+ await arb . reify ( { installStrategy : 'linked' , packageLockOnly : true } )
4172+ } catch {
4173+ // expected in offline test setup
4174+ }
4175+
4176+ // the sanitized store entry must land inside testDir/node_modules/.store
4177+ t . ok ( fs . existsSync ( resolve ( testDir , 'node_modules/.store/escape-target@1.1.1' ) ) ,
4178+ 'sanitized .store/escape-target@1.1.1 created inside the project' )
4179+
4180+ // and no escape-target@1.1.1 directory should appear above testDir
4181+ t . notOk ( fs . existsSync ( resolve ( testDir , '..' , 'escape-target@1.1.1' ) ) ,
4182+ 'no escape-target@1.1.1 leaked one level above testDir' )
4183+ t . notOk ( fs . existsSync ( resolve ( testDir , '..' , '..' , 'escape-target@1.1.1' ) ) ,
4184+ 'no escape-target@1.1.1 leaked two levels above testDir' )
4185+ t . notOk ( fs . existsSync ( resolve ( testDir , '..' , '..' , '..' , 'escape-target@1.1.1' ) ) ,
4186+ 'no escape-target@1.1.1 leaked three levels above testDir' )
4187+ } )
4188+ }
4189+
40684190t . test ( 'externalOptionalDependencies excludes inert optional node with installStrategy linked' , async t => {
40694191 const testDir = t . testdir ( {
40704192 'package.json' : JSON . stringify ( {
0 commit comments