Skip to content

Commit ac32679

Browse files
aksOpsPaperclip-Paperclipclaude
committed
fix(security): block path traversal via symlinks in /api/file and read_file (RAN-8)
Resolve symlinks with `Path.toRealPath()` and re-check the resolved path against the codebase root on both the REST `/api/file` endpoint and the MCP `read_file` tool. `Path.normalize()` is purely lexical and left symlinks inside the indexed repo usable for exfiltrating off-tree files (e.g. `link -> /etc/passwd`). - GraphController: canonicalize root, lexical guard, then toRealPath() and re-check; 404 on NoSuchFileException, 403 on out-of-root. - McpTools: same two-stage guard, returns "Path traversal detected". - Tests: positive (escape symlink rejected) + negative (in-repo symlink read succeeds) for both REST and MCP. Skip gracefully on filesystems without symlink support. Co-Authored-By: Paperclip <noreply@paperclip.ing> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cab71a4 commit ac32679

4 files changed

Lines changed: 123 additions & 8 deletions

File tree

src/main/java/io/github/randomcodespace/iq/api/GraphController.java

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import java.io.IOException;
2020
import java.nio.charset.StandardCharsets;
2121
import java.nio.file.Files;
22+
import java.nio.file.NoSuchFileException;
2223
import java.nio.file.Path;
2324
import java.util.Arrays;
2425
import java.util.List;
@@ -257,18 +258,40 @@ public ResponseEntity<String> readFile(
257258
@RequestParam String path,
258259
@RequestParam(required = false) Integer startLine,
259260
@RequestParam(required = false) Integer endLine) {
260-
Path codebasePath = Path.of(config.getRootPath()).toAbsolutePath().normalize();
261-
Path resolved = codebasePath.resolve(path).normalize();
262-
if (!resolved.startsWith(codebasePath)) {
261+
Path codebaseReal;
262+
try {
263+
codebaseReal = Path.of(config.getRootPath()).toRealPath();
264+
} catch (IOException e) {
265+
return ResponseEntity.status(500)
266+
.contentType(MediaType.TEXT_PLAIN)
267+
.body("Failed to resolve codebase root: " + e.getMessage());
268+
}
269+
Path candidate = codebaseReal.resolve(path).normalize();
270+
if (!candidate.startsWith(codebaseReal)) {
271+
return ResponseEntity.status(403)
272+
.contentType(MediaType.TEXT_PLAIN)
273+
.body("Path traversal blocked");
274+
}
275+
Path resolvedReal;
276+
try {
277+
resolvedReal = candidate.toRealPath();
278+
} catch (NoSuchFileException e) {
279+
return ResponseEntity.notFound().build();
280+
} catch (IOException e) {
281+
return ResponseEntity.status(500)
282+
.contentType(MediaType.TEXT_PLAIN)
283+
.body("Failed to resolve file: " + e.getMessage());
284+
}
285+
if (!resolvedReal.startsWith(codebaseReal)) {
263286
return ResponseEntity.status(403)
264287
.contentType(MediaType.TEXT_PLAIN)
265288
.body("Path traversal blocked");
266289
}
267-
if (!Files.isRegularFile(resolved)) {
290+
if (!Files.isRegularFile(resolvedReal)) {
268291
return ResponseEntity.notFound().build();
269292
}
270293
try {
271-
String content = Files.readString(resolved, StandardCharsets.UTF_8);
294+
String content = Files.readString(resolvedReal, StandardCharsets.UTF_8);
272295
if (startLine != null || endLine != null) {
273296
String[] lines = content.split("\n", -1);
274297
int start = (startLine != null ? startLine : 1);

src/main/java/io/github/randomcodespace/iq/mcp/McpTools.java

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -385,9 +385,15 @@ public String readFile(
385385
@McpToolParam(description = "Start line number, 1-based (optional — omit to read entire file)", required = false) Integer startLine,
386386
@McpToolParam(description = "End line number, 1-based inclusive (optional — omit to read to end)", required = false) Integer endLine) {
387387
try {
388-
Path root = Path.of(config.getRootPath()).toAbsolutePath().normalize();
389-
Path resolved = root.resolve(filePath).normalize();
390-
// Path traversal protection
388+
Path root = Path.of(config.getRootPath()).toRealPath();
389+
Path candidate = root.resolve(filePath).normalize();
390+
// Lexical traversal guard (rejects ../ before any filesystem touch)
391+
if (!candidate.startsWith(root)) {
392+
return toJson(Map.of(PROP_ERROR, "Path traversal detected"));
393+
}
394+
// Follow symlinks and re-check so an in-repo symlink pointing outside the
395+
// codebase (e.g. link -> /etc/passwd) cannot be used to exfiltrate files.
396+
Path resolved = candidate.toRealPath();
391397
if (!resolved.startsWith(root)) {
392398
return toJson(Map.of(PROP_ERROR, "Path traversal detected"));
393399
}

src/test/java/io/github/randomcodespace/iq/api/GraphControllerTest.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,49 @@ void readFileShouldReturnFullContentWithoutLineParams(@TempDir Path tempDir) thr
557557
.andExpect(content().string("aaa\nbbb\nccc"));
558558
}
559559

560+
@Test
561+
void readFileShouldRejectSymlinkEscapingRoot(@TempDir Path tempDir) throws Exception {
562+
Path target = Files.createTempFile("codeiq-escape-", ".txt");
563+
try {
564+
Files.writeString(target, "TOP SECRET", StandardCharsets.UTF_8);
565+
Path link = tempDir.resolve("leak.txt");
566+
try {
567+
Files.createSymbolicLink(link, target.toAbsolutePath());
568+
} catch (UnsupportedOperationException | java.io.IOException unsupported) {
569+
// Filesystem does not support symlinks (e.g. Windows without privilege) — skip.
570+
return;
571+
}
572+
CodeIqConfigTestSupport.override(config).rootPath(tempDir.toAbsolutePath().toString()).done();
573+
var controller = new GraphController(queryService, config);
574+
var fileMvc = MockMvcBuilders.standaloneSetup(controller).build();
575+
576+
fileMvc.perform(get("/api/file").param("path", "leak.txt"))
577+
.andExpect(status().isForbidden())
578+
.andExpect(content().string("Path traversal blocked"));
579+
} finally {
580+
Files.deleteIfExists(target);
581+
}
582+
}
583+
584+
@Test
585+
void readFileShouldAllowInRepoSymlink(@TempDir Path tempDir) throws Exception {
586+
Path real = tempDir.resolve("real.txt");
587+
Files.writeString(real, "in-repo", StandardCharsets.UTF_8);
588+
Path link = tempDir.resolve("alias.txt");
589+
try {
590+
Files.createSymbolicLink(link, real);
591+
} catch (UnsupportedOperationException | java.io.IOException unsupported) {
592+
return;
593+
}
594+
CodeIqConfigTestSupport.override(config).rootPath(tempDir.toAbsolutePath().toString()).done();
595+
var controller = new GraphController(queryService, config);
596+
var fileMvc = MockMvcBuilders.standaloneSetup(controller).build();
597+
598+
fileMvc.perform(get("/api/file").param("path", "alias.txt"))
599+
.andExpect(status().isOk())
600+
.andExpect(content().string("in-repo"));
601+
}
602+
560603
// POST /api/analyze removed — API is read-only
561604

562605
// --- /api/file-tree ---

src/test/java/io/github/randomcodespace/iq/mcp/McpToolsTest.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,4 +524,47 @@ void readFileShouldClampOutOfBoundsLineRange(@TempDir Path tempDir) throws IOExc
524524

525525
assertEquals("line2\nline3", result);
526526
}
527+
528+
@Test
529+
void readFileShouldRejectSymlinkEscapingRoot(@TempDir Path tempDir) throws IOException {
530+
CodeIqConfigTestSupport.override(config).rootPath(tempDir.toString()).done();
531+
532+
Path target = Files.createTempFile("codeiq-escape-", ".txt");
533+
try {
534+
Files.writeString(target, "TOP SECRET");
535+
Path link = tempDir.resolve("leak.txt");
536+
try {
537+
Files.createSymbolicLink(link, target.toAbsolutePath());
538+
} catch (UnsupportedOperationException | IOException unsupported) {
539+
// Filesystem does not support symlinks (e.g. Windows without privilege) — skip.
540+
return;
541+
}
542+
543+
String result = mcpTools.readFile("leak.txt", null, null);
544+
545+
assertFalse(result.contains("TOP SECRET"),
546+
"Symlink target contents must not leak through read_file");
547+
Map<String, Object> parsed = parseJson(result);
548+
assertEquals("Path traversal detected", parsed.get("error"));
549+
} finally {
550+
Files.deleteIfExists(target);
551+
}
552+
}
553+
554+
@Test
555+
void readFileShouldAllowInRepoSymlink(@TempDir Path tempDir) throws IOException {
556+
CodeIqConfigTestSupport.override(config).rootPath(tempDir.toString()).done();
557+
Path real = tempDir.resolve("real.txt");
558+
Files.writeString(real, "in-repo");
559+
Path link = tempDir.resolve("alias.txt");
560+
try {
561+
Files.createSymbolicLink(link, real);
562+
} catch (UnsupportedOperationException | IOException unsupported) {
563+
return;
564+
}
565+
566+
String result = mcpTools.readFile("alias.txt", null, null);
567+
568+
assertEquals("in-repo", result);
569+
}
527570
}

0 commit comments

Comments
 (0)