Skip to content

Commit 32b21a8

Browse files
Jvr2022isaacs
authored andcommitted
fix(extract): prevent raced symlink writes outside cwd
Fix a race in file extraction where a destination path could be swapped to a symlink after the existing `lstat()` check but before the file was opened. This change uses `O_NOFOLLOW` for extracted file writes on non-Windows platforms so the final open does not follow a symlink at the destination path. Also adds a regression test that reproduces the race and checks that extraction does not overwrite a file outside the target directory. PR-URL: isaacs#456 Credit: @Jvr2022 Close: isaacs#456 Reviewed-by: @isaacs
1 parent 2a294d3 commit 32b21a8

3 files changed

Lines changed: 93 additions & 5 deletions

File tree

src/get-write-flag.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const platform = process.env.__FAKE_PLATFORM__ || process.platform
1212
const isWindows = platform === 'win32'
1313

1414
/* c8 ignore start */
15-
const { O_CREAT, O_TRUNC, O_WRONLY } = fs.constants
15+
const { O_CREAT, O_NOFOLLOW, O_TRUNC, O_WRONLY } = fs.constants
1616
const UV_FS_O_FILEMAP =
1717
Number(process.env.__FAKE_FS_O_FILENAME__) ||
1818
fs.constants.UV_FS_O_FILEMAP ||
@@ -22,7 +22,11 @@ const UV_FS_O_FILEMAP =
2222
const fMapEnabled = isWindows && !!UV_FS_O_FILEMAP
2323
const fMapLimit = 512 * 1024
2424
const fMapFlag = UV_FS_O_FILEMAP | O_TRUNC | O_CREAT | O_WRONLY
25+
const noFollowFlag =
26+
!isWindows && typeof O_NOFOLLOW === 'number' ?
27+
O_NOFOLLOW | O_TRUNC | O_CREAT | O_WRONLY
28+
: null
2529
export const getWriteFlag =
26-
!fMapEnabled ?
27-
() => 'w'
30+
noFollowFlag !== null ? () => noFollowFlag
31+
: !fMapEnabled ? () => 'w'
2832
: (size: number) => (size < fMapLimit ? fMapFlag : 'w')

test/get-write-flag.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ const __filename = fileURLToPath(import.meta.url)
1313
const hasFmap = !!fs.constants.UV_FS_O_FILEMAP
1414
const { platform } = process
1515
const UV_FS_O_FILEMAP = 0x20000000
16+
const unixFlag =
17+
typeof fs.constants.O_NOFOLLOW === 'number' ?
18+
fs.constants.O_NOFOLLOW |
19+
fs.constants.O_TRUNC |
20+
fs.constants.O_CREAT |
21+
fs.constants.O_WRONLY
22+
: 'w'
1623

1724
switch (process.argv[2]) {
1825
case 'win32-fmap': {
@@ -32,8 +39,8 @@ switch (process.argv[2]) {
3239
}
3340

3441
case 'unix': {
35-
t.equal(getWriteFlag(1), 'w')
36-
t.equal(getWriteFlag(512 * 1024 + 1), 'w')
42+
t.equal(getWriteFlag(1), unixFlag)
43+
t.equal(getWriteFlag(512 * 1024 + 1), unixFlag)
3744
break
3845
}
3946

test/symlink-race-extract.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import fs, { lstatSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'
2+
import { resolve } from 'node:path'
3+
import t from 'tap'
4+
import { extract } from '../src/extract.js'
5+
import { Header } from '../src/header.js'
6+
7+
if (typeof fs.constants.O_NOFOLLOW !== 'number') {
8+
t.plan(0, 'no O_NOFOLLOW flag')
9+
process.exit(0)
10+
}
11+
12+
const makeTarball = () => {
13+
const header = Buffer.alloc(512)
14+
new Header({
15+
path: 'victim.txt',
16+
type: 'File',
17+
size: 6,
18+
}).encode(header)
19+
20+
return Buffer.concat([
21+
header,
22+
Buffer.from('PWNED\n'.padEnd(512, '\0')),
23+
Buffer.alloc(1024),
24+
])
25+
}
26+
27+
t.test('extract does not follow a raced-in symlink for file entries', async t => {
28+
const dir = t.testdir({
29+
cwd: {},
30+
'target.txt': 'ORIGINAL\n',
31+
})
32+
const cwd = resolve(dir, 'cwd')
33+
const target = resolve(dir, 'target.txt')
34+
const tarball = resolve(dir, 'poc.tar')
35+
const victim = resolve(cwd, 'victim.txt')
36+
writeFileSync(tarball, makeTarball())
37+
38+
const warnings: [code: string, message: string][] = []
39+
const lstat = fs.lstat
40+
let raced = false
41+
fs.lstat = ((path, options, cb) => {
42+
const callback =
43+
typeof options === 'function' ? options
44+
: (cb as Parameters<typeof fs.lstat>[1] & ((
45+
err: NodeJS.ErrnoException | null,
46+
stats: fs.Stats,
47+
) => void))
48+
if (!raced && String(path) === victim) {
49+
raced = true
50+
symlinkSync(target, victim)
51+
const er = Object.assign(new Error('raced symlink'), {
52+
code: 'ENOENT',
53+
})
54+
process.nextTick(() => callback(er, undefined as never))
55+
return
56+
}
57+
return typeof options === 'function' ?
58+
lstat(path, options)
59+
: lstat(path, options, cb as never)
60+
}) as typeof fs.lstat
61+
t.teardown(() => {
62+
fs.lstat = lstat
63+
})
64+
65+
await extract({
66+
cwd,
67+
file: tarball,
68+
onwarn: (code, message) => warnings.push([code, String(message)]),
69+
})
70+
71+
t.equal(readFileSync(target, 'utf8'), 'ORIGINAL\n')
72+
t.equal(lstatSync(victim).isSymbolicLink(), true)
73+
t.match(warnings, [[
74+
'TAR_ENTRY_ERROR',
75+
/ELOOP|symbolic link|Too many symbolic links/,
76+
]])
77+
})

0 commit comments

Comments
 (0)