fix(run): sanitize input-file names — path-traversal + prompt-injection hardening (gadfly #18)
executus CI / test (pull_request) Successful in 48s
executus CI / test (pull_request) Successful in 48s
The full swarm (5-6 models) flagged that stageInputFiles passed the untrusted attachment filename straight to StageInputFile and inlined it into the [ATTACHED FILES]/`/workspace/<name>` descriptor with no sanitization — a path the byte-cap already treats as a trust boundary. A name like ../../etc/passwd or an absolute/drive path could escape the host store or the sandbox workspace, and newlines in the name/mime could inject text into the prompt block. - sanitizeName: strips control chars/newlines, then reduces to a base name (path.Base after backslash-normalization) so ../, nested dirs, and absolute / drive paths all collapse to their last element; "attachment" fallback for empty/"."/"..". Applied BEFORE staging AND inlining. - sanitizeField: strips control chars from MimeType (also inlined verbatim). - maxInputFiles (32) count cap — defense-in-depth vs a flood of tiny files, independent of the per-file byte cap. Tests: sanitizeName table (traversal/absolute/backslash/control/fallback, + no-separator invariant); traversal staged+described under the base name only; oversize skip; count-cap truncation. Full suite green (-race). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -117,3 +117,72 @@ func TestStageInputFilesSkipsBad(t *testing.T) {
|
||||
t.Errorf("save error should drop the file and leave the prompt, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStageInputFilesOversize: a file past the byte cap is skipped (prompt
|
||||
// unchanged), exercising the size guard directly.
|
||||
func TestStageInputFilesOversize(t *testing.T) {
|
||||
st := &stagerFunc{}
|
||||
ex := newStagerExecutor(st)
|
||||
big := make([]byte, maxInputFileBytes+1)
|
||||
out := ex.stageInputFiles(context.Background(), "r", "a",
|
||||
[]tool.InputFile{{Name: "huge.bin", Data: big}}, "p")
|
||||
if out != "p" || len(st.staged) != 0 {
|
||||
t.Errorf("oversized file should be skipped: out=%q staged=%d", out, len(st.staged))
|
||||
}
|
||||
}
|
||||
|
||||
// TestStageInputFilesCountCap: more than maxInputFiles attachments are truncated
|
||||
// to the cap.
|
||||
func TestStageInputFilesCountCap(t *testing.T) {
|
||||
st := &stagerFunc{}
|
||||
ex := newStagerExecutor(st)
|
||||
files := make([]tool.InputFile, maxInputFiles+5)
|
||||
for i := range files {
|
||||
files[i] = tool.InputFile{Name: "f.bin", Data: []byte("x")}
|
||||
}
|
||||
ex.stageInputFiles(context.Background(), "r", "a", files, "p")
|
||||
if len(st.staged) != maxInputFiles {
|
||||
t.Errorf("count cap: staged %d, want %d", len(st.staged), maxInputFiles)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeName: traversal + absolute + control-char filenames are reduced to
|
||||
// a safe base name (no path separators, no newlines), with a fallback.
|
||||
func TestSanitizeName(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"../../etc/passwd": "passwd",
|
||||
"/etc/cron.d/x": "x",
|
||||
`..\..\windows\sys`: "sys",
|
||||
"clip.mp3": "clip.mp3",
|
||||
"": "attachment",
|
||||
"..": "attachment",
|
||||
".": "attachment",
|
||||
"evil\n- injected": "evil- injected",
|
||||
"a/b/c.wav": "c.wav",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := sanitizeName(in); got != want {
|
||||
t.Errorf("sanitizeName(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
// A sanitized name must never carry a path separator or newline.
|
||||
got := sanitizeName(in)
|
||||
if strings.ContainsAny(got, "/\\\n\r") {
|
||||
t.Errorf("sanitizeName(%q) = %q still contains a separator/newline", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStageInputFilesSanitizesTraversal: a traversal filename is staged AND
|
||||
// described under its safe base name only.
|
||||
func TestStageInputFilesSanitizesTraversal(t *testing.T) {
|
||||
st := &stagerFunc{}
|
||||
ex := newStagerExecutor(st)
|
||||
out := ex.stageInputFiles(context.Background(), "r", "a",
|
||||
[]tool.InputFile{{Name: "../../../etc/passwd", MimeType: "text/plain", Data: []byte("x")}}, "go")
|
||||
if len(st.staged) != 1 || st.staged[0].name != "passwd" {
|
||||
t.Fatalf("staged name = %+v, want passwd", st.staged)
|
||||
}
|
||||
if strings.Contains(out, "..") || strings.Contains(out, "/etc/") {
|
||||
t.Errorf("descriptor leaked the traversal path:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user