Skip to content

Refuse archive entries that escape their target directory and drop dead ECIES code - #126

Merged
vharseko merged 3 commits into
OpenIdentityPlatform:masterfrom
vharseko:codeql-high-zipslip-dead-crypto
Sep 30, 2026
Merged

vharseko merged 3 commits into
OpenIdentityPlatform:masterfrom
vharseko:codeql-high-zipslip-dead-crypto

Conversation

@vharseko

@vharseko vharseko commented Sep 18, 2026 •

Copy link
Copy Markdown
Member

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 as lib/../../x land outside of the directory it is extracted into:

  • LocalConnectorInfoManagerImpl.copyStreamToFile(stream, name) — the lib/* and native/* entries of a connector bundle are expanded into java.io.tmpdir/bundle-<random>/. A crafted bundle wrote outside of that directory (the new test reproduced it: the file showed up in java.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 throws IOException when the entry's canonical path leaves dir. The directory itself passes: the plugin strips the shared/ prefix, so its directory entry resolves to an empty name — the first cut rejected that and would have aborted the resource copy; covered by resolveEntryAcceptsTheDirectoryItself.

Dead ECIES code

ECIESEncryptor (AES/CBC/PKCS5 with the IV taken from the ECDH secret) was referenced only from OpenICFServerAdapter.initialiseEncryptor() and from the META-INF/services/org.identityconnectors.common.security.Encryptor registration. Nothing calls initialiseEncryptor(), and the method starts with HandshakeMessage message = null; message.getPublicKey(). The registration could not be instantiated by ServiceLoader, because the class had no no-arg constructor. All three are removed, together with the round-trip test in SecurityUtilTest. The public class goes without a deprecation release in 2.1.0. keyPair stays (it goes into the handshake message); SecurityUtil.doECDH stays as public API.

Small ones

  • AuthenticationApiOpTests: for (int i …; i < getLongTestParam(MAX_ITERATIONS, 1); …) compared an int with a long; the counter is long now.
  • ObjectPool.borrowObjectNoTest: do { … } while (nanos > 0) — the body throws on nanos <= 0 before the condition is ever evaluated, so the loop only left by returning or throwing; now while (true) with a comment saying so. Same behaviour, ObjectPoolTests green.

Tests

  • IOUtilsTests: resolveEntry covers 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). unjar refuses ../escaped.txt and does not write it. Every test deletes its temporary directory.
  • LocalConnectorInfoManagerTests.testRejectsBundleEntryEscapingTempDirectory: bundle with lib/ok.jar followed by lib/../../escaped-<uuid>.jar (the regular entry first, so that lib/ exists and the escape resolves) → ConfigurationException, nothing written to java.io.tmpdir.
  • The maven plugin sites and the long counters have no tests: the plugin has no test harness and extracts its own artifact, and the counter change needs MAX_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)

…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.
@vharseko vharseko added security Security fix / CVE remediation java Pull requests that update java code tests Test additions or fixes framework OpenICF-java-framework maven-plugin OpenICF-maven-plugin labels Sep 18, 2026
…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 maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • IOUtil.resolveEntry appends the separator to both sides of the prefix check, so the directory itself passes (the plugin's shared/ entry stripped to "") and a <dir>2 sibling does not.
  • copyStreamToFile canonicalises bundleDir before the parent walk, so the walk still ends when java.io.tmpdir goes through a symlink (green on the macOS CI jobs).
  • testRejectsBundleEntryEscapingTempDirectory reproduces the escape through the real bundle path, with the regular lib/ 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.Encryptor

suggestion (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.
@vharseko

Copy link
Copy Markdown
Member Author

Addressed in 3271b8c:

  • Stale Encryptor service registration — the META-INF/services/org.identityconnectors.common.security.Encryptor file is removed. The description now names it as well.
  • Sibling sharing the directory name prefix — added resolveEntryRejectsSiblingSharingTheDirectoryNamePrefix (../<dir>2/evil.jar). Checked against the file.getPath().startsWith(rootPath) mutant: only this test turns red.
  • Canonical return value — added resolveEntryReturnsTheCanonicalFile (lib/../lib/a.jar, compared without canonicalising the result). Checked against the return new File(dir, entryName) mutant: only this test turns red.
  • Temporary directories — every resolveEntry/unjar test now deletes its directory in finally via IOUtil.delete.

On removing ECIESEncryptor without a deprecation release: this is intended. 2.1.0 is a minor release. The 2.0 line has already removed public classes without deprecating them first (the Jetty bridge classes in #67). The class itself derives the CBC IV from the ECDH secret, which gives a fixed IV per key pair. Keeping it for a release as @Deprecated would mean dismissing #3/#4 for code that nothing calls.

connector-framework 192, connector-framework-internal 470, connector-framework-server 28, all green.

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: The round-1 follow-ups add tests that fail against the exact mutants they target.

  • resolveEntryRejectsSiblingSharingTheDirectoryNamePrefix (../<dir>2/evil.jar) fails if the File.separator suffix in the prefix check (IOUtil.java:637-640) is dropped. The finally cleanup cannot mask that: IOUtil.delete on the empty temp dir does not throw.
  • resolveEntryReturnsTheCanonicalFile compares the raw return value of resolveEntry(dir, "lib/../lib/a.jar"), so the Javadoc's "canonical file" promise is now pinned.
  • The stale META-INF/services/org.identityconnectors.common.security.Encryptor entry is gone, and no reference to ECIESEncryptor is left in the tree.

@vharseko
vharseko merged commit 5f13859 into OpenIdentityPlatform:master Sep 30, 2026
9 of 14 checks passed
@vharseko
vharseko deleted the codeql-high-zipslip-dead-crypto branch September 30, 2026 13:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

framework OpenICF-java-framework java Pull requests that update java code maven-plugin OpenICF-maven-plugin security Security fix / CVE remediation tests Test additions or fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants