{"record":{"id":"28d7621fba18c786","repo":"spring-projects/spring-ai","slug":"invalid-filename-for-file-must-be-a-single-pat","errorCode":null,"errorMessage":"Invalid filename for file '': must be a single path segment ''","messagePattern":"Invalid filename for file '': must be a single path segment ''","errorType":"exception","errorClass":"IOException","httpStatus":null,"severity":"error","filePath":"models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicSkillsResponseHelper.java","lineNumber":164,"sourceCode":"\t * Filenames come from model-influenced API metadata and must not be trusted as safe\n\t * path components.\n\t */\n\tstatic Path resolveSafeChildPath(Path targetDir, @Nullable String rawName, String fileId) throws IOException {\n\t\tif (rawName == null || rawName.isBlank()) {\n\t\t\tthrow new IOException(\"Invalid filename for file '\" + fileId + \"': null or blank\");\n\t\t}\n\t\tPath name;\n\t\ttry {\n\t\t\tname = Path.of(rawName);\n\t\t}\n\t\tcatch (InvalidPathException ex) {\n\t\t\tthrow new IOException(\"Invalid filename for file '\" + fileId + \"': \" + rawName, ex);\n\t\t}\n\t\tif (name.isAbsolute() || name.getRoot() != null) {\n\t\t\tthrow new IOException(\"Invalid filename for file '\" + fileId + \"': absolute path '\" + rawName + \"'\");\n\t\t}\n\t\tif (name.getNameCount() != 1) {\n\t\t\tthrow new IOException(\n\t\t\t\t\t\"Invalid filename for file '\" + fileId + \"': must be a single path segment '\" + rawName + \"'\");\n\t\t}\n\t\tString only = name.getName(0).toString();\n\t\tif (only.equals(\".\") || only.equals(\"..\")) {\n\t\t\tthrow new IOException(\"Invalid filename for file '\" + fileId + \"': '\" + rawName + \"'\");\n\t\t}\n\n\t\t// One extra hardening check to make sure nothing fell through the cracks above\n\t\t// (future tweaks to the rules, odd platform path quirks, etc.).\n\t\tPath base = targetDir.toAbsolutePath().normalize();\n\t\tPath resolved = base.resolve(only).normalize();\n\t\tif (!resolved.startsWith(base)) {\n\t\t\tthrow new IOException(\n\t\t\t\t\t\"Invalid filename for file '\" + fileId + \"': resolves outside target directory '\" + rawName + \"'\");\n\t\t}\n\t\treturn resolved;\n\t}\n","sourceCodeStart":146,"sourceCodeEnd":182,"githubUrl":"https://github.com/spring-projects/spring-ai/blob/98a7beda4f29d80a71c5837eb4053b03a93a46f7/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicSkillsResponseHelper.java#L146-L182","documentation":"AnthropicSkillsResponseHelper.resolveSafeChildPath validates filenames returned by the Anthropic Files/Skills API before writing them under a target directory. It throws this IOException when the decoded filename parses to a path with more than one segment (e.g. 'a/b' or a multi-component name), because each downloaded file must map to exactly one entry inside the target dir. It is a path-traversal hardening guard.","triggerScenarios":"Anthropic API returns a file whose name field contains a path separator (e.g. 'sub/file.txt' or a Windows-style 'sub\\file.txt') when resolving a skill file via the filePath helper, so resolveSafeChildPath sees getNameCount() != 1.","commonSituations":"Skill bundles that internally reference files in subdirectories; a provider-side change in how the filename header is emitted; malicious or buggy filename coming back from the API; platform path quirks where the raw name contains separators.","solutions":["Inspect the file's name field returned by the API and use only its final segment before requesting/downloading.","If nested layout is expected, strip the directory portion yourself (Paths.get(raw).getFileName()) and create parent dirs explicitly rather than passing a multi-segment name.","Check whether your skill package metadata is emitting paths instead of plain filenames and fix the packager.","Catch IOException from resolveSafeChildPath and skip/log the offending file instead of failing the whole download."],"exampleFix":"// before\nPath resolved = helper.filePath(fileId, \"output/sub/report.md\");\n// after\nString raw = \"output/sub/report.md\";\nString onlyName = Paths.get(raw).getFileName().toString();\nPath resolved = helper.filePath(fileId, onlyName);","handlingStrategy":"validation","validationCode":"static boolean isSafeFileName(String name) {\n    return name != null && name.matches(\"[A-Za-z0-9._-]+\")\n        && !name.equals(\".\") && !name.equals(\"..\");\n}","typeGuard":"static String safeFileName(String raw) {\n    if (raw == null || !isSafeFileName(raw)) {\n        throw new IllegalArgumentException(\"Unsafe filename: \" + raw);\n    }\n    return raw;\n}","tryCatchPattern":"try {\n    Path p = helper.filePath(fileId, name);\n} catch (IOException e) {\n    log.warn(\"Skipping unsafe filename {}: {}\", name, e.getMessage());\n}","preventionTips":["Always take only the final path segment of any API-supplied filename.","Whitelist filename characters instead of blacklisting separators.","Never pass user- or API-controlled paths directly to file-writing helpers."],"tags":["path-validation","io","filename"],"backgroundTag":"path-traversal-blocked","analyzedSha":"98a7beda4f29d80a71c5837eb4053b03a93a46f7","analyzedAt":"2026-09-11T14:15:49.441Z","contentChangedAt":"2026-09-11T14:15:49.441Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}