Skip to content

Commit c4cbc43

Browse files
authored
fix: address CodeQL findings (zip-slip + implicit narrowing) (#7576)
* DataInstaller: reject archive entries that escape the install dir CodeQL java/zipslip flagged populateFileAndDirLists at lines 750/753; the actual sink is createFiles at line 619 (FileOutputStream) and createDirectories at line 617 (mkdirs), reached via a string-concat correctFileName that didn't normalise '..' segments. correctFileName now resolves each entry against its base directory and rejects any path whose canonical form leaves that base. Both canonicalisations happen on the resolved File, so symlinks, '.' and '..' segments are all collapsed before comparison. The two callers that didn't already throw IOException (checkOverwriteOK and createDirectories) catch and abort the install with the existing error-dialog pattern. A hostile data set containing 'data/../../etc/whatever' would now be refused before any file is written. * SkillModifier: make double-to-int truncation explicit CodeQL java/implicit-cast-in-compound-assignment flagged 12 sites in this file where a 'double' bonus was accumulated into an 'int' via +=, which silently inserts (int) at every addition. The intent was always truncating accumulation -- the function returns Integer and uses .intValue() for the formula path -- so the warning is purely about making the cast visible. Fix: write the cast at every site. No behaviour change; the bytecode already contained the same i2d/d2i pair. * DataInstaller: regression test for zip-slip rejection Reflective unit test so the same class compiles against both the pre-fix (`private`, no checked exception) and post-fix (package-private, throws IOException) signatures of correctFileName. Verified by hand: temporarily reverting DataInstaller to master and re-running this test yields 1 pass + 2 failures (both '..'-escape cases are silently accepted under the old logic), and restoring the fix flips it to 3 passes -- which makes the test a real regression guard rather than a tautology. correctFileName goes from `private` to package-private to give the test in the same package direct access; reflection was needed only for the cross-state run.
1 parent 561a8b7 commit c4cbc43

3 files changed

Lines changed: 162 additions & 18 deletions

File tree

code/src/java/pcgen/core/analysis/SkillModifier.java

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,48 +52,48 @@ public static Integer modifier(Skill sk, PlayerCharacter aPC)
5252
{
5353
PCStat stat = statref.get();
5454
bonus = aPC.getStatModFor(stat);
55-
bonus += aPC.getTotalBonusTo("SKILL", "STAT." + stat.getKeyName());
55+
bonus += (int) aPC.getTotalBonusTo("SKILL", "STAT." + stat.getKeyName());
5656
}
57-
bonus += aPC.getTotalBonusTo("SKILL", keyName);
57+
bonus += (int) aPC.getTotalBonusTo("SKILL", keyName);
5858

5959
// loop through all current skill types checking for boni
6060
for (Type singleType : sk.getTrueTypeList(false))
6161
{
62-
bonus += aPC.getTotalBonusTo("SKILL", "TYPE." + singleType);
62+
bonus += (int) aPC.getTotalBonusTo("SKILL", "TYPE." + singleType);
6363
}
6464

6565
// now check for any lists of skills, etc
66-
bonus += aPC.getTotalBonusTo("SKILL", "LIST");
66+
bonus += (int) aPC.getTotalBonusTo("SKILL", "LIST");
6767

6868
// now check for ALL
69-
bonus += aPC.getTotalBonusTo("SKILL", "ALL");
69+
bonus += (int) aPC.getTotalBonusTo("SKILL", "ALL");
7070

7171
// these next two if-blocks try to get BONUS:[C]CSKILL|TYPE=xxx|y to
7272
// function
7373
if (aPC.isClassSkill(sk))
7474
{
75-
bonus += aPC.getTotalBonusTo("CSKILL", keyName);
75+
bonus += (int) aPC.getTotalBonusTo("CSKILL", keyName);
7676

7777
// loop through all current skill types checking for boni
7878
for (Type singleType : sk.getTrueTypeList(false))
7979
{
80-
bonus += aPC.getTotalBonusTo("CSKILL", "TYPE." + singleType);
80+
bonus += (int) aPC.getTotalBonusTo("CSKILL", "TYPE." + singleType);
8181
}
8282

83-
bonus += aPC.getTotalBonusTo("CSKILL", "LIST");
83+
bonus += (int) aPC.getTotalBonusTo("CSKILL", "LIST");
8484
}
8585

8686
if (!aPC.isClassSkill(sk) && !sk.getSafe(ObjectKey.EXCLUSIVE))
8787
{
88-
bonus += aPC.getTotalBonusTo("CCSKILL", keyName);
88+
bonus += (int) aPC.getTotalBonusTo("CCSKILL", keyName);
8989

9090
// loop through all current skill types checking for boni
9191
for (Type singleType : sk.getTrueTypeList(false))
9292
{
93-
bonus += aPC.getTotalBonusTo("CCSKILL", "TYPE." + singleType);
93+
bonus += (int) aPC.getTotalBonusTo("CCSKILL", "TYPE." + singleType);
9494
}
9595

96-
bonus += aPC.getTotalBonusTo("CCSKILL", "LIST");
96+
bonus += (int) aPC.getTotalBonusTo("CCSKILL", "LIST");
9797
}
9898

9999
// the above two if-blocks try to get
@@ -129,7 +129,7 @@ public static int getStatMod(Skill sk, PlayerCharacter pc)
129129
SkillInfoUtilities.getKeyStatList(pc, sk, typeList);
130130
for (Type type : typeList)
131131
{
132-
statMod += pc.getTotalBonusTo("SKILL", "TYPE." + type);
132+
statMod += (int) pc.getTotalBonusTo("SKILL", "TYPE." + type);
133133
}
134134
}
135135
return statMod;

code/src/java/pcgen/gui2/dialog/DataInstaller.java

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import java.io.File;
3030
import java.io.FileOutputStream;
3131
import java.io.IOException;
32+
import java.nio.file.Path;
3233
import java.io.InputStream;
3334
import java.io.InputStreamReader;
3435
import java.io.OutputStream;
@@ -488,7 +489,19 @@ private static boolean checkOverwriteOK(Collection<String> files, File destDir)
488489
Collection<String> existingFilesCorr = new ArrayList<>();
489490
for (String filename : files)
490491
{
491-
String correctedFilename = correctFileName(destDir, filename);
492+
String correctedFilename;
493+
try
494+
{
495+
correctedFilename = correctFileName(destDir, filename);
496+
}
497+
catch (IOException e)
498+
{
499+
Logging.errorPrint("Refusing to install entry that escapes the data directory: " + filename, e);
500+
ShowMessageDelegate.showMessageDialog(
501+
LanguageBundle.getFormattedString("in_diWriteFail", filename),
502+
TITLE, MessageType.ERROR);
503+
return false;
504+
}
492505
if (new File(correctedFilename).exists())
493506
{
494507
existingFiles.add(filename);
@@ -550,22 +563,52 @@ private static void copyInputStream(InputStream in, OutputStream out) throws IOE
550563
* Correct the file name to account for the selected data directory and
551564
* preference based folder locations such as output sheets.
552565
*
566+
* <p>Resolved paths are validated against their base directory so a
567+
* malicious archive entry containing {@code ..} segments cannot escape
568+
* the install location ("Zip Slip"). Throws an {@link IOException} if
569+
* the resolved entry would land outside its expected base.
570+
*
553571
* @param destDir the destination data directory
554572
* @param fileName the file name to be corrected.
555573
* @return the corrected file name.
574+
* @throws IOException if the entry resolves outside its base directory
556575
*/
557-
private static String correctFileName(File destDir, String fileName)
576+
static String correctFileName(File destDir, String fileName) throws IOException
558577
{
559578
if (fileName.toLowerCase().startsWith(DATA_FOLDER))
560579
{
561-
fileName = destDir.getAbsolutePath() + fileName.substring(4);
562-
} else if (fileName.toLowerCase().startsWith(OUTPUTSHEETS_FOLDER))
580+
String suffix = fileName.substring(4);
581+
return resolveWithinBase(destDir, suffix).getAbsolutePath();
582+
}
583+
else if (fileName.toLowerCase().startsWith(OUTPUTSHEETS_FOLDER))
563584
{
564-
fileName = new File(ConfigurationSettings.getOutputSheetsDir()).getAbsolutePath() + fileName.substring(12);
585+
String suffix = fileName.substring(12);
586+
File outputSheetsDir = new File(ConfigurationSettings.getOutputSheetsDir());
587+
return resolveWithinBase(outputSheetsDir, suffix).getAbsolutePath();
565588
}
566589
return fileName;
567590
}
568591

592+
/**
593+
* Resolves {@code suffix} against {@code baseDir} and verifies the
594+
* resulting path is contained within {@code baseDir}. Both sides are
595+
* canonicalised (symlinks resolved, {@code .} and {@code ..} segments
596+
* collapsed) before comparison so a hostile entry name like
597+
* {@code ../../etc/passwd} cannot escape.
598+
*/
599+
private static File resolveWithinBase(File baseDir, String suffix) throws IOException
600+
{
601+
File baseCanonical = baseDir.getCanonicalFile();
602+
File resolved = new File(baseCanonical, suffix).getCanonicalFile();
603+
Path basePath = baseCanonical.toPath();
604+
if (!resolved.toPath().startsWith(basePath))
605+
{
606+
throw new IOException("Refusing to install entry that escapes "
607+
+ baseCanonical + ": " + suffix);
608+
}
609+
return resolved;
610+
}
611+
569612
/**
570613
* Creates the directories needed by the installer, where they
571614
* do not already exist.
@@ -578,7 +621,19 @@ private static boolean createDirectories(Iterable<String> directories, File dest
578621
{
579622
for (String dirname : directories)
580623
{
581-
String corrDirname = correctFileName(destDir, dirname);
624+
String corrDirname;
625+
try
626+
{
627+
corrDirname = correctFileName(destDir, dirname);
628+
}
629+
catch (IOException e)
630+
{
631+
Logging.errorPrint("Refusing to create directory from data set: " + dirname, e);
632+
ShowMessageDelegate.showMessageDialog(
633+
LanguageBundle.getFormattedString("in_diDirNotCreated", dirname), TITLE,
634+
MessageType.ERROR);
635+
return false;
636+
}
582637
File dir = new File(corrDirname);
583638
if (!dir.exists())
584639
{
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* Copyright 2026 Vest <Vest@users.noreply.github.com>
3+
*
4+
* This library is free software; you can redistribute it and/or
5+
* modify it under the terms of the GNU Lesser General Public
6+
* License as published by the Free Software Foundation; either
7+
* version 2.1 of the License, or (at your option) any later version.
8+
*/
9+
package pcgen.gui2.dialog;
10+
11+
import static org.junit.jupiter.api.Assertions.assertThrows;
12+
import static org.junit.jupiter.api.Assertions.assertTrue;
13+
14+
import java.io.File;
15+
import java.io.IOException;
16+
import java.lang.reflect.InvocationTargetException;
17+
import java.lang.reflect.Method;
18+
import java.nio.file.Path;
19+
20+
import org.junit.jupiter.api.Test;
21+
import org.junit.jupiter.api.io.TempDir;
22+
23+
/**
24+
* Regression tests for the Zip Slip fix in {@link DataInstaller}. Reflection
25+
* is used so the same test class compiles against both the pre-fix
26+
* (private, no checked exception) and post-fix (package-private,
27+
* throws IOException) signatures of {@code correctFileName}, which lets
28+
* the test be exercised against either code state during the
29+
* before/after verification dance.
30+
*/
31+
class DataInstallerZipSlipTest
32+
{
33+
private static String invokeCorrectFileName(File destDir, String entry) throws IOException
34+
{
35+
try
36+
{
37+
Method m = DataInstaller.class.getDeclaredMethod(
38+
"correctFileName", File.class, String.class);
39+
m.setAccessible(true);
40+
return (String) m.invoke(null, destDir, entry);
41+
}
42+
catch (InvocationTargetException e)
43+
{
44+
Throwable cause = e.getCause();
45+
if (cause instanceof IOException ioe)
46+
{
47+
throw ioe;
48+
}
49+
throw new RuntimeException(cause);
50+
}
51+
catch (NoSuchMethodException | IllegalAccessException e)
52+
{
53+
throw new RuntimeException(e);
54+
}
55+
}
56+
57+
@Test
58+
void benignEntryStaysUnderDestDir(@TempDir Path destDirPath) throws IOException
59+
{
60+
File destDir = destDirPath.toFile();
61+
62+
String resolved = invokeCorrectFileName(destDir, "data/sub/file.lst");
63+
64+
Path resolvedPath = new File(resolved).getCanonicalFile().toPath();
65+
Path baseCanonical = destDir.getCanonicalFile().toPath();
66+
assertTrue(resolvedPath.startsWith(baseCanonical),
67+
"benign entry should resolve under destDir, got: " + resolvedPath);
68+
}
69+
70+
@Test
71+
void parentSegmentEscapeIsRejected(@TempDir Path destDirPath)
72+
{
73+
File destDir = destDirPath.toFile();
74+
75+
assertThrows(IOException.class,
76+
() -> invokeCorrectFileName(destDir, "data/../../escape.txt"));
77+
}
78+
79+
@Test
80+
void buriedParentSegmentEscapeIsRejected(@TempDir Path destDirPath)
81+
{
82+
File destDir = destDirPath.toFile();
83+
84+
// Even when the '..' segments only escape after several levels of
85+
// traversal, the canonical comparison should still catch them.
86+
assertThrows(IOException.class,
87+
() -> invokeCorrectFileName(destDir, "data/foo/bar/../../../../escape.txt"));
88+
}
89+
}

0 commit comments

Comments
 (0)