Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions core/src/main/java/com/google/adk/skills/LocalSkillSource.java
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,21 @@ public LocalSkillSource(Path skillsBasePath) {

@Override
public Single<ImmutableList<String>> listResources(String skillName, String resourceDirectory) {
Path skillDir = skillsBasePath.resolve(skillName);
Path skillDir = skillsBasePath.resolve(skillName).normalize();
if (!isDirectory(skillDir)) {
return Single.error(
new SkillSourceException("Skill not found: " + skillName, SKILL_NOT_FOUND));
}
Path resourceDir = skillDir.resolve(resourceDirectory);
Path resourceDir = skillDir.resolve(resourceDirectory).normalize();
// Prevent path traversal: the resolved resource directory must remain inside
// the skill's own directory. A raw string prefix check on the input is not
// sufficient because "references/../../../../etc" bypasses it.
if (!resourceDir.startsWith(skillDir)) {
return Single.error(
new SkillSourceException(
"Path traversal detected in resource directory: " + resourceDirectory,
RESOURCE_NOT_FOUND));
}
if (!isDirectory(resourceDir)) {
return Single.error(
new SkillSourceException(
Expand Down Expand Up @@ -96,7 +105,16 @@ protected Flowable<SkillMdPath<Path>> listSkills() {

@Override
protected Single<Path> findResourcePath(String skillName, String resourcePath) {
Path file = skillsBasePath.resolve(skillName).resolve(resourcePath);
Path base = skillsBasePath.resolve(skillName).normalize();
Path file = base.resolve(resourcePath).normalize();
// Enforce boundary: the resolved path must remain inside the skill's base
// directory. Without this check, a payload like
// "references/../../../../etc/passwd" passes the startsWith("references/")
// prefix check in LoadSkillResourceTool but resolves outside skillsBasePath.
if (!file.startsWith(base)) {
return Single.error(
new SkillSourceException("Path traversal detected: " + resourcePath, RESOURCE_NOT_FOUND));
}
if (!Files.exists(file)) {
return Single.error(
new SkillSourceException("Resource not found: " + file, RESOURCE_NOT_FOUND));
Expand Down
87 changes: 87 additions & 0 deletions core/src/test/java/com/google/adk/skills/LocalSkillSourceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -374,4 +374,91 @@ public void testLoadInstructions_emptyFile() throws IOException {
.hasMessageThat()
.contains("Skill file must start with ---");
}

@Test
public void testLoadResource_pathTraversalBlocked() throws IOException {
Path skillsBase = tempFolder.getRoot().toPath().resolve("skills");
Files.createDirectory(skillsBase);

Path skillDir = skillsBase.resolve("my-skill");
Files.createDirectory(skillDir);
Files.createDirectory(skillDir.resolve("references"));

SkillSource source = new LocalSkillSource(skillsBase);
// "references/../../../../etc/passwd" passes startsWith("references/") but escapes skillsBase
var single = source.loadResource("my-skill", "references/../../../../etc/passwd");
RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet);
assertThat(exception).hasCauseThat().isInstanceOf(SkillSourceException.class);
SkillSourceException cause = (SkillSourceException) exception.getCause();
assertThat(cause.getErrorCode()).isEqualTo(SkillSourceException.RESOURCE_NOT_FOUND);
assertThat(cause).hasMessageThat().contains("Path traversal detected");
}

@Test
public void testLoadResource_pathTraversalWithDoubleDotOnly() throws IOException {
Path skillsBase = tempFolder.getRoot().toPath().resolve("skills");
Files.createDirectory(skillsBase);

Path skillDir = skillsBase.resolve("my-skill");
Files.createDirectory(skillDir);

SkillSource source = new LocalSkillSource(skillsBase);
var single = source.loadResource("my-skill", "../../outside.txt");
RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet);
assertThat(exception).hasCauseThat().isInstanceOf(SkillSourceException.class);
SkillSourceException cause = (SkillSourceException) exception.getCause();
assertThat(cause.getErrorCode()).isEqualTo(SkillSourceException.RESOURCE_NOT_FOUND);
assertThat(cause).hasMessageThat().contains("Path traversal detected");
}

@Test
public void testLoadResource_legitimatePathNotBlocked() throws IOException {
Path skillsBase = tempFolder.getRoot().toPath().resolve("skills");
Files.createDirectory(skillsBase);

Path skillDir = skillsBase.resolve("my-skill");
Files.createDirectory(skillDir);
Path referencesDir = skillDir.resolve("references");
Files.createDirectory(referencesDir);
Files.writeString(referencesDir.resolve("readme.md"), "legitimate content");

SkillSource source = new LocalSkillSource(skillsBase);
ByteSource resource = source.loadResource("my-skill", "references/readme.md").blockingGet();
assertThat(new String(resource.read(), UTF_8)).isEqualTo("legitimate content");
}

@Test
public void testListResources_pathTraversalInResourceDirectoryBlocked() throws IOException {
Path skillsBase = tempFolder.getRoot().toPath().resolve("skills");
Files.createDirectory(skillsBase);

Path skillDir = skillsBase.resolve("my-skill");
Files.createDirectory(skillDir);
Files.createDirectory(skillDir.resolve("references"));

SkillSource source = new LocalSkillSource(skillsBase);
var single = source.listResources("my-skill", "references/../../../../etc");
RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet);
assertThat(exception).hasCauseThat().isInstanceOf(SkillSourceException.class);
SkillSourceException cause = (SkillSourceException) exception.getCause();
assertThat(cause.getErrorCode()).isEqualTo(SkillSourceException.RESOURCE_NOT_FOUND);
assertThat(cause).hasMessageThat().contains("Path traversal detected");
}

@Test
public void testListResources_dotDotResourceDirectoryBlocked() throws IOException {
Path skillsBase = tempFolder.getRoot().toPath().resolve("skills");
Files.createDirectory(skillsBase);

Path skillDir = skillsBase.resolve("my-skill");
Files.createDirectory(skillDir);

SkillSource source = new LocalSkillSource(skillsBase);
var single = source.listResources("my-skill", "../other-skill");
RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet);
assertThat(exception).hasCauseThat().isInstanceOf(SkillSourceException.class);
SkillSourceException cause = (SkillSourceException) exception.getCause();
assertThat(cause.getErrorCode()).isEqualTo(SkillSourceException.RESOURCE_NOT_FOUND);
assertThat(cause).hasMessageThat().contains("Path traversal detected");
}
}
Loading