Skip to content

Commit 02e910e

Browse files
committed
Clean up resource upload endpoint
Fix stream leak on validation failure by wrapping resource handling in try/finally. Remove unnecessary if/else branch — always route through UploadSkillWithResourcesAsync since it handles empty resource lists. Rename form field from "references" to "resources" and let clients specify the full relative path (e.g. references/guide.md, scripts/setup.sh) so all allowed directories are accessible, not just references/. Add integration test for path traversal rejection.
1 parent 5cd51cd commit 02e910e

2 files changed

Lines changed: 61 additions & 45 deletions

File tree

src/SkillServer/Endpoints.cs

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -228,43 +228,34 @@ private static async Task<IResult> UploadSkill(
228228
});
229229
}
230230

231-
var referenceFiles = request.Form.Files.GetFiles("references");
232-
var hasReferences = referenceFiles.Count > 0;
233-
234-
if (hasReferences)
231+
var resourceFiles = request.Form.Files.GetFiles("resources");
232+
var resources = new List<(ResourcePath Path, Stream Content)>();
233+
try
235234
{
236-
var resources = new List<(ResourcePath Path, Stream Content)>();
237-
foreach (var refFile in referenceFiles)
235+
foreach (var resourceFile in resourceFiles)
238236
{
239-
var relativePath = $"references/{refFile.FileName}";
240-
if (!ResourcePath.TryCreate(relativePath, out var resourcePath))
237+
if (!ResourcePath.TryCreate(resourceFile.FileName, out var resourcePath))
241238
{
242239
return Results.BadRequest(new ErrorResponse
243240
{
244241
Error = "invalid_resource_path",
245-
Message = $"Invalid resource path: '{relativePath}'. Must be in references/, scripts/, or assets/ directories."
242+
Message = $"Invalid resource path: '{resourceFile.FileName}'. Must be in references/, scripts/, or assets/ directories."
246243
});
247244
}
248245

249-
resources.Add((resourcePath.Value, refFile.OpenReadStream()));
246+
resources.Add((resourcePath.Value, resourceFile.OpenReadStream()));
250247
}
251248

252249
await using var stream = file.OpenReadStream();
253250
var result = await uploadService.UploadSkillWithResourcesAsync(
254251
skillName.Value, skillVersion.Value, stream, resources, category, ct);
255252

256-
foreach (var (_, content) in resources)
257-
await content.DisposeAsync();
258-
259253
return HandleUploadResult(result, configuration);
260254
}
261-
else
255+
finally
262256
{
263-
await using var stream = file.OpenReadStream();
264-
var result = await uploadService.UploadSkillMdAsync(
265-
skillName.Value, skillVersion.Value, stream, category, ct);
266-
267-
return HandleUploadResult(result, configuration);
257+
foreach (var (_, content) in resources)
258+
await content.DisposeAsync();
268259
}
269260
}
270261

tests/SkillServer.Integration.Tests/SkillServerIntegrationTests.cs

Lines changed: 51 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -608,18 +608,18 @@ public async Task SearchSkills_WithPorterStemming_MatchesStemmedTerms()
608608
}
609609

610610
[Fact]
611-
public async Task UploadSkillWithReferences_EndToEnd()
611+
public async Task UploadSkillWithResources_EndToEnd()
612612
{
613613
var ct = TestContext.Current.CancellationToken;
614614
var skillName = $"ref-test-{Guid.NewGuid():N}"[..20];
615615

616616
var skillContent = $"""
617617
---
618618
name: {skillName}
619-
description: Testing reference file uploads
619+
description: Testing resource file uploads
620620
---
621621
622-
# Reference Upload Test
622+
# Resource Upload Test
623623
624624
See references/guide.md for details.
625625
""";
@@ -636,13 +636,11 @@ First section content.
636636
Second section content.
637637
""";
638638

639-
var referenceContent2 = """
640-
# Patterns
641-
642-
Common usage patterns for this skill.
639+
var scriptContent = """
640+
#!/bin/bash
641+
echo "setup complete"
643642
""";
644643

645-
// Upload skill with two reference files
646644
using var content = new MultipartFormDataContent();
647645
content.Add(new StringContent(skillName), "name");
648646
content.Add(new StringContent("1.0.0"), "version");
@@ -653,39 +651,35 @@ Second section content.
653651

654652
var refFileContent = new ByteArrayContent(Encoding.UTF8.GetBytes(referenceContent));
655653
refFileContent.Headers.ContentType = new MediaTypeHeaderValue("text/markdown");
656-
content.Add(refFileContent, "references", "guide.md");
654+
content.Add(refFileContent, "resources", "references/guide.md");
657655

658-
var refFileContent2 = new ByteArrayContent(Encoding.UTF8.GetBytes(referenceContent2));
659-
refFileContent2.Headers.ContentType = new MediaTypeHeaderValue("text/markdown");
660-
content.Add(refFileContent2, "references", "patterns.md");
656+
var scriptFileContent = new ByteArrayContent(Encoding.UTF8.GetBytes(scriptContent));
657+
scriptFileContent.Headers.ContentType = new MediaTypeHeaderValue("application/x-sh");
658+
content.Add(scriptFileContent, "resources", "scripts/setup.sh");
661659

662660
var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content, ct);
663661
Assert.Equal(HttpStatusCode.Created, uploadResponse.StatusCode);
664662

665-
// Verify the version shows file count (SKILL.md + 2 references)
666663
var version = await _fixture.Client.GetVersionAsync(skillName, "1.0.0", ct);
667664
Assert.NotNull(version);
668665
Assert.Equal(2, version.FileCount);
669666

670-
// Download SKILL.md
671667
var downloadedSkill = await _fixture.Client.GetSkillFileAsStringAsync(skillName, "1.0.0", ct: ct);
672-
Assert.Contains("# Reference Upload Test", downloadedSkill);
668+
Assert.Contains("# Resource Upload Test", downloadedSkill);
673669

674-
// Download reference files via resource endpoint
675670
var refResponse = await _fixture.HttpClient.GetAsync(
676671
$"/skills/{skillName}/1.0.0/references/guide.md", ct);
677672
Assert.Equal(HttpStatusCode.OK, refResponse.StatusCode);
678673
var refBody = await refResponse.Content.ReadAsStringAsync(ct);
679674
Assert.Contains("# Guide", refBody);
680675
Assert.Contains("Section One", refBody);
681676

682-
var refResponse2 = await _fixture.HttpClient.GetAsync(
683-
$"/skills/{skillName}/1.0.0/references/patterns.md", ct);
684-
Assert.Equal(HttpStatusCode.OK, refResponse2.StatusCode);
685-
var refBody2 = await refResponse2.Content.ReadAsStringAsync(ct);
686-
Assert.Contains("# Patterns", refBody2);
677+
var scriptResponse = await _fixture.HttpClient.GetAsync(
678+
$"/skills/{skillName}/1.0.0/scripts/setup.sh", ct);
679+
Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode);
680+
var scriptBody = await scriptResponse.Content.ReadAsStringAsync(ct);
681+
Assert.Contains("setup complete", scriptBody);
687682

688-
// Verify resources appear in RFC index
689683
var index = await _fixture.Client.GetRfcIndexAsync(ct);
690684
Assert.NotNull(index);
691685
var indexSkill = index.Skills.FirstOrDefault(s => s.Name == skillName);
@@ -694,22 +688,22 @@ Second section content.
694688
Assert.NotNull(resources);
695689
Assert.Equal(2, resources!.Count);
696690
Assert.Contains(resources, r => r.Path == "references/guide.md");
697-
Assert.Contains(resources, r => r.Path == "references/patterns.md");
691+
Assert.Contains(resources, r => r.Path == "scripts/setup.sh");
698692
}
699693

700694
[Fact]
701-
public async Task UploadSkillWithReferences_WithoutReferences_StillWorks()
695+
public async Task UploadSkillWithResources_WithoutResources_StillWorks()
702696
{
703697
var ct = TestContext.Current.CancellationToken;
704698
var skillName = $"noref-{Guid.NewGuid():N}"[..20];
705699

706700
var skillContent = $"""
707701
---
708702
name: {skillName}
709-
description: Testing upload without references still works
703+
description: Testing upload without resources still works
710704
---
711705
712-
# No References Test
706+
# No Resources Test
713707
""";
714708

715709
using var content = new MultipartFormDataContent();
@@ -727,4 +721,35 @@ public async Task UploadSkillWithReferences_WithoutReferences_StillWorks()
727721
Assert.NotNull(version);
728722
Assert.Equal(0, version.FileCount);
729723
}
724+
725+
[Fact]
726+
public async Task UploadSkillWithResources_InvalidPath_ReturnsBadRequest()
727+
{
728+
var ct = TestContext.Current.CancellationToken;
729+
var skillName = $"badpath-{Guid.NewGuid():N}"[..20];
730+
731+
var skillContent = $"""
732+
---
733+
name: {skillName}
734+
description: Testing invalid resource path rejection
735+
---
736+
737+
# Bad Path Test
738+
""";
739+
740+
using var content = new MultipartFormDataContent();
741+
content.Add(new StringContent(skillName), "name");
742+
content.Add(new StringContent("1.0.0"), "version");
743+
744+
var fileContent = new ByteArrayContent(Encoding.UTF8.GetBytes(skillContent));
745+
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/markdown");
746+
content.Add(fileContent, "file", "SKILL.md");
747+
748+
var badFile = new ByteArrayContent(Encoding.UTF8.GetBytes("exploit"));
749+
badFile.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
750+
content.Add(badFile, "resources", "../etc/passwd");
751+
752+
var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content, ct);
753+
Assert.Equal(HttpStatusCode.BadRequest, uploadResponse.StatusCode);
754+
}
730755
}

0 commit comments

Comments
 (0)