Skip to content

Commit ae8146b

Browse files
committed
test(skills): add path traversal tests for LocalSkillSource
Covers findResourcePath() and listResources() boundary enforcement: - references/../../../../etc/passwd (prefix bypass, main attack vector) - ../../outside.txt (no valid prefix) - ../other-skill (listResources directory traversal) - Regression: legitimate path still loads correctly
1 parent 30ba017 commit ae8146b

1 file changed

Lines changed: 88 additions & 0 deletions

File tree

core/src/test/java/com/google/adk/skills/LocalSkillSourceTest.java

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,4 +374,92 @@ public void testLoadInstructions_emptyFile() throws IOException {
374374
.hasMessageThat()
375375
.contains("Skill file must start with ---");
376376
}
377+
378+
@Test
379+
public void testLoadResource_pathTraversalBlocked() throws IOException {
380+
Path skillsBase = tempFolder.getRoot().toPath().resolve("skills");
381+
Files.createDirectory(skillsBase);
382+
383+
Path skillDir = skillsBase.resolve("my-skill");
384+
Files.createDirectory(skillDir);
385+
Files.createDirectory(skillDir.resolve("references"));
386+
387+
SkillSource source = new LocalSkillSource(skillsBase);
388+
// "references/../../../../etc/passwd" passes startsWith("references/") but escapes skillsBase
389+
var single = source.loadResource("my-skill", "references/../../../../etc/passwd");
390+
RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet);
391+
assertThat(exception).hasCauseThat().isInstanceOf(SkillSourceException.class);
392+
SkillSourceException cause = (SkillSourceException) exception.getCause();
393+
assertThat(cause.getErrorCode()).isEqualTo(SkillSourceException.RESOURCE_NOT_FOUND);
394+
assertThat(cause).hasMessageThat().contains("Path traversal detected");
395+
}
396+
397+
@Test
398+
public void testLoadResource_pathTraversalWithDoubleDotOnly() throws IOException {
399+
Path skillsBase = tempFolder.getRoot().toPath().resolve("skills");
400+
Files.createDirectory(skillsBase);
401+
402+
Path skillDir = skillsBase.resolve("my-skill");
403+
Files.createDirectory(skillDir);
404+
405+
SkillSource source = new LocalSkillSource(skillsBase);
406+
var single = source.loadResource("my-skill", "../../outside.txt");
407+
RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet);
408+
assertThat(exception).hasCauseThat().isInstanceOf(SkillSourceException.class);
409+
SkillSourceException cause = (SkillSourceException) exception.getCause();
410+
assertThat(cause.getErrorCode()).isEqualTo(SkillSourceException.RESOURCE_NOT_FOUND);
411+
assertThat(cause).hasMessageThat().contains("Path traversal detected");
412+
}
413+
414+
@Test
415+
public void testLoadResource_legitimatePathNotBlocked() throws IOException {
416+
Path skillsBase = tempFolder.getRoot().toPath().resolve("skills");
417+
Files.createDirectory(skillsBase);
418+
419+
Path skillDir = skillsBase.resolve("my-skill");
420+
Files.createDirectory(skillDir);
421+
Path referencesDir = skillDir.resolve("references");
422+
Files.createDirectory(referencesDir);
423+
Files.writeString(referencesDir.resolve("readme.md"), "legitimate content");
424+
425+
SkillSource source = new LocalSkillSource(skillsBase);
426+
ByteSource resource =
427+
source.loadResource("my-skill", "references/readme.md").blockingGet();
428+
assertThat(new String(resource.read(), UTF_8)).isEqualTo("legitimate content");
429+
}
430+
431+
@Test
432+
public void testListResources_pathTraversalInResourceDirectoryBlocked() throws IOException {
433+
Path skillsBase = tempFolder.getRoot().toPath().resolve("skills");
434+
Files.createDirectory(skillsBase);
435+
436+
Path skillDir = skillsBase.resolve("my-skill");
437+
Files.createDirectory(skillDir);
438+
Files.createDirectory(skillDir.resolve("references"));
439+
440+
SkillSource source = new LocalSkillSource(skillsBase);
441+
var single = source.listResources("my-skill", "references/../../../../etc");
442+
RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet);
443+
assertThat(exception).hasCauseThat().isInstanceOf(SkillSourceException.class);
444+
SkillSourceException cause = (SkillSourceException) exception.getCause();
445+
assertThat(cause.getErrorCode()).isEqualTo(SkillSourceException.RESOURCE_NOT_FOUND);
446+
assertThat(cause).hasMessageThat().contains("Path traversal detected");
447+
}
448+
449+
@Test
450+
public void testListResources_dotDotResourceDirectoryBlocked() throws IOException {
451+
Path skillsBase = tempFolder.getRoot().toPath().resolve("skills");
452+
Files.createDirectory(skillsBase);
453+
454+
Path skillDir = skillsBase.resolve("my-skill");
455+
Files.createDirectory(skillDir);
456+
457+
SkillSource source = new LocalSkillSource(skillsBase);
458+
var single = source.listResources("my-skill", "../other-skill");
459+
RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet);
460+
assertThat(exception).hasCauseThat().isInstanceOf(SkillSourceException.class);
461+
SkillSourceException cause = (SkillSourceException) exception.getCause();
462+
assertThat(cause.getErrorCode()).isEqualTo(SkillSourceException.RESOURCE_NOT_FOUND);
463+
assertThat(cause).hasMessageThat().contains("Path traversal detected");
464+
}
377465
}

0 commit comments

Comments
 (0)