Skip to content

Commit 119c401

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: #456 Credit: @Jvr2022 Close: #456 Reviewed-by: @isaacs
1 parent 2a294d3 commit 119c401

File tree

3 files changed

+96
-5
lines changed

3 files changed

+96
-5
lines changed

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

0 commit comments

Comments
 (0)