Skip to content

Commit cf28207

Browse files
anandgupta42claude
andcommitted
fix: critical symlink/../ escape via realpathSync lexical normalization divergence
Gemini 3.1 Pro found that `realpathSync` and the OS kernel disagree on `symlink/../file.txt`: - `realpathSync("project/link/..")` → `project/` (lexical normalization) - `writeFile("project/link/../f")` → writes to parent of symlink TARGET (kernel) This means `containsReal` would approve a write that the OS places OUTSIDE the project boundary. The fix rejects any unresolved path containing `..` segments, since their behavior through symlinks is fundamentally unpredictable at the application level. Also adds `.github` to `SENSITIVE_DIRS` (workflow injection vector). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6c42fb6 commit cf28207

13 files changed

Lines changed: 308 additions & 12 deletions
Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
fix: address code review findingsrule ordering bug, cross-platform paths, TOCTOU docs
1+
fix: address multi-model review consensusmovePath guard, case-insensitive matching, expanded patterns
22

3-
- Fix critical bug: bash deny defaults had `"*": "ask"` LAST which overrode deny rules
4-
due to last-match-wins semantics. Moved `"*": "ask"` to first position so deny rules
5-
take precedence.
6-
- Fix all doc examples with same ordering bug (security-faq.md, permissions.md)
7-
- Fix `isSensitiveWrite` to use regex split `/[/\\]/` for cross-platform path handling
8-
- Allow per-path "Always" approval for sensitive file writes (reduces approval fatigue)
9-
- Document TOCTOU limitation in `containsReal` JSDoc
10-
- Add doc clarification about last-match-wins rule ordering with examples
11-
- Add tests: bash deny defaults evaluation, user override merge, Windows backslash paths
3+
Fixes from consensus across GPT 5.2, Kimi K2.5, MiniMax M2.5, and GLM-5 reviews:
4+
5+
- Add `assertSensitiveWrite(ctx, movePath)` for move destinations in `apply_patch`
6+
(CRITICAL: 3 models flagged that moves to `.ssh/`, `.env` bypassed sensitive check)
7+
- Add case-insensitive matching on macOS/Windows for sensitive dirs and files
8+
(`.GIT/config`, `.SSH/id_rsa` now correctly detected on case-insensitive FS)
9+
- Expand `SENSITIVE_FILES` with `.htpasswd`, `.pgpass`
10+
- Add `SENSITIVE_EXTENSIONS` for private keys: `.pem`, `.key`, `.p12`, `.pfx`
11+
- Add tests: case-insensitive matching, certificate extensions, credential files
1212

1313
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

packages/opencode/src/file/protected.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const WIN32_HOME = ["AppData", "Downloads", "Desktop", "Documents", "Pictures",
4545
*/
4646
const SENSITIVE_DIRS = [
4747
".git",
48+
".github",
4849
".ssh",
4950
".gnupg",
5051
".aws",

packages/opencode/src/util/filesystem.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,11 +181,27 @@ export namespace Filesystem {
181181
// Child doesn't exist — walk up to find nearest existing ancestor
182182
}
183183

184+
// SECURITY: If the raw child path contains '..' segments, reject it.
185+
// realpathSync normalizes '..' lexically (before symlink resolution),
186+
// but the OS kernel resolves symlinks THEN applies '..'. For example:
187+
// realpathSync("project/symlink/..") → project/ (lexical)
188+
// writeFile("project/symlink/../f") → writes outside project (kernel)
189+
// Since we can't trust realpathSync's resolution of paths with '..',
190+
// any path containing '..' that couldn't be fully resolved above is denied.
191+
const segments = child.split(/[/\\]/)
192+
if (segments.includes("..")) return false
193+
184194
// Walk up the directory tree to find the nearest existing ancestor,
185195
// then append the remaining segments. This handles write operations
186196
// where the target directory hasn't been created yet.
187-
const resolved = pathResolve(child)
188-
let current = resolved
197+
//
198+
// CRITICAL: realpathSync normalizes '..' lexically (before symlink resolution),
199+
// but the OS kernel resolves symlinks THEN applies '..'. For example:
200+
// realpathSync("project/symlink/..") → project/ (lexical)
201+
// writeFile("project/symlink/../f") → writes outside project (kernel)
202+
// Therefore, if any trailing segment is '..', we MUST deny the access since
203+
// we cannot predict where the OS will actually write.
204+
let current = child
189205
const trailing: string[] = []
190206
while (true) {
191207
try {

packages/opencode/test/file/security-e2e.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,32 @@ describe("E2E: symlink escape attacks", () => {
131131
})
132132
})
133133

134+
test("symlink/../file.txt escape for non-existent write target is blocked", async () => {
135+
// CRITICAL: This tests the Gemini-found vulnerability where path.resolve()
136+
// strips '..' lexically before symlinks are resolved. The agent writes to
137+
// /project/symlink/../secret.txt. Without the fix, pathResolve normalizes
138+
// this to /project/secret.txt (looks safe). With the fix, realpathSync on
139+
// /project/symlink/.. correctly follows the symlink first.
140+
await using outside = await tmpdir({
141+
init: async (dir) => {
142+
await Bun.write(path.join(dir, "existing.txt"), "data")
143+
},
144+
})
145+
146+
await using project = await tmpdir({
147+
init: async (dir) => {
148+
// symlink inside project pointing outside
149+
await fs.symlink(outside.path, path.join(dir, "link"))
150+
},
151+
})
152+
153+
// /project/link/../new-file.txt — OS resolves link to outside, then ..
154+
// goes to outside's parent. This must be DENIED.
155+
// NOTE: path.join normalizes away '..', so we construct the path manually
156+
const escapePath = project.path + "/link/../new-file.txt"
157+
expect(Filesystem.containsReal(project.path, escapePath)).toBe(false)
158+
})
159+
134160
test("relative symlink that resolves outside is blocked", async () => {
135161
await using outside = await tmpdir({
136162
init: async (dir) => {

test_bypass.cjs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
const fs = require('fs')
2+
const path = require('path')
3+
4+
// Fake containsReal implementation matching the one in the codebase
5+
function containsReal(parent, child) {
6+
let realParent;
7+
try {
8+
realParent = fs.realpathSync(parent)
9+
} catch {
10+
return false;
11+
}
12+
13+
try {
14+
const realChild = fs.realpathSync(child)
15+
const rel = path.relative(realParent, realChild)
16+
return !path.isAbsolute(rel) && !rel.startsWith("..")
17+
} catch {
18+
// Child doesn't exist — walk up to find nearest existing ancestor
19+
}
20+
21+
const resolved = path.resolve(child)
22+
let current = resolved
23+
const trailing = []
24+
while (true) {
25+
try {
26+
const realAncestor = fs.realpathSync(current)
27+
const realChild = trailing.length > 0 ? path.join(realAncestor, ...trailing) : realAncestor
28+
const rel = path.relative(realParent, realChild)
29+
return !path.isAbsolute(rel) && !rel.startsWith("..")
30+
} catch {
31+
const parent_ = path.dirname(current)
32+
if (parent_ === current) {
33+
return false;
34+
}
35+
trailing.unshift(path.basename(current))
36+
current = parent_
37+
}
38+
}
39+
}
40+
41+
const parent = '/tmp/project'
42+
const child = '/tmp/project/symlink/../new_secret.txt'
43+
44+
console.log("containsReal allows bypass write?:", containsReal(parent, child))

test_bypass_fix.cjs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
const fs = require('fs')
2+
const path = require('path')
3+
4+
function containsReal(parent, child) {
5+
let realParent = fs.realpathSync(parent)
6+
7+
let current = path.isAbsolute(child) ? child : path.resolve(child) // wait, path.resolve normalizes.
8+
// If it's relative, we can do path.join(process.cwd(), child) instead of path.resolve?
9+
// Let's test with absolute child to keep it simple.
10+
current = child;
11+
12+
const trailing = []
13+
while (true) {
14+
try {
15+
const realAncestor = fs.realpathSync(current)
16+
const realChild = trailing.length > 0 ? path.join(realAncestor, ...trailing) : realAncestor
17+
const rel = path.relative(realParent, realChild)
18+
return !path.isAbsolute(rel) && !rel.startsWith("..")
19+
} catch {
20+
const parent_ = path.dirname(current)
21+
if (parent_ === current) {
22+
return false;
23+
}
24+
trailing.unshift(path.basename(current))
25+
current = parent_
26+
}
27+
}
28+
}
29+
30+
console.log("Fixed allows bypass write?:", containsReal('/tmp/project', '/tmp/project/symlink/../new_secret.txt'))

test_bypass_fix2.cjs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
const fs = require('fs')
2+
const path = require('path')
3+
4+
function containsReal(parent, child) {
5+
let realParent = fs.realpathSync(parent)
6+
7+
let current = child;
8+
const trailing = []
9+
while (true) {
10+
try {
11+
const realAncestor = fs.realpathSync(current)
12+
console.log("Resolved", current, "->", realAncestor)
13+
const realChild = trailing.length > 0 ? path.join(realAncestor, ...trailing) : realAncestor
14+
console.log("realChild:", realChild)
15+
const rel = path.relative(realParent, realChild)
16+
return !path.isAbsolute(rel) && !rel.startsWith("..")
17+
} catch (e) {
18+
const parent_ = path.dirname(current)
19+
if (parent_ === current) {
20+
return false;
21+
}
22+
trailing.unshift(path.basename(current))
23+
current = parent_
24+
}
25+
}
26+
}
27+
28+
console.log("Fixed allows bypass write?:", containsReal('/tmp/project', '/tmp/project/symlink/../new_secret.txt'))

test_bypass_fix3.cjs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
const fs = require('fs')
2+
const path = require('path')
3+
4+
fs.rmSync('/tmp/project2', {recursive: true, force: true})
5+
fs.rmSync('/tmp/outside2', {recursive: true, force: true})
6+
7+
fs.mkdirSync('/tmp/project2', {recursive: true})
8+
fs.mkdirSync('/tmp/outside2/sub', {recursive: true})
9+
fs.symlinkSync('/tmp/outside2/sub', '/tmp/project2/symlink')
10+
11+
function containsReal(parent, child) {
12+
let realParent = fs.realpathSync(parent)
13+
14+
let current = child;
15+
const trailing = []
16+
while (true) {
17+
try {
18+
const realAncestor = fs.realpathSync(current)
19+
console.log("Resolved", current, "->", realAncestor)
20+
const realChild = trailing.length > 0 ? path.join(realAncestor, ...trailing) : realAncestor
21+
console.log("realChild:", realChild)
22+
const rel = path.relative(realParent, realChild)
23+
return !path.isAbsolute(rel) && !rel.startsWith("..")
24+
} catch (e) {
25+
const parent_ = path.dirname(current)
26+
if (parent_ === current) {
27+
return false;
28+
}
29+
trailing.unshift(path.basename(current))
30+
current = parent_
31+
}
32+
}
33+
}
34+
35+
console.log("Fixed allows bypass write?:", containsReal('/tmp/project2', '/tmp/project2/symlink/../new_secret.txt'))

test_bypass_fix4.cjs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
const fs = require('fs')
2+
const path = require('path')
3+
4+
function containsRealNative(parent, child) {
5+
let realParent = fs.realpathSync.native(parent)
6+
const resolved = path.resolve(child)
7+
let current = resolved
8+
const trailing = []
9+
while (true) {
10+
try {
11+
const realAncestor = fs.realpathSync.native(current)
12+
const realChild = trailing.length > 0 ? path.join(realAncestor, ...trailing) : realAncestor
13+
const rel = path.relative(realParent, realChild)
14+
return !path.isAbsolute(rel) && !rel.startsWith("..")
15+
} catch (e) {
16+
const parent_ = path.dirname(current)
17+
if (parent_ === current) {
18+
return false;
19+
}
20+
trailing.unshift(path.basename(current))
21+
current = parent_
22+
}
23+
}
24+
}
25+
26+
console.log("With .native but using path.resolve. bypass write?:", containsRealNative('/tmp/project2', '/tmp/project2/symlink/../new_secret2.txt'))

test_bypass_fix5.cjs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
const fs = require('fs')
2+
const path = require('path')
3+
4+
function containsRealNativeWithDirname(parent, child) {
5+
let realParent = fs.realpathSync.native(parent)
6+
let current = child // NO path.resolve(child)
7+
const trailing = []
8+
while (true) {
9+
try {
10+
const realAncestor = fs.realpathSync.native(current)
11+
const realChild = trailing.length > 0 ? path.join(realAncestor, ...trailing) : realAncestor
12+
const rel = path.relative(realParent, realChild)
13+
return !path.isAbsolute(rel) && !rel.startsWith("..")
14+
} catch (e) {
15+
const parent_ = path.dirname(current)
16+
if (parent_ === current) {
17+
return false;
18+
}
19+
trailing.unshift(path.basename(current))
20+
current = parent_
21+
}
22+
}
23+
}
24+
25+
console.log("With dirname and .native bypass write?:", containsRealNativeWithDirname('/tmp/project2', '/tmp/project2/symlink/../new_secret5.txt'))

0 commit comments

Comments
 (0)