Skip to content

Commit d18e4e1

Browse files
committed
fix: do not write linkpaths through symlinks
Prevent any `Link` or `SymbolicLink` entry from being created if its `linkpath` would target a location that is through a symbolic link from the current working directory. This matches the behavior of `bsdtar` for hard links, and is somewhat more restrictive in applying the same logic to symbolic links as well. Unpacking links with targets that extend through symlink folders is allowed if `preservePaths` option is enabled, as this disables all protective link checking by design, and is only designed for use with trusted input. Fix: GHSA-83g3-92jg-28cx
1 parent 4a37eb9 commit d18e4e1

4 files changed

Lines changed: 159 additions & 13 deletions

File tree

src/process-umask.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// separate file so I stop getting nagged in vim about deprecated API.
2+
export const umask = () => process.umask()

src/unpack.ts

Lines changed: 87 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import { TarOptions } from './options.js'
2020
import { PathReservations } from './path-reservations.js'
2121
import { ReadEntry } from './read-entry.js'
2222
import { WarnData } from './warn-method.js'
23+
import { SymlinkError } from './symlink-error.js'
24+
import { umask } from './process-umask.js'
2325

2426
const ONENTRY = Symbol('onEntry')
2527
const CHECKFS = Symbol('checkFs')
@@ -31,6 +33,7 @@ const DIRECTORY = Symbol('directory')
3133
const LINK = Symbol('link')
3234
const SYMLINK = Symbol('symlink')
3335
const HARDLINK = Symbol('hardlink')
36+
const ENSURE_NO_SYMLINK = Symbol('ensureNoSymlink')
3437
const UNSUPPORTED = Symbol('unsupported')
3538
const CHECKPATH = Symbol('checkPath')
3639
const STRIPABSOLUTEPATH = Symbol('stripAbsolutePath')
@@ -235,7 +238,7 @@ export class Unpack extends Parser {
235238
this.processUmask =
236239
!this.chmod ? 0
237240
: typeof opt.processUmask === 'number' ? opt.processUmask
238-
: process.umask()
241+
: umask()
239242
this.umask =
240243
typeof opt.umask === 'number' ? opt.umask : this.processUmask
241244

@@ -332,6 +335,7 @@ export class Unpack extends Parser {
332335
return true
333336
}
334337

338+
// no IO, just string checking for absolute indicators
335339
[CHECKPATH](entry: ReadEntry) {
336340
const p = normalizeWindowsPath(entry.path)
337341
const parts = p.split('/')
@@ -663,14 +667,66 @@ export class Unpack extends Parser {
663667
}
664668

665669
[SYMLINK](entry: ReadEntry, done: () => void) {
666-
this[LINK](entry, String(entry.linkpath), 'symlink', done)
670+
const parts = normalizeWindowsPath(
671+
path.relative(
672+
this.cwd,
673+
path.resolve(
674+
path.dirname(String(entry.absolute)),
675+
String(entry.linkpath),
676+
),
677+
),
678+
).split('/')
679+
this[ENSURE_NO_SYMLINK](
680+
entry,
681+
this.cwd,
682+
parts,
683+
() =>
684+
this[LINK](entry, String(entry.linkpath), 'symlink', done),
685+
er => {
686+
this[ONERROR](er, entry)
687+
done()
688+
},
689+
)
667690
}
668691

669692
[HARDLINK](entry: ReadEntry, done: () => void) {
670693
const linkpath = normalizeWindowsPath(
671694
path.resolve(this.cwd, String(entry.linkpath)),
672695
)
673-
this[LINK](entry, linkpath, 'link', done)
696+
const parts = normalizeWindowsPath(String(entry.linkpath)).split(
697+
'/',
698+
)
699+
this[ENSURE_NO_SYMLINK](
700+
entry,
701+
this.cwd,
702+
parts,
703+
() => this[LINK](entry, linkpath, 'link', done),
704+
er => {
705+
this[ONERROR](er, entry)
706+
done()
707+
},
708+
)
709+
}
710+
711+
[ENSURE_NO_SYMLINK](
712+
entry: ReadEntry,
713+
cwd: string,
714+
parts: string[],
715+
done: () => void,
716+
onError: (er: SymlinkError) => void,
717+
) {
718+
const p = parts.shift()
719+
if (this.preservePaths || p === undefined) return done()
720+
const t = path.resolve(cwd, p)
721+
fs.lstat(t, (er, st) => {
722+
if (er) return done()
723+
if (st?.isSymbolicLink()) {
724+
return onError(
725+
new SymlinkError(t, path.resolve(t, parts.join('/'))),
726+
)
727+
}
728+
this[ENSURE_NO_SYMLINK](entry, t, parts, done, onError)
729+
})
674730
}
675731

676732
[PEND]() {
@@ -851,7 +907,6 @@ export class Unpack extends Parser {
851907
link: 'link' | 'symlink',
852908
done: () => void,
853909
) {
854-
// XXX: get the type ('symlink' or 'junction') for windows
855910
fs[link](linkpath, String(entry.absolute), er => {
856911
if (er) {
857912
this[ONERROR](er, entry)
@@ -864,11 +919,13 @@ export class Unpack extends Parser {
864919
}
865920
}
866921

867-
const callSync = (fn: () => any) => {
922+
const callSync = <T>(
923+
fn: () => T,
924+
): [null, T] | [NodeJS.ErrnoException, null] => {
868925
try {
869926
return [null, fn()]
870927
} catch (er) {
871-
return [er, null]
928+
return [er as NodeJS.ErrnoException, null]
872929
}
873930
}
874931

@@ -1089,15 +1146,37 @@ export class UnpackSync extends Unpack {
10891146
}
10901147
}
10911148

1149+
[ENSURE_NO_SYMLINK](
1150+
_entry: ReadEntry,
1151+
cwd: string,
1152+
parts: string[],
1153+
done: () => void,
1154+
onError: (er: SymlinkError) => void,
1155+
) {
1156+
if (this.preservePaths || !parts.length) return done()
1157+
let t = cwd
1158+
for (const p of parts) {
1159+
t = path.resolve(t, p)
1160+
const [er, st] = callSync(() => fs.lstatSync(t))
1161+
if (er) return done()
1162+
if (st.isSymbolicLink()) {
1163+
return onError(
1164+
new SymlinkError(t, path.resolve(t, parts.join('/'))),
1165+
)
1166+
}
1167+
}
1168+
done()
1169+
}
1170+
10921171
[LINK](
10931172
entry: ReadEntry,
10941173
linkpath: string,
10951174
link: 'link' | 'symlink',
10961175
done: () => void,
10971176
) {
1098-
const ls: `${typeof link}Sync` = `${link}Sync`
1177+
const linkSync: `${typeof link}Sync` = `${link}Sync`
10991178
try {
1100-
fs[ls](linkpath, String(entry.absolute))
1179+
fs[linkSync](linkpath, String(entry.absolute))
11011180
done()
11021181
entry.resume()
11031182
} catch (er) {

test/process-umask.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import t from 'tap'
2+
import { umask } from '../src/process-umask.js'
3+
t.equal(umask(), process.umask())

test/unpack.js

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Unpack, UnpackSync } from '../dist/esm/unpack.js'
22

3-
import fs, { readdirSync, statSync } from 'fs'
3+
import fs from 'fs'
44
import { Minipass } from 'minipass'
55
import * as z from 'minizlib'
66
import path from 'path'
@@ -3317,7 +3317,7 @@ t.test('ignore self-referential hardlinks', async t => {
33173317
])
33183318
const check = (t, warnings) => {
33193319
t.matchSnapshot(warnings)
3320-
t.strictSame(readdirSync(t.testdirName), [], 'nothing extracted')
3320+
t.strictSame(fs.readdirSync(t.testdirName), [], 'nothing extracted')
33213321
t.end()
33223322
}
33233323
t.test('async', t => {
@@ -3417,8 +3417,70 @@ t.test(
34173417
fs.readFileSync(dir + '/bar/link.txt', 'utf8'),
34183418
'hello world',
34193419
)
3420-
t.throws(() => statSync(dir+ '/bar/badlink.txt'))
3421-
t.match(warnings, [['TAR_ENTRY_ERROR', 'linkpath escapes extraction directory']])
3422-
3420+
t.throws(() => fs.statSync(dir + '/bar/badlink.txt'))
3421+
t.match(warnings, [
3422+
['TAR_ENTRY_ERROR', 'linkpath escapes extraction directory'],
3423+
])
34233424
},
34243425
)
3426+
3427+
t.test('no linking through a symlink', t => {
3428+
const types = ['Link', 'SymbolicLink']
3429+
for (const type of types) {
3430+
t.test(type, t => {
3431+
const exploit = makeTar([
3432+
{
3433+
type: 'SymbolicLink',
3434+
path: 'a/b/up',
3435+
linkpath: '../..',
3436+
mode: 0o755,
3437+
},
3438+
{
3439+
type: 'SymbolicLink',
3440+
path: 'a/b/escape',
3441+
linkpath: 'up/..',
3442+
mode: 0o755,
3443+
},
3444+
{
3445+
type,
3446+
path: 'exploit',
3447+
linkpath: 'a/b/escape/exploited-file',
3448+
mode: 0o755,
3449+
},
3450+
'',
3451+
'',
3452+
])
3453+
const setup = t => t.testdir({
3454+
x: {},
3455+
'exploited-file': 'original content',
3456+
})
3457+
const check = t => {
3458+
fs.writeFileSync(t.testdirName + '/x/exploit', 'pwned')
3459+
t.equal(
3460+
fs.readFileSync(t.testdirName + '/exploited-file', 'utf8'),
3461+
'original content',
3462+
)
3463+
}
3464+
t.test('sync', t => {
3465+
const cwd = setup(t)
3466+
t.throws(() => {
3467+
new UnpackSync({ cwd, strict: true }).end(exploit)
3468+
})
3469+
check(t)
3470+
t.end()
3471+
})
3472+
t.test('async', async t => {
3473+
const cwd = setup(t)
3474+
await t.rejects(new Promise((res, rej) => {
3475+
new Unpack({ cwd, strict: true })
3476+
.on('finish', res)
3477+
.on('error', rej)
3478+
.end(exploit)
3479+
}))
3480+
check(t)
3481+
})
3482+
t.end()
3483+
})
3484+
}
3485+
t.end()
3486+
})

0 commit comments

Comments
 (0)