You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by cs...@apache.org on 2026/08/31 11:37:27 UTC
(maven-resolver) branch master updated: fix: bound response reads and sanitize remote-derived strings (#2081)
This is an automated email from the ASF dual-hosted git repository.
cstamas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven-resolver.git
The following commit(s) were added to refs/heads/master by this push:
new 7006fc33c fix: bound response reads and sanitize remote-derived strings (#2081)
7006fc33c is described below
commit 7006fc33cfb2c48e01718edbede04aff9f2bbc0a
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Mon Aug 31 13:37:21 2026 +0200
fix: bound response reads and sanitize remote-derived strings (#2081)
## Summary
Fixes 4 findings from the maven-resolver security audit (scan-maven-resolver-20260811):
| Finding | Severity | Description |
|---------|----------|-------------|
| f004 | MEDIUM | HTTP error-response bodies read unbounded into memory (decompression-amplified OOM) |
| f024 | LOW | Hostile remote checksum file causes unbounded allocation |
| f026 | LOW | Server Last-Modified future date permanently pins artifact against refresh |
| f028 | LOW | Remote-derived strings reach console logs without control-character sanitization |
**Root cause:** Response bodies, checksum files, timestamps, and strings from the remote side are consumed with no size cap, no plausibility clamp, and no control-character sanitization.
**Fix:** Bound before allocating: cap error-body and checksum reads, clamp timestamps, sanitize control characters in log messages.
---
.../aether/transfer/ChecksumFailureException.java | 38 ++++++-
.../transfer/ChecksumFailureExceptionTest.java | 73 +++++++++++++
.../internal/impl/AbstractChecksumPolicy.java | 6 +-
.../internal/impl/DefaultArtifactResolver.java | 9 +-
.../internal/impl/DefaultChecksumProcessor.java | 72 ++++++++++---
.../aether/internal/impl/DefaultFileProcessor.java | 17 +--
.../internal/impl/DefaultUpdateCheckManager.java | 8 +-
.../eclipse/aether/internal/impl/LogSanitizer.java | 58 ++++++++++
.../aether/internal/impl/WarnChecksumPolicy.java | 8 +-
.../impl/DefaultChecksumProcessorTest.java | 111 +++++++++++++++++++
.../aether/internal/impl/LogSanitizerTest.java | 80 ++++++++++++++
.../transport/http/RFC9457/RFC9457Reporter.java | 50 ++++++++-
.../http/RFC9457/RFC9457ReporterTest.java | 118 +++++++++++++++++++++
.../transport/apache/ApacheRFC9457Reporter.java | 15 ++-
.../aether/transport/apache/ApacheTransporter.java | 4 +-
.../aether/transport/jdk/JdkRFC9457Reporter.java | 3 +-
.../aether/transport/jdk/JdkTransporter.java | 7 +-
.../transport/jetty/JettyRFC9457Reporter.java | 3 +-
.../aether/transport/jetty/JettyTransporter.java | 3 +-
.../aether/transport/url/UrlTransporter.java | 3 +-
.../org/eclipse/aether/util/ChecksumUtils.java | 68 +++++++++---
.../transport/http/HttpTransporterUtils.java | 19 ++++
.../transport/http/HttpTransporterUtilsTest.java | 20 ++++
23 files changed, 720 insertions(+), 73 deletions(-)
diff --git a/maven-resolver-api/src/main/java/org/eclipse/aether/transfer/ChecksumFailureException.java b/maven-resolver-api/src/main/java/org/eclipse/aether/transfer/ChecksumFailureException.java
index 100bb342b..c80ca67e5 100644
--- a/maven-resolver-api/src/main/java/org/eclipse/aether/transfer/ChecksumFailureException.java
+++ b/maven-resolver-api/src/main/java/org/eclipse/aether/transfer/ChecksumFailureException.java
@@ -68,14 +68,42 @@ public class ChecksumFailureException extends RepositoryException {
public static ChecksumFailureException mismatchDetail(
String detail, String expected, String expectedKind, String actual) {
String message = "Checksum validation failed, expected '"
- + expected + "'" + (expectedKind == null ? "" : " (" + expectedKind + ")")
- + " but is actually '" + actual + "'";
+ + sanitize(expected) + "'" + (expectedKind == null ? "" : " (" + sanitize(expectedKind) + ")")
+ + " but is actually '" + sanitize(actual) + "'";
if (detail != null) {
- message = message + " (" + detail + ")";
+ message = message + " (" + sanitize(detail) + ")";
}
return new ChecksumFailureException(message, null, expected, expectedKind, actual, true);
}
+ /**
+ * Replaces ISO control characters below U+0020 (except LF and TAB) and DEL (U+007F) with their visible
+ * {@code \}{@code uXXXX} escapes. The expected checksum value is remote-supplied and ends up in log output (the
+ * default "warn" checksum policy logs this exception's message as the sole integrity warning): raw terminal
+ * control characters such as ESC or CR embedded in it could erase or forge that log line. The
+ * {@link #getExpected()}, {@link #getExpectedKind()} and {@link #getActual()} accessors keep returning the
+ * raw values.
+ */
+ private static String sanitize(String value) {
+ if (value == null) {
+ return null;
+ }
+ StringBuilder result = null;
+ for (int i = 0; i < value.length(); i++) {
+ char c = value.charAt(i);
+ if ((c < 0x20 && c != '\n' && c != '\t') || c == 0x7f) {
+ if (result == null) {
+ result = new StringBuilder(value.length() + 16);
+ result.append(value, 0, i);
+ }
+ result.append(String.format("\\u%04X", (int) c));
+ } else if (result != null) {
+ result.append(c);
+ }
+ }
+ return result == null ? value : result.toString();
+ }
+
/**
* Use in case of checksum not available. Optionally, one can specify which kind was not available.
*
@@ -112,8 +140,8 @@ public class ChecksumFailureException extends RepositoryException {
@Deprecated
public ChecksumFailureException(String expected, String expectedKind, String actual) {
super("Checksum validation failed, expected '"
- + expected + "'" + (expectedKind == null ? "" : " (" + expectedKind + ")")
- + " but is actually '" + actual + "'");
+ + sanitize(expected) + "'" + (expectedKind == null ? "" : " (" + sanitize(expectedKind) + ")")
+ + " but is actually '" + sanitize(actual) + "'");
this.expected = expected;
this.expectedKind = expectedKind;
this.actual = actual;
diff --git a/maven-resolver-api/src/test/java/org/eclipse/aether/transfer/ChecksumFailureExceptionTest.java b/maven-resolver-api/src/test/java/org/eclipse/aether/transfer/ChecksumFailureExceptionTest.java
new file mode 100644
index 000000000..b97d27243
--- /dev/null
+++ b/maven-resolver-api/src/test/java/org/eclipse/aether/transfer/ChecksumFailureExceptionTest.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.eclipse.aether.transfer;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class ChecksumFailureExceptionTest {
+
+ private static final String ESC = "\u001B";
+
+ private static final String DEL = "\u007F";
+
+ @Test
+ void mismatchMessageCarriesValues() {
+ ChecksumFailureException e = ChecksumFailureException.mismatch("cafebabe", "PROVIDED", "deadbeef");
+ assertTrue(e.getMessage().contains("cafebabe"));
+ assertTrue(e.getMessage().contains("PROVIDED"));
+ assertTrue(e.getMessage().contains("deadbeef"));
+ assertEquals("cafebabe", e.getExpected());
+ assertEquals("PROVIDED", e.getExpectedKind());
+ assertEquals("deadbeef", e.getActual());
+ assertTrue(e.isRetryWorthy());
+ }
+
+ @Test
+ void mismatchMessageEscapesControlCharacters() {
+ // remote-supplied checksum values reach WARN logs via this message; terminal escapes must be neutralized
+ String hostileExpected = "abc" + ESC + "[2K\rdef";
+ String hostileActual = "actual" + DEL;
+ ChecksumFailureException e = ChecksumFailureException.mismatchDetail(
+ "de" + ESC + "tail", hostileExpected, "PROVIDED", hostileActual);
+ String message = e.getMessage();
+ assertFalse(message.contains(ESC), "raw ESC must not survive into the message");
+ assertFalse(message.contains("\r"), "raw CR must not survive into the message");
+ assertFalse(message.contains(DEL), "raw DEL must not survive into the message");
+ assertTrue(message.contains("abc\\u001B[2K\\u000Ddef"));
+ assertTrue(message.contains("actual\\u007F"));
+ assertTrue(message.contains("de\\u001Btail"));
+ // the accessors keep returning the raw values for programmatic consumers
+ assertEquals(hostileExpected, e.getExpected());
+ assertEquals(hostileActual, e.getActual());
+ assertTrue(e.isRetryWorthy());
+ }
+
+ @Test
+ @SuppressWarnings("deprecation")
+ void deprecatedMismatchConstructorEscapesControlCharacters() {
+ ChecksumFailureException e = new ChecksumFailureException("bad" + ESC + "[1A", "PROVIDED", "actual");
+ assertFalse(e.getMessage().contains(ESC));
+ assertTrue(e.getMessage().contains("bad\\u001B[1A"));
+ assertEquals("bad" + ESC + "[1A", e.getExpected());
+ }
+}
diff --git a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/AbstractChecksumPolicy.java b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/AbstractChecksumPolicy.java
index 2007042fc..8dd408774 100644
--- a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/AbstractChecksumPolicy.java
+++ b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/AbstractChecksumPolicy.java
@@ -55,7 +55,11 @@ abstract class AbstractChecksumPolicy implements ChecksumPolicy {
throws ChecksumFailureException {
requireNonNull(algorithm, "algorithm cannot be null");
requireNonNull(exception, "exception cannot be null");
- logger.debug("Could not validate {} checksum for {}", algorithm, resource.getResourceName(), exception);
+ logger.debug(
+ "Could not validate {} checksum for {}",
+ algorithm,
+ LogSanitizer.sanitize(resource.getResourceName()),
+ exception);
}
@Override
diff --git a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultArtifactResolver.java b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultArtifactResolver.java
index e4ddcde55..dc781a03b 100644
--- a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultArtifactResolver.java
+++ b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultArtifactResolver.java
@@ -379,11 +379,14 @@ public class DefaultArtifactResolver implements ArtifactResolver {
if (local.getPath() != null) {
LOGGER.info(
"Artifact {} is present in the local repository, but cached from a remote repository ID that is unavailable in current build context, verifying that is downloadable from {}",
- artifact,
- remoteRepositories);
+ LogSanitizer.sanitize(String.valueOf(artifact)),
+ LogSanitizer.sanitize(String.valueOf(remoteRepositories)));
}
- LOGGER.debug("Resolving artifact {} from {}", artifact, remoteRepositories);
+ LOGGER.debug(
+ "Resolving artifact {} from {}",
+ LogSanitizer.sanitize(String.valueOf(artifact)),
+ LogSanitizer.sanitize(String.valueOf(remoteRepositories)));
AtomicBoolean resolved = new AtomicBoolean(false);
Iterator<ResolutionGroup> groupIt = groups.iterator();
for (RemoteRepository repo : filteredRemoteRepositories) {
diff --git a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultChecksumProcessor.java b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultChecksumProcessor.java
index 5def5234c..e558b51fc 100644
--- a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultChecksumProcessor.java
+++ b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultChecksumProcessor.java
@@ -39,6 +39,15 @@ import static java.util.Objects.requireNonNull;
@Singleton
@Named
public class DefaultChecksumProcessor implements ChecksumProcessor {
+ /**
+ * Upper bound (in characters) for data read from a checksum file. Every sane checksum file format fits well
+ * within this limit (the longest is "SHA-512 (<file name>) = <128 hex chars>"). Checksum files
+ * are fetched from remote repositories: without a bound, a hostile repository answering a checksum request
+ * with a multi-gigabyte single-line body would be buffered wholesale into memory, exhausting the build JVM
+ * heap. Longer input is rejected as malformed instead of being buffered.
+ */
+ static final int MAX_CHECKSUM_FILE_CHARS = 8192;
+
private final PathProcessor pathProcessor;
@Inject
@@ -48,23 +57,12 @@ public class DefaultChecksumProcessor implements ChecksumProcessor {
@Override
public String readChecksum(final Path checksumPath) throws IOException {
- // for now do exactly same as happened before, but FileProcessor is a component and can be replaced
- String checksum = "";
+ String checksum;
try (BufferedReader br = Files.newBufferedReader(checksumPath, StandardCharsets.UTF_8)) {
- while (true) {
- String line = br.readLine();
- if (line == null) {
- break;
- }
- line = line.trim();
- if (!line.isEmpty()) {
- checksum = line;
- break;
- }
- }
+ checksum = readFirstNonEmptyLine(br, checksumPath.toString());
}
- if (checksum.matches(".+= [0-9A-Fa-f]+")) {
+ if (isAlgorithmHeaderFormat(checksum)) {
int lastSpacePos = checksum.lastIndexOf(' ');
checksum = checksum.substring(lastSpacePos + 1);
} else {
@@ -78,6 +76,52 @@ public class DefaultChecksumProcessor implements ChecksumProcessor {
return checksum;
}
+ /**
+ * Reads the first non-empty line, enforcing {@link #MAX_CHECKSUM_FILE_CHARS} on the total amount of data
+ * consumed. Returns the trimmed line, or an empty string if the stream holds no non-empty line.
+ */
+ static String readFirstNonEmptyLine(BufferedReader reader, String source) throws IOException {
+ StringBuilder buffer = new StringBuilder(64);
+ int read = 0;
+ int c;
+ while ((c = reader.read()) != -1) {
+ if (++read > MAX_CHECKSUM_FILE_CHARS) {
+ throw new IOException("Checksum file " + source + " is malformed: longer than "
+ + MAX_CHECKSUM_FILE_CHARS + " characters");
+ }
+ if (c == '\n' || c == '\r') {
+ String line = buffer.toString().trim();
+ if (!line.isEmpty()) {
+ return line;
+ }
+ buffer.setLength(0);
+ } else {
+ buffer.append((char) c);
+ }
+ }
+ return buffer.toString().trim();
+ }
+
+ /**
+ * Non-backtracking equivalent of {@code line.matches(".+= [0-9A-Fa-f]+")}: at least one character, followed
+ * by "= ", followed by one or more hex digits reaching the end of the line ("<algorithm> (<file>)
+ * = <hex>" style checksum lines).
+ */
+ static boolean isAlgorithmHeaderFormat(String line) {
+ int lastSpacePos = line.lastIndexOf(' ');
+ if (lastSpacePos < 2 || lastSpacePos == line.length() - 1 || line.charAt(lastSpacePos - 1) != '=') {
+ return false;
+ }
+ for (int i = lastSpacePos + 1; i < line.length(); i++) {
+ char ch = line.charAt(i);
+ boolean hex = (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F');
+ if (!hex) {
+ return false;
+ }
+ }
+ return true;
+ }
+
@Override
public void writeChecksum(Path target, String checksum) throws IOException {
// for now do exactly same as happened before, but FileProcessor is a component and can be replaced
diff --git a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultFileProcessor.java b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultFileProcessor.java
index ed4660ac7..949ddb0b4 100644
--- a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultFileProcessor.java
+++ b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultFileProcessor.java
@@ -145,24 +145,13 @@ public class DefaultFileProcessor implements FileProcessor {
@Override
public String readChecksum(final File checksumFile) throws IOException {
- // for now do exactly same as happened before, but FileProcessor is a component and can be replaced
- String checksum = "";
+ String checksum;
try (BufferedReader br = new BufferedReader(
new InputStreamReader(Files.newInputStream(checksumFile.toPath()), StandardCharsets.UTF_8), 512)) {
- while (true) {
- String line = br.readLine();
- if (line == null) {
- break;
- }
- line = line.trim();
- if (!line.isEmpty()) {
- checksum = line;
- break;
- }
- }
+ checksum = DefaultChecksumProcessor.readFirstNonEmptyLine(br, checksumFile.toString());
}
- if (checksum.matches(".+= [0-9A-Fa-f]+")) {
+ if (DefaultChecksumProcessor.isAlgorithmHeaderFormat(checksum)) {
int lastSpacePos = checksum.lastIndexOf(' ');
checksum = checksum.substring(lastSpacePos + 1);
} else {
diff --git a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultUpdateCheckManager.java b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultUpdateCheckManager.java
index 0a8af9304..9eab98a89 100644
--- a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultUpdateCheckManager.java
+++ b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultUpdateCheckManager.java
@@ -235,7 +235,7 @@ public class DefaultUpdateCheckManager implements UpdateCheckManager {
+ repository.getUrl() + " during a previous attempt. This failure"
+ " was cached in the local repository and"
+ " resolution is not reattempted until the update interval of " + repository.getId()
- + " has elapsed or updates are forced. Original error: " + error,
+ + " has elapsed or updates are forced. Original error: " + LogSanitizer.sanitize(error),
true);
}
}
@@ -333,7 +333,7 @@ public class DefaultUpdateCheckManager implements UpdateCheckManager {
+ repository.getUrl() + " during a previous attempt."
+ " This failure was cached in the local repository and"
+ " resolution will not be reattempted until the update interval of " + repository.getId()
- + " has elapsed or updates are forced. Original error: " + error,
+ + " has elapsed or updates are forced. Original error: " + LogSanitizer.sanitize(error),
true);
}
}
@@ -563,7 +563,9 @@ public class DefaultUpdateCheckManager implements UpdateCheckManager {
if (msg == null || msg.isEmpty()) {
msg = error.getClass().getSimpleName();
}
- updates.put(dataKey + ERROR_KEY_SUFFIX, msg);
+ // transfer error messages may embed remote-derived bytes: neutralize terminal control characters
+ // before persisting them, as they are re-read and logged on subsequent update checks
+ updates.put(dataKey + ERROR_KEY_SUFFIX, LogSanitizer.sanitize(msg));
updates.put(dataKey + UPDATED_KEY_SUFFIX, null);
updates.put(transferKey + UPDATED_KEY_SUFFIX, timestamp);
}
diff --git a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/LogSanitizer.java b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/LogSanitizer.java
new file mode 100644
index 000000000..e27e2f2f3
--- /dev/null
+++ b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/LogSanitizer.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.eclipse.aether.internal.impl;
+
+/**
+ * Sanitizes strings that may carry remote-derived bytes (artifact coordinates, checksum values, transfer error
+ * messages) before they are logged or persisted. Bytes received from a remote repository must not be able to
+ * influence terminal rendering: raw control characters such as ESC (ANSI escape sequences) or CR (line rewrite)
+ * embedded in, for example, a transitive POM's coordinates could otherwise erase or forge log lines - including
+ * the sole integrity WARNING emitted under the default "warn" checksum policy.
+ *
+ * @since 2.0.23
+ */
+final class LogSanitizer {
+ private LogSanitizer() {}
+
+ /**
+ * Replaces every ISO control character below U+0020 (except LF and TAB) as well as DEL (U+007F) with its
+ * visible {@code \}{@code uXXXX} escape, preserving the evidence while neutralizing terminal escape
+ * sequences. Returns the input instance unchanged (no allocation) when nothing needs escaping; returns
+ * {@code null} for {@code null} input.
+ */
+ static String sanitize(String value) {
+ if (value == null) {
+ return null;
+ }
+ StringBuilder result = null;
+ for (int i = 0; i < value.length(); i++) {
+ char c = value.charAt(i);
+ if ((c < 0x20 && c != '\n' && c != '\t') || c == 0x7f) {
+ if (result == null) {
+ result = new StringBuilder(value.length() + 16);
+ result.append(value, 0, i);
+ }
+ result.append(String.format("\\u%04X", (int) c));
+ } else if (result != null) {
+ result.append(c);
+ }
+ }
+ return result == null ? value : result.toString();
+ }
+}
diff --git a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/WarnChecksumPolicy.java b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/WarnChecksumPolicy.java
index f9c47d7d3..2d582025e 100644
--- a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/WarnChecksumPolicy.java
+++ b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/WarnChecksumPolicy.java
@@ -34,8 +34,8 @@ final class WarnChecksumPolicy extends AbstractChecksumPolicy {
public boolean onTransferChecksumFailure(ChecksumFailureException exception) {
logger.warn(
"Could not validate integrity of download from {}{}",
- resource.getRepositoryUrl(),
- resource.getResourceName(),
+ LogSanitizer.sanitize(resource.getRepositoryUrl()),
+ LogSanitizer.sanitize(resource.getResourceName()),
exception);
return true;
}
@@ -44,7 +44,7 @@ final class WarnChecksumPolicy extends AbstractChecksumPolicy {
public void onNoMoreChecksums() {
logger.warn(
"Could not validate integrity of download from {}{}: no checksums available",
- resource.getRepositoryUrl(),
- resource.getResourceName());
+ LogSanitizer.sanitize(resource.getRepositoryUrl()),
+ LogSanitizer.sanitize(resource.getResourceName()));
}
}
diff --git a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultChecksumProcessorTest.java b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultChecksumProcessorTest.java
new file mode 100644
index 000000000..7ac4696fd
--- /dev/null
+++ b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultChecksumProcessorTest.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.eclipse.aether.internal.impl;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class DefaultChecksumProcessorTest {
+
+ private static final String SHA1 = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
+
+ @TempDir
+ private Path targetDir;
+
+ private DefaultChecksumProcessor checksumProcessor;
+
+ @BeforeEach
+ void setup() {
+ checksumProcessor = new DefaultChecksumProcessor(new DefaultPathProcessor());
+ }
+
+ private Path write(String content) throws IOException {
+ Path file = targetDir.resolve("checksum.sha1");
+ Files.write(file, content.getBytes(StandardCharsets.UTF_8));
+ return file;
+ }
+
+ @Test
+ void testReadChecksum() throws IOException {
+ assertEquals(SHA1, checksumProcessor.readChecksum(write(SHA1)));
+ }
+
+ @Test
+ void testReadChecksumEmptyFile() throws IOException {
+ assertEquals("", checksumProcessor.readChecksum(write("")));
+ }
+
+ @Test
+ void testReadChecksumLeadingEmptyLinesAndWhitespace() throws IOException {
+ assertEquals(SHA1, checksumProcessor.readChecksum(write("\n\r\n \n " + SHA1 + " \n")));
+ }
+
+ @Test
+ void testReadChecksumAlgorithmHeaderFormat() throws IOException {
+ assertEquals(SHA1, checksumProcessor.readChecksum(write("foobar(alg) = " + SHA1)));
+ }
+
+ @Test
+ void testReadChecksumFirstTokenFormat() throws IOException {
+ assertEquals(SHA1, checksumProcessor.readChecksum(write(SHA1 + " foobar")));
+ }
+
+ @Test
+ void testReadChecksumNonHexTailFallsBackToFirstToken() throws IOException {
+ // "= " present but tail is not hex: not the "<algorithm> (<file>) = <hex>" format,
+ // so the first token wins (matches the historic regex-based behavior)
+ assertEquals("foo=", checksumProcessor.readChecksum(write("foo= xyz")));
+ }
+
+ @Test
+ void testReadChecksumOversizedInputRejected() throws IOException {
+ char[] big = new char[DefaultChecksumProcessor.MAX_CHECKSUM_FILE_CHARS + 1];
+ Arrays.fill(big, 'a');
+ Path file = write(new String(big));
+ IOException e = assertThrows(IOException.class, () -> checksumProcessor.readChecksum(file));
+ assertTrue(e.getMessage().contains("malformed"));
+ }
+
+ @Test
+ void testReadChecksumOversizedSecondLineRejected() throws IOException {
+ // the bound is on total data consumed, not per line: a checksum hidden behind a huge
+ // preamble of blank lines is equally malformed
+ char[] blanks = new char[DefaultChecksumProcessor.MAX_CHECKSUM_FILE_CHARS + 1];
+ Arrays.fill(blanks, '\n');
+ Path file = write(new String(blanks) + SHA1);
+ assertThrows(IOException.class, () -> checksumProcessor.readChecksum(file));
+ }
+
+ @Test
+ void testReadChecksumWithinBoundAccepted() throws IOException {
+ // a legal checksum preceded by a modest preamble stays accepted
+ assertEquals(SHA1, checksumProcessor.readChecksum(write("\n\n\n" + SHA1)));
+ }
+}
diff --git a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/LogSanitizerTest.java b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/LogSanitizerTest.java
new file mode 100644
index 000000000..8dad31f45
--- /dev/null
+++ b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/LogSanitizerTest.java
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.eclipse.aether.internal.impl;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+public class LogSanitizerTest {
+
+ private static final char ESC = '\u001B';
+
+ private static final char DEL = '\u007F';
+
+ @Test
+ void nullPassesThrough() {
+ assertNull(LogSanitizer.sanitize(null));
+ }
+
+ @Test
+ void cleanStringReturnsSameInstance() {
+ String clean = "org.apache.maven.resolver:artifact:jar:1.0 from https://repo.example.com/";
+ assertSame(clean, LogSanitizer.sanitize(clean));
+ }
+
+ @Test
+ void newlineAndTabArePreserved() {
+ String value = "line1\nline2\tcolumn";
+ assertSame(value, LogSanitizer.sanitize(value));
+ }
+
+ @Test
+ void escapeSequenceIsNeutralized() {
+ // ESC[2K erases the current terminal line, CR returns the cursor: together they cancel a WARNING line
+ assertEquals("abc\\u001B[2K\\u000Ddef", LogSanitizer.sanitize("abc" + ESC + "[2K\rdef"));
+ }
+
+ @Test
+ void carriageReturnIsEscaped() {
+ assertEquals("evil\\u000D[INFO] BUILD SUCCESS", LogSanitizer.sanitize("evil\r[INFO] BUILD SUCCESS"));
+ }
+
+ @Test
+ void deleteAndLowControlsAreEscaped() {
+ assertEquals("a\\u007Fb\\u0000c\\u0008d", LogSanitizer.sanitize("a" + DEL + "b\u0000c\bd"));
+ }
+
+ @Test
+ void sanitizedOutputContainsNoRawControls() {
+ StringBuilder hostile = new StringBuilder("x");
+ for (char c = 0; c < 0x20; c++) {
+ hostile.append(c);
+ }
+ hostile.append(DEL).append('y');
+ String sanitized = LogSanitizer.sanitize(hostile.toString());
+ for (int i = 0; i < sanitized.length(); i++) {
+ char c = sanitized.charAt(i);
+ assertFalse((c < 0x20 && c != '\n' && c != '\t') || c == DEL, "raw control char at index " + i);
+ }
+ }
+}
diff --git a/maven-resolver-spi/src/main/java/org/eclipse/aether/spi/connector/transport/http/RFC9457/RFC9457Reporter.java b/maven-resolver-spi/src/main/java/org/eclipse/aether/spi/connector/transport/http/RFC9457/RFC9457Reporter.java
index 7fb51a945..a250ffebf 100644
--- a/maven-resolver-spi/src/main/java/org/eclipse/aether/spi/connector/transport/http/RFC9457/RFC9457Reporter.java
+++ b/maven-resolver-spi/src/main/java/org/eclipse/aether/spi/connector/transport/http/RFC9457/RFC9457Reporter.java
@@ -18,7 +18,10 @@
*/
package org.eclipse.aether.spi.connector.transport.http.RFC9457;
+import java.io.ByteArrayOutputStream;
import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
/**
* A reporter for RFC 9457 messages.
@@ -38,6 +41,14 @@ public abstract class RFC9457Reporter<T, E extends Exception, R> {
public static final String CONTENT_TYPE_PROBLEM_DETAILS_JSON = "application/problem+json";
public static final String CONTENT_TYPE_PROBLEM_DETAILS_JSON_AND_ANY = "application/problem+json,*/*";
+ /**
+ * Maximum number of bytes read from an error response body. Legitimate RFC 9457 payloads are
+ * tiny, while the body of an error response is attacker influenced data (the content type header
+ * alone triggers consumption) and may be transparently decompressed by the transport, so it must
+ * never be buffered unbounded. Bodies larger than this limit are truncated, not rejected.
+ */
+ public static final int MAX_BODY_BYTES = 64 * 1024;
+
protected abstract boolean isRFC9457Message(T response);
protected abstract int getStatusCode(T response);
@@ -54,6 +65,30 @@ public abstract class RFC9457Reporter<T, E extends Exception, R> {
*/
public abstract void prepareRequest(R request);
+ /**
+ * Reads the given stream into a UTF-8 string, consuming at most {@link #MAX_BODY_BYTES} bytes.
+ * Any remaining bytes are left unread, so an oversized (or decompression amplified) body is
+ * truncated instead of exhausting memory. The caller remains responsible for closing the stream.
+ *
+ * @param is The stream to read the body from, must not be {@code null}.
+ * @return The body as UTF-8 string, truncated to {@link #MAX_BODY_BYTES} bytes, never {@code null}.
+ * @throws IOException If reading the stream fails.
+ */
+ protected static String readBody(InputStream is) throws IOException {
+ ByteArrayOutputStream body = new ByteArrayOutputStream();
+ byte[] chunk = new byte[8 * 1024];
+ int remaining = MAX_BODY_BYTES;
+ while (remaining > 0) {
+ int read = is.read(chunk, 0, Math.min(chunk.length, remaining));
+ if (read < 0) {
+ break;
+ }
+ body.write(chunk, 0, read);
+ remaining -= read;
+ }
+ return body.toString(StandardCharsets.UTF_8.name());
+ }
+
protected boolean hasRFC9457ContentType(String contentType) {
if (contentType == null) {
return false;
@@ -88,8 +123,19 @@ public abstract class RFC9457Reporter<T, E extends Exception, R> {
}
if (body != null && !body.isEmpty()) {
- RFC9457Payload rfc9457Payload = RFC9457Parser.parse(body);
- throw new HttpRFC9457Exception(statusCode, reasonPhrase, rfc9457Payload);
+ RFC9457Payload rfc9457Payload;
+ try {
+ rfc9457Payload = RFC9457Parser.parse(body);
+ } catch (RuntimeException ignore) {
+ // Malformed (possibly truncated, see MAX_BODY_BYTES) problem details
+ // must not change the error classification.
+ rfc9457Payload = null;
+ }
+ if (rfc9457Payload != null) {
+ throw new HttpRFC9457Exception(statusCode, reasonPhrase, rfc9457Payload);
+ }
+ baseException.accept(statusCode, reasonPhrase);
+ return;
}
throw new HttpRFC9457Exception(statusCode, reasonPhrase, RFC9457Payload.INSTANCE);
}
diff --git a/maven-resolver-spi/src/test/java/org/eclipse/aether/spi/connector/transport/http/RFC9457/RFC9457ReporterTest.java b/maven-resolver-spi/src/test/java/org/eclipse/aether/spi/connector/transport/http/RFC9457/RFC9457ReporterTest.java
index 95db2325a..6e1cad9f6 100644
--- a/maven-resolver-spi/src/test/java/org/eclipse/aether/spi/connector/transport/http/RFC9457/RFC9457ReporterTest.java
+++ b/maven-resolver-spi/src/test/java/org/eclipse/aether/spi/connector/transport/http/RFC9457/RFC9457ReporterTest.java
@@ -18,12 +18,58 @@
*/
package org.eclipse.aether.spi.connector.transport.http.RFC9457;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class RFC9457ReporterTest {
+ private static final class TestReporter extends RFC9457Reporter<String, IOException, Object> {
+ private final boolean rfc9457;
+
+ private boolean bodyRead;
+
+ private TestReporter(boolean rfc9457) {
+ this.rfc9457 = rfc9457;
+ }
+
+ @Override
+ protected boolean isRFC9457Message(String response) {
+ return rfc9457;
+ }
+
+ @Override
+ protected int getStatusCode(String response) {
+ return 404;
+ }
+
+ @Override
+ protected String getReasonPhrase(String response) {
+ return "Not Found";
+ }
+
+ @Override
+ protected String getBody(String response) throws IOException {
+ bodyRead = true;
+ try (InputStream is = new ByteArrayInputStream(response.getBytes(StandardCharsets.UTF_8))) {
+ return readBody(is);
+ }
+ }
+
+ @Override
+ public void prepareRequest(Object request) {}
+ }
+
+ private static void throwBaseException(Integer statusCode, String reasonPhrase) throws IOException {
+ throw new IOException("base:" + statusCode);
+ }
+
@Test
void hasRFC9457ContentType() {
RFC9457Reporter<Object, Exception, Object> reporter = new RFC9457Reporter<Object, Exception, Object>() {
@@ -53,4 +99,76 @@ class RFC9457ReporterTest {
assertTrue(reporter.hasRFC9457ContentType("application/problem+json"));
assertTrue(reporter.hasRFC9457ContentType("application/problem+json; charset=utf-8"));
}
+
+ @Test
+ void readBodyTruncatesOversizedStream() throws IOException {
+ byte[] oversized = new byte[RFC9457Reporter.MAX_BODY_BYTES + 4096];
+ Arrays.fill(oversized, (byte) 'a');
+ ByteArrayInputStream is = new ByteArrayInputStream(oversized);
+ String body = RFC9457Reporter.readBody(is);
+ assertEquals(RFC9457Reporter.MAX_BODY_BYTES, body.length());
+ assertEquals(4096, is.available());
+ }
+
+ @Test
+ void readBodyReadsSmallStreamCompletely() throws IOException {
+ String payload = "{\"title\":\"gone\"}";
+ String body = RFC9457Reporter.readBody(new ByteArrayInputStream(payload.getBytes(StandardCharsets.UTF_8)));
+ assertEquals(payload, body);
+ }
+
+ @Test
+ void validProblemDetailsThrowsHttpRFC9457Exception() {
+ TestReporter reporter = new TestReporter(true);
+ HttpRFC9457Exception exception = assertThrows(
+ HttpRFC9457Exception.class,
+ () -> reporter.generateException(
+ "{\"status\":404,\"title\":\"not found\"}", RFC9457ReporterTest::throwBaseException));
+ assertEquals(404, exception.getStatusCode());
+ assertEquals("not found", exception.getPayload().getTitle());
+ }
+
+ @Test
+ void malformedProblemDetailsFallsBackToBaseException() {
+ TestReporter reporter = new TestReporter(true);
+ IOException exception = assertThrows(
+ IOException.class,
+ () -> reporter.generateException("{ this is not json", RFC9457ReporterTest::throwBaseException));
+ assertFalse(exception instanceof HttpRFC9457Exception);
+ assertEquals("base:404", exception.getMessage());
+ }
+
+ @Test
+ void oversizedProblemDetailsIsTruncatedAndFallsBackToBaseException() {
+ char[] padding = new char[RFC9457Reporter.MAX_BODY_BYTES];
+ Arrays.fill(padding, 'a');
+ // valid problem details if read completely, malformed once truncated at the cap
+ String oversized = "{\"title\":\"" + new String(padding) + "\"}";
+ TestReporter reporter = new TestReporter(true);
+ IOException exception = assertThrows(
+ IOException.class,
+ () -> reporter.generateException(oversized, RFC9457ReporterTest::throwBaseException));
+ assertFalse(exception instanceof HttpRFC9457Exception);
+ assertEquals("base:404", exception.getMessage());
+ }
+
+ @Test
+ void emptyBodyThrowsExceptionWithEmptyPayload() {
+ TestReporter reporter = new TestReporter(true);
+ HttpRFC9457Exception exception = assertThrows(
+ HttpRFC9457Exception.class,
+ () -> reporter.generateException("", RFC9457ReporterTest::throwBaseException));
+ assertSame(RFC9457Payload.INSTANCE, exception.getPayload());
+ }
+
+ @Test
+ void nonProblemDetailsResponseDoesNotReadBody() {
+ TestReporter reporter = new TestReporter(false);
+ IOException exception = assertThrows(
+ IOException.class,
+ () -> reporter.generateException("{\"title\":\"ignored\"}", RFC9457ReporterTest::throwBaseException));
+ assertFalse(exception instanceof HttpRFC9457Exception);
+ assertEquals("base:404", exception.getMessage());
+ assertFalse(reporter.bodyRead);
+ }
}
diff --git a/maven-resolver-transport-apache/src/main/java/org/eclipse/aether/transport/apache/ApacheRFC9457Reporter.java b/maven-resolver-transport-apache/src/main/java/org/eclipse/aether/transport/apache/ApacheRFC9457Reporter.java
index 41f43701b..6f5453319 100644
--- a/maven-resolver-transport-apache/src/main/java/org/eclipse/aether/transport/apache/ApacheRFC9457Reporter.java
+++ b/maven-resolver-transport-apache/src/main/java/org/eclipse/aether/transport/apache/ApacheRFC9457Reporter.java
@@ -19,14 +19,14 @@
package org.eclipse.aether.transport.apache;
import java.io.IOException;
-import java.nio.charset.StandardCharsets;
+import java.io.InputStream;
import org.apache.http.Header;
+import org.apache.http.HttpEntity;
import org.apache.http.HttpHeaders;
import org.apache.http.HttpRequest;
import org.apache.http.client.HttpResponseException;
import org.apache.http.client.methods.CloseableHttpResponse;
-import org.apache.http.util.EntityUtils;
import org.eclipse.aether.spi.connector.transport.http.HttpConstants;
import org.eclipse.aether.spi.connector.transport.http.RFC9457.RFC9457Reporter;
@@ -67,9 +67,16 @@ public class ApacheRFC9457Reporter extends RFC9457Reporter<CloseableHttpResponse
@Override
protected String getBody(final CloseableHttpResponse response) throws IOException {
- if (response.getEntity() == null) {
+ HttpEntity entity = response.getEntity();
+ if (entity == null) {
return "";
}
- return EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
+ InputStream is = entity.getContent();
+ if (is == null) {
+ return "";
+ }
+ try (InputStream stream = is) {
+ return readBody(stream);
+ }
}
}
diff --git a/maven-resolver-transport-apache/src/main/java/org/eclipse/aether/transport/apache/ApacheTransporter.java b/maven-resolver-transport-apache/src/main/java/org/eclipse/aether/transport/apache/ApacheTransporter.java
index 34765c82e..0c36edf3c 100644
--- a/maven-resolver-transport-apache/src/main/java/org/eclipse/aether/transport/apache/ApacheTransporter.java
+++ b/maven-resolver-transport-apache/src/main/java/org/eclipse/aether/transport/apache/ApacheTransporter.java
@@ -679,7 +679,9 @@ final class ApacheTransporter extends AbstractTransporter implements HttpTranspo
if (lastModifiedHeader != null) {
Date lastModified = DateUtils.parseDate(lastModifiedHeader.getValue());
if (lastModified != null) {
- pathProcessor.setLastModified(task.getDataPath(), lastModified.getTime());
+ pathProcessor.setLastModified(
+ task.getDataPath(),
+ HttpTransporterUtils.clampRemoteLastModified(lastModified.getTime()));
}
}
}
diff --git a/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/src/main/java/org/eclipse/aether/transport/jdk/JdkRFC9457Reporter.java b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/src/main/java/org/eclipse/aether/transport/jdk/JdkRFC9457Reporter.java
index a2733ce6a..6340e57e4 100644
--- a/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/src/main/java/org/eclipse/aether/transport/jdk/JdkRFC9457Reporter.java
+++ b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/src/main/java/org/eclipse/aether/transport/jdk/JdkRFC9457Reporter.java
@@ -23,7 +23,6 @@ import java.io.InputStream;
import java.net.http.HttpRequest;
import java.net.http.HttpRequest.Builder;
import java.net.http.HttpResponse;
-import java.nio.charset.StandardCharsets;
import java.util.Optional;
import org.eclipse.aether.spi.connector.transport.http.HttpConstants;
@@ -64,7 +63,7 @@ public class JdkRFC9457Reporter
@Override
protected String getBody(final HttpResponse<InputStream> response) throws IOException {
try (InputStream is = response.body()) {
- return new String(is.readAllBytes(), StandardCharsets.UTF_8);
+ return readBody(is);
}
}
}
diff --git a/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporter.java b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporter.java
index 2ce6137b8..b0851117e 100644
--- a/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporter.java
+++ b/maven-resolver-transport-jdk-parent/maven-resolver-transport-jdk11/src/main/java/org/eclipse/aether/transport/jdk/JdkTransporter.java
@@ -377,9 +377,10 @@ final class JdkTransporter extends AbstractTransporter implements HttpTransporte
try {
pathProcessor.setLastModified(
task.getDataPath(),
- ZonedDateTime.parse(lastModifiedHeader, RFC7231)
- .toInstant()
- .toEpochMilli());
+ HttpTransporterUtils.clampRemoteLastModified(
+ ZonedDateTime.parse(lastModifiedHeader, RFC7231)
+ .toInstant()
+ .toEpochMilli()));
} catch (DateTimeParseException e) {
// fall through
}
diff --git a/maven-resolver-transport-jetty/src/main/java/org/eclipse/aether/transport/jetty/JettyRFC9457Reporter.java b/maven-resolver-transport-jetty/src/main/java/org/eclipse/aether/transport/jetty/JettyRFC9457Reporter.java
index 08d5c7852..127d46c83 100644
--- a/maven-resolver-transport-jetty/src/main/java/org/eclipse/aether/transport/jetty/JettyRFC9457Reporter.java
+++ b/maven-resolver-transport-jetty/src/main/java/org/eclipse/aether/transport/jetty/JettyRFC9457Reporter.java
@@ -20,7 +20,6 @@ package org.eclipse.aether.transport.jetty;
import java.io.IOException;
import java.io.InputStream;
-import java.nio.charset.StandardCharsets;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@@ -77,7 +76,7 @@ public class JettyRFC9457Reporter
@Override
protected String getBody(final InputStreamResponseListener listener) throws IOException {
try (InputStream is = listener.getInputStream()) {
- return new String(is.readAllBytes(), StandardCharsets.UTF_8);
+ return readBody(is);
}
}
}
diff --git a/maven-resolver-transport-jetty/src/main/java/org/eclipse/aether/transport/jetty/JettyTransporter.java b/maven-resolver-transport-jetty/src/main/java/org/eclipse/aether/transport/jetty/JettyTransporter.java
index f2be78483..e7db9cf25 100644
--- a/maven-resolver-transport-jetty/src/main/java/org/eclipse/aether/transport/jetty/JettyTransporter.java
+++ b/maven-resolver-transport-jetty/src/main/java/org/eclipse/aether/transport/jetty/JettyTransporter.java
@@ -312,7 +312,8 @@ final class JettyTransporter extends AbstractTransporter implements HttpTranspor
long lastModified =
response.getHeaders().getDateField(LAST_MODIFIED); // note: Wagon also does first not last
if (lastModified != -1) {
- pathProcessor.setLastModified(task.getDataPath(), lastModified);
+ pathProcessor.setLastModified(
+ task.getDataPath(), HttpTransporterUtils.clampRemoteLastModified(lastModified));
}
}
Map<String, String> checksums = checksumExtractor.extractChecksums(headerGetter(response));
diff --git a/maven-resolver-transport-url/src/main/java/org/eclipse/aether/transport/url/UrlTransporter.java b/maven-resolver-transport-url/src/main/java/org/eclipse/aether/transport/url/UrlTransporter.java
index 9f651c690..4273e5a77 100644
--- a/maven-resolver-transport-url/src/main/java/org/eclipse/aether/transport/url/UrlTransporter.java
+++ b/maven-resolver-transport-url/src/main/java/org/eclipse/aether/transport/url/UrlTransporter.java
@@ -250,7 +250,8 @@ public class UrlTransporter extends AbstractTransporter implements HttpTransport
if (task.getDataPath() != null) {
long lastModified = con.getLastModified();
if (lastModified != 0) {
- pathProcessor.setLastModified(task.getDataPath(), lastModified);
+ pathProcessor.setLastModified(
+ task.getDataPath(), HttpTransporterUtils.clampRemoteLastModified(lastModified));
}
}
} finally {
diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/ChecksumUtils.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/ChecksumUtils.java
index f8759f3ad..1eba761aa 100644
--- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/ChecksumUtils.java
+++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/ChecksumUtils.java
@@ -39,6 +39,12 @@ import java.util.Map;
*/
@Deprecated
public final class ChecksumUtils {
+ /**
+ * Upper bound (in characters) for data read from a checksum file, see {@link #read(File)}. Every sane
+ * checksum file format fits well within this limit; longer input is rejected as malformed instead of being
+ * buffered into memory.
+ */
+ private static final int MAX_CHECKSUM_FILE_CHARS = 8192;
private ChecksumUtils() {
// hide constructor
@@ -54,23 +60,13 @@ public final class ChecksumUtils {
*/
@Deprecated
public static String read(File checksumFile) throws IOException {
- String checksum = "";
+ String checksum;
try (BufferedReader br = new BufferedReader(
new InputStreamReader(new FileInputStream(checksumFile), StandardCharsets.UTF_8), 512)) {
- while (true) {
- String line = br.readLine();
- if (line == null) {
- break;
- }
- line = line.trim();
- if (!line.isEmpty()) {
- checksum = line;
- break;
- }
- }
+ checksum = readFirstNonEmptyLine(br, checksumFile.toString());
}
- if (checksum.matches(".+= [0-9A-Fa-f]+")) {
+ if (isAlgorithmHeaderFormat(checksum)) {
int lastSpacePos = checksum.lastIndexOf(' ');
checksum = checksum.substring(lastSpacePos + 1);
} else {
@@ -84,6 +80,52 @@ public final class ChecksumUtils {
return checksum;
}
+ /**
+ * Reads the first non-empty line, enforcing {@link #MAX_CHECKSUM_FILE_CHARS} on the total amount of data
+ * consumed. Returns the trimmed line, or an empty string if the stream holds no non-empty line.
+ */
+ private static String readFirstNonEmptyLine(BufferedReader reader, String source) throws IOException {
+ StringBuilder buffer = new StringBuilder(64);
+ int read = 0;
+ int c;
+ while ((c = reader.read()) != -1) {
+ if (++read > MAX_CHECKSUM_FILE_CHARS) {
+ throw new IOException("Checksum file " + source + " is malformed: longer than "
+ + MAX_CHECKSUM_FILE_CHARS + " characters");
+ }
+ if (c == '\n' || c == '\r') {
+ String line = buffer.toString().trim();
+ if (!line.isEmpty()) {
+ return line;
+ }
+ buffer.setLength(0);
+ } else {
+ buffer.append((char) c);
+ }
+ }
+ return buffer.toString().trim();
+ }
+
+ /**
+ * Non-backtracking equivalent of {@code line.matches(".+= [0-9A-Fa-f]+")}: at least one character, followed
+ * by "= ", followed by one or more hex digits reaching the end of the line ("<algorithm> (<file>)
+ * = <hex>" style checksum lines).
+ */
+ private static boolean isAlgorithmHeaderFormat(String line) {
+ int lastSpacePos = line.lastIndexOf(' ');
+ if (lastSpacePos < 2 || lastSpacePos == line.length() - 1 || line.charAt(lastSpacePos - 1) != '=') {
+ return false;
+ }
+ for (int i = lastSpacePos + 1; i < line.length(); i++) {
+ char ch = line.charAt(i);
+ boolean hex = (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F');
+ if (!hex) {
+ return false;
+ }
+ }
+ return true;
+ }
+
/**
* Calculates checksums for the specified file.
*
diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/connector/transport/http/HttpTransporterUtils.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/connector/transport/http/HttpTransporterUtils.java
index b9251e19d..4b958288a 100644
--- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/connector/transport/http/HttpTransporterUtils.java
+++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/connector/transport/http/HttpTransporterUtils.java
@@ -328,6 +328,25 @@ public final class HttpTransporterUtils {
ConfigurationProperties.HTTP_VERSION);
}
+ /**
+ * Clamps a remote-supplied {@code Last-Modified} timestamp to the local clock: returns the given value as-is,
+ * or the current time when the value lies in the future.
+ * <p>
+ * HTTP transports stamp the downloaded file's modification time from the server-provided {@code Last-Modified}
+ * header, and the update check manager subsequently uses that file modification time as the {@code lastUpdated}
+ * instant for update-policy cutoffs. A repository serving a far-future {@code Last-Modified} date would otherwise
+ * permanently suppress interval-based update checks for that path. Update-interval decisions must rest on
+ * locally-observed time, so future-dated values are clamped to {@code System.currentTimeMillis()}.
+ *
+ * @param lastModifiedMillis the last modified timestamp in milliseconds since the epoch, as supplied by the
+ * remote server
+ * @return the supplied value, clamped to the local wall clock
+ * @since 2.0.23
+ */
+ public static long clampRemoteLastModified(long lastModifiedMillis) {
+ return Math.min(lastModifiedMillis, System.currentTimeMillis());
+ }
+
/**
* Shared code to create "base {@link URI}" for most common HTTP remote repositories and all HTTP transports.
* Note: this method just applies common validation and adjustments to URI, but it does not enforce protocol
diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/connector/transport/http/HttpTransporterUtilsTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/connector/transport/http/HttpTransporterUtilsTest.java
index 4d3f8455e..93a1c917c 100644
--- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/connector/transport/http/HttpTransporterUtilsTest.java
+++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/connector/transport/http/HttpTransporterUtilsTest.java
@@ -27,8 +27,28 @@ import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
public class HttpTransporterUtilsTest {
+ @Test
+ void clampRemoteLastModifiedPassesPastAndPresentValues() {
+ long testStart = System.currentTimeMillis();
+ long past = testStart - 10_000L;
+ assertEquals(past, HttpTransporterUtils.clampRemoteLastModified(past));
+ assertEquals(0L, HttpTransporterUtils.clampRemoteLastModified(0L));
+ assertEquals(-1L, HttpTransporterUtils.clampRemoteLastModified(-1L));
+ }
+
+ @Test
+ void clampRemoteLastModifiedClampsFutureValuesToLocalClock() {
+ long testStart = System.currentTimeMillis();
+ long future = testStart + 365L * 24L * 60L * 60L * 1000L; // one year ahead
+ long clamped = HttpTransporterUtils.clampRemoteLastModified(future);
+ assertTrue(clamped >= testStart, "clamped value must not predate the call");
+ assertTrue(clamped <= System.currentTimeMillis(), "clamped value must not exceed the local clock");
+ assertTrue(clamped < future, "future-dated value must be clamped");
+ }
+
@Test
void goodUris() throws URISyntaxException {
URI uri;