Refuse archive entries that escape their target directory and drop dead ECIES code - #126
Conversation
…ad ECIES code Entries of connector bundles (lib/*, native/*), of IOUtil.unjar and of the maven plugin's own resources were resolved with new File(dir, name), so an entry such as lib/../../x was written outside of the target directory (CodeQL java/zipslip). IOUtil.resolveEntry now rejects any entry whose canonical path leaves the directory, and the four extraction sites use it. ECIESEncryptor (AES/CBC with an IV taken from the ECDH secret) was only reachable through OpenICFServerAdapter.initialiseEncryptor(), which nothing calls and which dereferences a null HandshakeMessage; both are removed. Also widens the contract tests' loop counters to long to match the long MAX_ITERATIONS parameter, and makes the ObjectPool wait loop's exits explicit - it only ever left by returning or throwing.
…izer The check compared the canonical path with "equals the directory or starts with its prefix"; CodeQL's path-injection sanitizer only credits a lone startsWith on a normalised path that guards the use. A trailing separator on both sides makes one startsWith cover the directory itself as well. resolveEntry now returns the canonical file, so the bundle temp directory is canonicalised too before its parent directories are walked.
maximthomas
left a comment
There was a problem hiding this comment.
IOUtil.resolveEntryappends the separator to both sides of the prefix check, so the directory itself passes (the plugin'sshared/entry stripped to"") and a<dir>2sibling does not.copyStreamToFilecanonicalisesbundleDirbefore the parent walk, so the walk still ends whenjava.io.tmpdirgoes through a symlink (green on the macOS CI jobs).testRejectsBundleEntryEscapingTempDirectoryreproduces the escape through the real bundle path, with the regularlib/entry first so that the escape resolves.
issue (non-blocking): The deleted ECIESEncryptor is still registered as an Encryptor service provider.
OpenICF-java-framework/connector-framework-server/src/main/resources/META-INF/services/org.identityconnectors.common.security.Encryptor:1
This file's only line is org.forgerock.openicf.framework.remote.security.ECIESEncryptor. At the head, git grep ECIESEncryptor matches nothing else. The bundle therefore ships a provider entry for a class that no longer exists, and ServiceLoader.load(Encryptor.class) over it throws ServiceConfigurationError "Provider ... not found". The runtime impact is nil, because no main code uses ServiceLoader for Encryptor, and at the base the class had only a (KeyPair, PublicKey) constructor, so iterating the loader already failed. The description's "only referenced from initialiseEncryptor()" misses this file.
git rm OpenICF-java-framework/connector-framework-server/src/main/resources/META-INF/services/org.identityconnectors.common.security.Encryptorsuggestion (non-blocking): No test pins the trailing-separator guard against a sibling directory whose name starts with the target's name.
OpenICF-java-framework/connector-framework/src/main/java/org/identityconnectors/common/IOUtil.java:637-640, OpenICF-java-framework/connector-framework/src/test/java/org/identityconnectors/common/IOUtilsTests.java:90-94
The mutant file.getPath().startsWith(rootPath), with the separator dropped, passes all four new IOUtilsTests cases (survives 4/4) and testRejectsBundleEntryEscapingTempDirectory. Every escaping input resolves to the parent directory, which fails any prefix check. Under that mutant, dir <tmp>/x with entry ../x2/evil resolves to <tmp>/x2/evil, which is the sibling variant of the zip slip this PR closes.
@Test(expectedExceptions = IOException.class)
public void resolveEntryRejectsSiblingSharingTheDirectoryNamePrefix() throws IOException {
File dir = Files.createTempDirectory("IOUtilsTests").toFile();
IOUtil.resolveEntry(dir, "../" + dir.getName() + "2/evil.jar");
}Pin: the mutant accepts <dir>2/evil.jar and this case turns red.
suggestion (non-blocking): No test pins that resolveEntry returns the canonical file, as its Javadoc says it does.
OpenICF-java-framework/connector-framework/src/test/java/org/identityconnectors/common/IOUtilsTests.java:78, :87
Both positive tests call getCanonicalFile() on the value resolveEntry returns before comparing it. As a result, a mutant that keeps the check but returns new File(dir, entryName) passes all four tests (survives 4/4). On macOS it returns /var/... instead of /private/var/.... It also returns lib/../lib/a.jar unnormalised. External callers of the public method can observe both. No caller in this repo depends on the returned path form.
@Test
public void resolveEntryReturnsTheCanonicalFile() throws IOException {
File dir = Files.createTempDirectory("IOUtilsTests").toFile();
assertEquals(IOUtil.resolveEntry(dir, "lib/../lib/a.jar"),
new File(dir, "lib/a.jar").getCanonicalFile());
}Pin: the non-canonical return value <dir>/lib/../lib/a.jar is not equal to <canonical dir>/lib/a.jar, so this case turns red under the mutant.
question (non-blocking): Is removing the public ECIESEncryptor without a deprecation release intended for 2.1.0?
OpenICF-java-framework/connector-framework-server/src/main/java/org/forgerock/openicf/framework/remote/security/ECIESEncryptor.java (deleted), OpenICF-java-framework/connector-framework-server/src/main/java/org/forgerock/openicf/framework/remote/OpenICFServerAdapter.java:490
Removing initialiseEncryptor() cannot break anyone: it always threw an NPE. ECIESEncryptor and its public ALGORITHM / FULL_ALGORITHM constants did work on their own at the base (SecurityUtilTest.testECIESEncryptor), so a third-party user would fail with NoClassDefFoundError against 2.1.x. A code search across the OpenIdentityPlatform org finds no Java consumer outside OpenICF, and users outside the org could not be checked. If removal is intended, nothing changes. If the project deprecates public API before removing it, this is a Major: keep the class @Deprecated for one release and dismiss the CodeQL alerts with that reason until then.
suggestion (non-blocking): The new IOUtilsTests leave their temporary directories and evil.jar behind.
OpenICF-java-framework/connector-framework/src/test/java/org/identityconnectors/common/IOUtilsTests.java:77, :86, :92, :98-101
Each test creates an IOUtilsTests* directory, and the unjar test also writes out/ and evil.jar in its directory. Nothing deletes them, and surefire does not redirect java.io.tmpdir, so every build leaves four directories in the system tmpdir. testRejectsBundleEntryEscapingTempDirectory does clean up after itself.
File dir = Files.createTempDirectory("IOUtilsTests").toFile();
try {
assertEquals(IOUtil.resolveEntry(dir, "lib/a.jar").getCanonicalFile(),
new File(dir, "lib/a.jar").getCanonicalFile());
} finally {
IOUtil.delete(dir);
}- Remove META-INF/services/...security.Encryptor; its only provider was the deleted ECIESEncryptor. - IOUtilsTests: a sibling sharing the directory name prefix (../<dir>2/evil.jar) is refused, and resolveEntry returns the canonical file (lib/../lib/a.jar); each test deletes its temporary directory.
|
Addressed in 3271b8c:
On removing connector-framework 192, connector-framework-internal 470, connector-framework-server 28, all green. |
maximthomas
left a comment
There was a problem hiding this comment.
praise: The round-1 follow-ups add tests that fail against the exact mutants they target.
resolveEntryRejectsSiblingSharingTheDirectoryNamePrefix(../<dir>2/evil.jar) fails if theFile.separatorsuffix in the prefix check (IOUtil.java:637-640) is dropped. Thefinallycleanup cannot mask that:IOUtil.deleteon the empty temp dir does not throw.resolveEntryReturnsTheCanonicalFilecompares the raw return value ofresolveEntry(dir, "lib/../lib/a.jar"), so the Javadoc's "canonical file" promise is now pinned.- The stale
META-INF/services/org.identityconnectors.common.security.Encryptorentry is gone, and no reference toECIESEncryptoris left in the tree.
Closes 11 of the 21 open high-severity CodeQL alerts (the ones that can be fixed without a compatibility impact):
java/zipslip#7 #8 #9 #10,java/weak-cryptographic-algorithm#3 #4,java/comparison-with-wider-type#16 #17 #18 #19,java/unreachable-exit-in-loop#23.Zip slip
new File(dir, entry.getName())let an archive entry such aslib/../../xland outside of the directory it is extracted into:LocalConnectorInfoManagerImpl.copyStreamToFile(stream, name)— thelib/*andnative/*entries of a connector bundle are expanded intojava.io.tmpdir/bundle-<random>/. A crafted bundle wrote outside of that directory (the new test reproduced it: the file showed up injava.io.tmpdir). Whoever can drop a bundle into the bundle directory can run code as the server anyway, so this is defence in depth rather than a privilege gain, but it costs one line.IOUtil.unjar— public utility, no callers left in this repository.ConnectorInfoReportMojo/DocBookResourceMojo— extract the plugin's own jar at build time; guarded for uniformity.All four go through the new
IOUtil.resolveEntry(dir, entryName), which throwsIOExceptionwhen the entry's canonical path leavesdir. The directory itself passes: the plugin strips theshared/prefix, so its directory entry resolves to an empty name — the first cut rejected that and would have aborted the resource copy; covered byresolveEntryAcceptsTheDirectoryItself.Dead ECIES code
ECIESEncryptor(AES/CBC/PKCS5 with the IV taken from the ECDH secret) was referenced only fromOpenICFServerAdapter.initialiseEncryptor()and from theMETA-INF/services/org.identityconnectors.common.security.Encryptorregistration. Nothing callsinitialiseEncryptor(), and the method starts withHandshakeMessage message = null; message.getPublicKey(). The registration could not be instantiated byServiceLoader, because the class had no no-arg constructor. All three are removed, together with the round-trip test inSecurityUtilTest. The public class goes without a deprecation release in 2.1.0.keyPairstays (it goes into the handshake message);SecurityUtil.doECDHstays as public API.Small ones
AuthenticationApiOpTests:for (int i …; i < getLongTestParam(MAX_ITERATIONS, 1); …)compared anintwith along; the counter islongnow.ObjectPool.borrowObjectNoTest:do { … } while (nanos > 0)— the body throws onnanos <= 0before the condition is ever evaluated, so the loop only left by returning or throwing; nowwhile (true)with a comment saying so. Same behaviour,ObjectPoolTestsgreen.Tests
IOUtilsTests:resolveEntrycovers an entry inside the directory, the directory itself, the canonical return value (lib/../lib/a.jar), an escaping entry (IOException) and a sibling sharing the directory name prefix (../<dir>2/…,IOException).unjarrefuses../escaped.txtand does not write it. Every test deletes its temporary directory.LocalConnectorInfoManagerTests.testRejectsBundleEntryEscapingTempDirectory: bundle withlib/ok.jarfollowed bylib/../../escaped-<uuid>.jar(the regular entry first, so thatlib/exists and the escape resolves) →ConfigurationException, nothing written tojava.io.tmpdir.longcounters have no tests: the plugin has no test harness and extracts its own artifact, and the counter change needsMAX_ITERATIONS > Integer.MAX_VALUE.Local runs: connector-framework 192, connector-framework-internal 470, connector-framework-server 28, all green; contract module and maven plugin compile.
Left open on purpose (separate decisions)
EncryptorImpl(update submodules #1 update opendj submodule #2 Bump org.apache.maven:maven-core from 3.0.4 to 3.8.1 in /OpenICF-maven-plugin #11 Bump com.jcraft:jsch from 0.1.53 to 0.1.54 in /OpenICF-ssh-connector #12): the default encryptor is the legacy wire format shared with the .NET connector server; the random one (in-memoryGuardedString) can move to AES/GCM without compatibility impact — separate PR.PasswordDecryptorDESede (#5 Merge submodules history in tree #6), LDAP{SHA}/{MD5}schemes (ADD Docker build images, test, release #27 optimise docker images size #28), SHA-1 connector key hash (Add IT test #25 FIX OpenIDM compatibility #26): formats dictated by external systems or existing configuration; candidates for dismissal with a recorded reason, or for a migration with dual support.