From ebbd343b5832e685908c862fbb4855aa5301c682 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:54:30 +0200 Subject: [PATCH] harden: verify dependency integrity and add download timeouts Fixes two findings from the loader audit: 1. Runtime-downloaded dependencies were loaded with no integrity check. DependencyDownloader now verifies every artifact against the strongest checksum the repository publishes (sha512 > sha256 > sha1) before it is written to the local cache and added to the classpath. Fails closed: a mismatch or the absence of any published checksum rejects that repository so resolution falls back to another source (and errors if none can vouch for the artifact). Adds a pure, unit-tested Checksum utility. 3. Jar, checksum, and POM fetches had no connect/read timeouts, so a slow or half-open repository could stall plugin startup. All three now set a 15s connect / 30s read timeout. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JrkLFxzmmn7BpB9y6vMTeg --- eternalcore-plugin/build.gradle.kts | 1 + .../core/loader/dependency/Checksum.java | 92 +++++++++++++++++++ .../core/loader/dependency/Dependency.java | 5 + .../dependency/DependencyDownloader.java | 59 +++++++++++- .../core/loader/pom/PomXmlScanner.java | 10 +- .../core/loader/dependency/ChecksumTest.java | 58 ++++++++++++ 6 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 eternalcore-plugin/src/main/java/com/eternalcode/core/loader/dependency/Checksum.java create mode 100644 eternalcore-plugin/src/test/java/com/eternalcode/core/loader/dependency/ChecksumTest.java diff --git a/eternalcore-plugin/build.gradle.kts b/eternalcore-plugin/build.gradle.kts index e1991e995..74887e8b6 100644 --- a/eternalcore-plugin/build.gradle.kts +++ b/eternalcore-plugin/build.gradle.kts @@ -3,6 +3,7 @@ import org.gradle.jvm.toolchain.JavaToolchainService plugins { `eternalcode-java` + `eternalcode-java-test` `eternalcore-repositories` `eternalcore-shadow-compiler` `eternalcore-publish-plugin` diff --git a/eternalcore-plugin/src/main/java/com/eternalcode/core/loader/dependency/Checksum.java b/eternalcore-plugin/src/main/java/com/eternalcode/core/loader/dependency/Checksum.java new file mode 100644 index 000000000..f28e6aebe --- /dev/null +++ b/eternalcore-plugin/src/main/java/com/eternalcode/core/loader/dependency/Checksum.java @@ -0,0 +1,92 @@ +package com.eternalcode.core.loader.dependency; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * Verifies the integrity of downloaded artifacts against the checksum files published alongside them in a Maven + * repository (e.g. {@code artifact-1.0.jar.sha256}). + * + *

Ordered strongest-first so callers can prefer the most secure digest a repository publishes. SHA-1 is retained + * only as a last-resort fallback because it is the single digest Maven repositories are guaranteed to serve; it is + * cryptographically weak and should not be relied upon on its own. + */ +public enum Checksum { + + SHA512("sha512", "SHA-512"), + SHA256("sha256", "SHA-256"), + SHA1("sha1", "SHA-1"); + + private final String extension; + private final String algorithm; + + Checksum(String extension, String algorithm) { + this.extension = extension; + this.algorithm = algorithm; + } + + /** + * The file extension appended to the artifact name to locate this checksum (without a leading dot). + */ + public String extension() { + return this.extension; + } + + /** + * Computes the lower-case hex digest of the given data using this algorithm. + */ + public String hash(byte[] data) { + MessageDigest digest; + try { + digest = MessageDigest.getInstance(this.algorithm); + } + catch (NoSuchAlgorithmException exception) { + throw new DependencyException("Missing digest algorithm: " + this.algorithm, exception); + } + + return toHex(digest.digest(data)); + } + + /** + * Returns {@code true} if the digest of {@code data} equals the published checksum. + * + *

Published checksum files sometimes contain trailing content such as {@code " "}; + * only the leading token is compared, and comparison is case-insensitive. + */ + public boolean matches(byte[] data, String publishedChecksum) { + if (publishedChecksum == null) { + return false; + } + + String expected = normalize(publishedChecksum); + if (expected.isEmpty()) { + return false; + } + + return expected.equalsIgnoreCase(this.hash(data)); + } + + static String normalize(String rawChecksum) { + String trimmed = rawChecksum.trim(); + + for (int index = 0; index < trimmed.length(); index++) { + if (Character.isWhitespace(trimmed.charAt(index))) { + return trimmed.substring(0, index); + } + } + + return trimmed; + } + + private static String toHex(byte[] bytes) { + StringBuilder builder = new StringBuilder(bytes.length * 2); + + for (byte value : bytes) { + builder.append(Character.forDigit((value >> 4) & 0xF, 16)); + builder.append(Character.forDigit(value & 0xF, 16)); + } + + return builder.toString(); + } + +} diff --git a/eternalcore-plugin/src/main/java/com/eternalcode/core/loader/dependency/Dependency.java b/eternalcore-plugin/src/main/java/com/eternalcode/core/loader/dependency/Dependency.java index 6e24c40fb..5fd1117b0 100644 --- a/eternalcore-plugin/src/main/java/com/eternalcode/core/loader/dependency/Dependency.java +++ b/eternalcore-plugin/src/main/java/com/eternalcode/core/loader/dependency/Dependency.java @@ -44,6 +44,11 @@ public ResourceLocator toMavenJar(Repository repository, String classifier) { return toResource(repository, JAR_MAVEN_FORMAT_WITH_CLASSIFIER.formatted(this.artifactId, this.version, classifier)); } + public ResourceLocator toMavenJarChecksum(Repository repository, String checksumExtension) { + String jarName = JAR_MAVEN_FORMAT.formatted(this.artifactId, this.version); + return toResource(repository, jarName + "." + checksumExtension); + } + public ResourceLocator toPomXml(Repository repository) { return toResource(repository, POM_XML_FORMAT.formatted(this.artifactId, this.version)); } diff --git a/eternalcore-plugin/src/main/java/com/eternalcode/core/loader/dependency/DependencyDownloader.java b/eternalcore-plugin/src/main/java/com/eternalcode/core/loader/dependency/DependencyDownloader.java index b279270d0..bd0f68044 100644 --- a/eternalcore-plugin/src/main/java/com/eternalcode/core/loader/dependency/DependencyDownloader.java +++ b/eternalcore-plugin/src/main/java/com/eternalcode/core/loader/dependency/DependencyDownloader.java @@ -8,6 +8,7 @@ import java.io.InputStream; import java.net.URISyntaxException; import java.net.URLConnection; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; @@ -18,6 +19,9 @@ public class DependencyDownloader { + private static final int CONNECT_TIMEOUT_MILLIS = 15_000; + private static final int READ_TIMEOUT_MILLIS = 30_000; + private final Logger logger; private final Repository localRepository; private final List repositories; @@ -70,6 +74,8 @@ private Path tryDownloadDependency(Dependency dependency) throws URISyntaxExcept private Path downloadJarAndSave(Repository repository, Dependency dependency, Path file) { try { byte[] bytes = this.downloadJar(repository, dependency); + this.verifyChecksum(repository, dependency, bytes); + Path parent = file.getParent(); Files.createDirectories(parent); @@ -86,9 +92,7 @@ private Path downloadJarAndSave(Repository repository, Dependency dependency, Pa } private byte[] downloadJar(Repository repository, Dependency dependency) throws IOException { - URLConnection connection = dependency.toMavenJar(repository).toURL().openConnection(); - - try (InputStream in = connection.getInputStream()) { + try (InputStream in = openStream(dependency.toMavenJar(repository).toURL().openConnection())) { byte[] bytes = ByteStreams.toByteArray(in); if (bytes.length == 0) { @@ -99,4 +103,53 @@ private byte[] downloadJar(Repository repository, Dependency dependency) throws } } + /** + * Verifies the downloaded artifact against the strongest checksum the repository publishes for it. Fails closed: + * a mismatch, or the total absence of any published checksum, rejects this repository so the caller can fall back + * to another one (and ultimately fail if none can vouch for the artifact). This prevents a tampered or corrupted + * jar from being written to the local cache and loaded into the JVM. + */ + private void verifyChecksum(Repository repository, Dependency dependency, byte[] jarBytes) { + for (Checksum checksum : Checksum.values()) { + String publishedChecksum = this.downloadChecksum(repository, dependency, checksum); + + if (publishedChecksum == null) { + continue; + } + + if (!checksum.matches(jarBytes, publishedChecksum)) { + throw new DependencyException( + "Checksum mismatch (" + checksum.extension() + ") for " + dependency + " from " + repository + + " - refusing to load a potentially tampered dependency"); + } + + return; + } + + throw new DependencyException( + "No published checksum (sha512/sha256/sha1) available to verify " + dependency + " from " + repository); + } + + private String downloadChecksum(Repository repository, Dependency dependency, Checksum checksum) { + try (InputStream in = openStream(dependency.toMavenJarChecksum(repository, checksum.extension()).toURL().openConnection())) { + byte[] bytes = ByteStreams.toByteArray(in); + + if (bytes.length == 0) { + return null; + } + + return new String(bytes, StandardCharsets.UTF_8); + } + catch (IOException exception) { + // Checksum not served by this repository; the caller treats this as "cannot verify here". + return null; + } + } + + private static InputStream openStream(URLConnection connection) throws IOException { + connection.setConnectTimeout(CONNECT_TIMEOUT_MILLIS); + connection.setReadTimeout(READ_TIMEOUT_MILLIS); + return connection.getInputStream(); + } + } diff --git a/eternalcore-plugin/src/main/java/com/eternalcode/core/loader/pom/PomXmlScanner.java b/eternalcore-plugin/src/main/java/com/eternalcode/core/loader/pom/PomXmlScanner.java index 16a752ee7..89adb3b9f 100644 --- a/eternalcore-plugin/src/main/java/com/eternalcode/core/loader/pom/PomXmlScanner.java +++ b/eternalcore-plugin/src/main/java/com/eternalcode/core/loader/pom/PomXmlScanner.java @@ -8,6 +8,7 @@ import java.io.InputStream; import java.net.URISyntaxException; import java.net.URL; +import java.net.URLConnection; import java.nio.file.Files; import java.nio.file.StandardCopyOption; import java.util.ArrayList; @@ -25,6 +26,9 @@ public class PomXmlScanner implements DependencyScanner { + private static final int CONNECT_TIMEOUT_MILLIS = 15_000; + private static final int READ_TIMEOUT_MILLIS = 30_000; + private static final DocumentBuilderFactory DOCUMENT_BUILDER_FACTORY = DocumentBuilderFactory.newInstance(); static { @@ -91,7 +95,11 @@ private File savePomXmlToLocalRepository(Dependency dependency, Repository repos URL url = dependency.toPomXml(repository).toURL(); - try (InputStream inputStream = url.openStream()) { + URLConnection connection = url.openConnection(); + connection.setConnectTimeout(CONNECT_TIMEOUT_MILLIS); + connection.setReadTimeout(READ_TIMEOUT_MILLIS); + + try (InputStream inputStream = connection.getInputStream()) { Files.createDirectories(localFile.toPath()); Files.copy(inputStream, localFile.toPath(), StandardCopyOption.REPLACE_EXISTING); } diff --git a/eternalcore-plugin/src/test/java/com/eternalcode/core/loader/dependency/ChecksumTest.java b/eternalcore-plugin/src/test/java/com/eternalcode/core/loader/dependency/ChecksumTest.java new file mode 100644 index 000000000..a72dae99e --- /dev/null +++ b/eternalcore-plugin/src/test/java/com/eternalcode/core/loader/dependency/ChecksumTest.java @@ -0,0 +1,58 @@ +package com.eternalcode.core.loader.dependency; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class ChecksumTest { + + // Known digests of the ASCII string "abc". + private static final byte[] ABC = "abc".getBytes(StandardCharsets.UTF_8); + private static final String ABC_SHA1 = "a9993e364706816aba3e25717850c26c9cd0d89d"; + private static final String ABC_SHA256 = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + private static final String ABC_SHA512 = + "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a" + + "2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"; + + @Test + void computesKnownDigests() { + assertEquals(ABC_SHA1, Checksum.SHA1.hash(ABC)); + assertEquals(ABC_SHA256, Checksum.SHA256.hash(ABC)); + assertEquals(ABC_SHA512, Checksum.SHA512.hash(ABC)); + } + + @Test + void matchesIgnoringCase() { + assertTrue(Checksum.SHA256.matches(ABC, ABC_SHA256)); + assertTrue(Checksum.SHA256.matches(ABC, ABC_SHA256.toUpperCase())); + } + + @Test + void rejectsMismatchedChecksum() { + assertFalse(Checksum.SHA256.matches(ABC, ABC_SHA1)); + assertFalse(Checksum.SHA256.matches(ABC, "not-a-hash")); + } + + @Test + void rejectsNullOrBlankChecksum() { + assertFalse(Checksum.SHA256.matches(ABC, null)); + assertFalse(Checksum.SHA256.matches(ABC, " ")); + } + + @Test + void ignoresTrailingFilenameInPublishedChecksum() { + // Maven repositories occasionally publish " ". + assertTrue(Checksum.SHA256.matches(ABC, ABC_SHA256 + " artifact-1.0.jar")); + assertEquals(ABC_SHA256, Checksum.normalize(ABC_SHA256 + " artifact-1.0.jar")); + } + + @Test + void exposesExpectedExtensions() { + assertEquals("sha512", Checksum.SHA512.extension()); + assertEquals("sha256", Checksum.SHA256.extension()); + assertEquals("sha1", Checksum.SHA1.extension()); + } +}