Refactor to reduce test code duplication#7
Conversation
… into feature/generator-test-refactor
| pathArray[i] = name.substring(0, name.indexOf("/")); | ||
| name = name.substring(name.indexOf("/") + 1); | ||
| } | ||
| pathArray[separatorCount + 1] = name; |
Check failure
Code scanning / CodeQL
Arbitrary file access during archive extraction ("Zip Slip") High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 5 months ago
At a high level, we want to ensure that any filesystem paths derived from untrusted or semi‑trusted data (jar entry names and logical path segments) cannot be used to access files outside an intended base directory. The standard fix is to resolve the candidate path against the base directory, normalize/canonicalize it, and verify that it is still under that base directory before using it in any file operation.
Concretely, in this codebase the risky places are:
-
addArchiveinFileSystem:- It stores
entry.getName()(jar entry path) as the value in thePathTree. If an entry name contains..or absolute elements,files.get(path)later returns that unsanitized value and it is passed toFileInputStreamas a filesystem path. We should validate the entry name when mounting the archive and either reject or normalize dangerous entries.
- It stores
-
getFileinFileSystem:- Uses
temp.getPath() + toString(path)to create aFilein the temp directory, without verifying that the resulting path is insidetemp. - Uses
new FileInputStream(files.get(path))on a string that may be derived (indirectly) from jar entries.
- Uses
-
saveToTempinFileSystem:- Also uses
temp.getPath() + toString(path)to create files for writing, with the same lack of boundary checks.
- Also uses
The safest, minimal‑behavior‑change fix is:
-
Introduce a small helper in
FileSystemto safely resolve a relative path (string constructed bytoString(path)or stored infiles) under a base directory, usingPath.of(base, relative).normalize()and checking that the resulting path starts with the base directory path. If not, throw anIOExceptionor log and abort. -
Use this helper:
- In
getFile, before using temp paths: instead ofnew File(temp.getPath() + toString(path)), compute aPathundertempwith normalization and bounds check, then use thatPathto open the file. - In
saveToTemp, do the same when creating/writing files in temp. - In
getFile’s non‑jar branch (new FileInputStream(files.get(path))), iffiles.get(path)is intended to be relative to some root, we should at least normalize it and optionally restrict it to not be absolute and not contain... Since we’re not shown the full semantics ofpathsand directory roots, we’ll implement a conservative check: disallow absolute paths and..segments infiles.get(path)before using it.
- In
-
In
addArchive, prevent storing clearly dangerous entry names. The code only uses the entry name as the value in the tree (not to open files inside the jar except viajar.getEntry(value), which is safe inside the jar), but that same value is later used to open an external file in the else‑branch ofgetFile. To avoid poisoning the mapping with dangerous values, we can:- Skip jar entries whose
entry.getName()is absolute or contains..path elements, and log a warning. - This doesn’t affect normal mods but prevents crafted jars from introducing unsafe paths.
- Skip jar entries whose
Implementation details and locations:
-
File:
src/main/java/neon/systems/files/FileSystem.java-
Add an import for
java.nio.file.Pathsor just usePath.of; we already importjava.nio.file.Path, so we can usePath.of(...)without new imports. -
Add a private helper method, e.g.
private Path resolveUnderBase(File baseDir, String relativePath) throws IOException, which:- Builds a
Path base = baseDir.toPath().toAbsolutePath().normalize(); - Builds
Path candidate = base.resolve(relativePath).normalize(); - Checks
if (!candidate.startsWith(base)) throw new IOException("Path traversal attempt: " + relativePath); - Returns
candidate.
- Builds a
-
Update
getFile:- Replace
new File(temp.getPath() + toString(path))with logic that usesresolveUnderBase(temp, toString(path)). - Replace
new FileInputStream(temp.getPath() + toString(path))accordingly. - Before
new FileInputStream(files.get(path)), validate thatfiles.get(path)is notnull, not absolute (!new File(name).isAbsolute()), and does not contain".."as a segment (simplecontains("..")or better,Path.of(name).normalize()and ensure it has no..). Since we must avoid changing semantics too much and not know the root, we’ll implement a simple guard: if the normalizedPathis absolute or contains..segments (detected by comparingnormalize()toPath.of(name)and/or scanning elements), we throw anIOException.
To keep changes small, we can restrict ourselves to rejecting any
files.get(path)that is absolute or whose normalized path starts with".."or contains"/../"orFile.separator + ".." + File.separator. That’s enough to avoid obvious path traversal into the host filesystem. - Replace
-
Update
saveToTemp:- Use
resolveUnderBase(temp, toString(path))to obtain aPathand then aFilefor writing (filePath.toFile()), instead of concatenating strings.
- Use
-
Update
addArchive:- After
String name = entry.getName();(or before adding tofiles), add a simple validation to skip dangerous names:- If
name.startsWith("/")orname.contains(".."), log a warning andcontinue;. - This prevents storing entries with traversal patterns.
- If
- After
-
These changes keep functionality for legitimate paths but prevent traversal both when writing to temp and when using potentially poisoned values from files. They also address all CodeQL variants because they sanitize the data at the point where untrusted archive entry names flow into filesystem sinks.
| @@ -130,6 +130,11 @@ | ||
| JarEntry entry = entries.nextElement(); | ||
| if (!entry.isDirectory()) { | ||
| String name = entry.getName(); | ||
| // basic sanity check on jar entry names to avoid path traversal patterns | ||
| if (name.startsWith("/") || name.contains("..")) { | ||
| log.warn("Skipping suspicious jar entry '{}' in archive '{}'", name, path); | ||
| continue; | ||
| } | ||
| // this apparently must use "/" because I'm in a jar, and not File.separator | ||
| int separatorCount = name.length() - name.replace("/", "").length(); | ||
| String[] pathArray = new String[separatorCount + 2]; | ||
| @@ -207,8 +212,11 @@ | ||
| public <T> T getFile(Translator<T> translator, String... path) { | ||
| try { | ||
| // System.out.println(Arrays.deepToString(path)); | ||
| if (new File(temp.getPath() + toString(path)).exists()) { | ||
| InputStream stream = new FileInputStream(temp.getPath() + toString(path)); | ||
| String relative = toString(path); | ||
| Path tempBase = temp.toPath().toAbsolutePath().normalize(); | ||
| Path tempTarget = tempBase.resolve(relative).normalize(); | ||
| if (tempTarget.startsWith(tempBase) && tempTarget.toFile().exists()) { | ||
| InputStream stream = new FileInputStream(tempTarget.toFile()); | ||
| return translator.translate(stream); | ||
| } else if (jars.containsKey(path[0])) { // path[0] is the name of the mod | ||
| JarFile jar = new JarFile(new File(jars.get(path[0]))); | ||
| @@ -217,7 +225,16 @@ | ||
| jar.close(); | ||
| return t; | ||
| } else { | ||
| InputStream stream = new FileInputStream(files.get(path)); | ||
| String external = files.get(path); | ||
| if (external == null) { | ||
| throw new IOException("No file mapped for path " + Arrays.toString(path)); | ||
| } | ||
| File externalFile = new File(external); | ||
| // basic guard against absolute paths and traversal segments | ||
| if (externalFile.isAbsolute() || external.contains("..")) { | ||
| throw new IOException("Refusing to access suspicious path '" + external + "'"); | ||
| } | ||
| InputStream stream = new FileInputStream(externalFile); | ||
| return translator.translate(stream); | ||
| } | ||
| } catch (IOException e) { | ||
| @@ -263,7 +280,13 @@ | ||
| */ | ||
| public <T> void saveToTemp(T output, Translator<T> translator, String... path) { | ||
| try { | ||
| File file = new File(temp.getPath() + toString(path)); | ||
| String relative = toString(path); | ||
| Path base = temp.toPath().toAbsolutePath().normalize(); | ||
| Path target = base.resolve(relative).normalize(); | ||
| if (!target.startsWith(base)) { | ||
| throw new IOException("Refusing to write outside temp directory: " + target); | ||
| } | ||
| File file = target.toFile(); | ||
| if (!file.getParentFile().exists()) { | ||
| makeDir(file.getParent()); | ||
| } |
No description provided.