This is an automated email from the ASF dual-hosted git repository.
yuqi1129 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new 409f83447f [#11784] improvement(common): harden remote file fetch
against SSRF DNS-rebinding (#11785)
409f83447f is described below
commit 409f83447fde452eef58db4a86405826d71651ad
Author: YangJie <[email protected]>
AuthorDate: Tue Jul 7 17:24:30 2026 +0800
[#11784] improvement(common): harden remote file fetch against SSRF
DNS-rebinding (#11785)
### What changes were proposed in this pull request?
`FileFetcher` already checked a remote URI's host against an SSRF
denylist, but the download step then let the JDK re-resolve the hostname
when it connected. That leaves a DNS-rebinding window: the host can look
safe during validation and resolve to an internal address when the
download actually connects.
The fix resolves the host once in `RemoteUriValidator`, validates every
address it resolves to, and returns one for the caller to pin.
`RemoteFileDownloader` is a small HTTP/1.0 client that connects straight
to that pinned `InetAddress` and never looks the hostname up again,
while still using the original hostname for TLS/SNI verification. It
follows no redirects and accepts only Content-Length or chunked bodies,
so a truncated download can't be mistaken for a complete one.
Along the way it also:
- widens the denylist to 0.0.0.0/8, CGNAT 100.64/10 (covers the Alibaba
metadata IP), the Oracle metadata IP, broadcast, IPv6 unique-local, and
the IPv4-compatible/mapped/NAT64/6to4/ISATAP forms that hide an IPv4 in
an IPv6 literal;
- rejects `ftp://` when blocking is on, because its PASV data channel
connects to an address the server picks and can't be pinned;
- adds `SafeUri.redact` to strip userinfo and query strings from URIs
before they reach logs or error messages.
### Why are the changes needed?
The server downloads keytabs and jars from operator-supplied URIs.
Without pinning, someone who controls DNS for one of those hosts can
pass validation and still steer the connection to a cloud metadata
endpoint or an internal service. The extra denylist entries and the FTP
restriction remove the other ways to reach those addresses.
Fix: #11784
### Does this PR introduce _any_ user-facing change?
No new config or API. One behavior change: with
`gravitino.fetchFile.blockUnsafeRemoteUri` enabled (the default),
`ftp://` URIs are now rejected. Set it to false to allow them from a
trusted source.
### How was this patch tested?
Added 48 unit tests across four classes covering address classification
(including the IPv6-embedded forms), address pinning,
redirect/unframed/oversized/truncated responses, chunked decoding, FTP
rejection, and URI redaction. `./gradlew :common:test` and
`:common:spotlessCheck` pass.
---
.../org/apache/gravitino/utils/FileFetcher.java | 60 +-
.../gravitino/utils/RemoteFileDownloader.java | 396 ++++++++++++
.../apache/gravitino/utils/RemoteUriValidator.java | 140 ++++-
.../java/org/apache/gravitino/utils/SafeUri.java | 55 ++
.../apache/gravitino/utils/TestFileFetcher.java | 89 ++-
.../gravitino/utils/TestRemoteFileDownloader.java | 686 +++++++++++++++++++++
.../gravitino/utils/TestRemoteUriValidator.java | 118 ++--
.../org/apache/gravitino/utils/TestSafeUri.java | 51 ++
8 files changed, 1514 insertions(+), 81 deletions(-)
diff --git a/common/src/main/java/org/apache/gravitino/utils/FileFetcher.java
b/common/src/main/java/org/apache/gravitino/utils/FileFetcher.java
index 7291a9232a..dacd0cdcb8 100644
--- a/common/src/main/java/org/apache/gravitino/utils/FileFetcher.java
+++ b/common/src/main/java/org/apache/gravitino/utils/FileFetcher.java
@@ -20,11 +20,13 @@ package org.apache.gravitino.utils;
import java.io.File;
import java.io.IOException;
+import java.net.InetAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
+import java.util.Locale;
import java.util.Optional;
import javax.annotation.Nullable;
import org.apache.commons.io.FileUtils;
@@ -90,17 +92,33 @@ public final class FileFetcher {
throws IOException {
try {
URI uri = new URI(fileUri);
- String scheme = Optional.ofNullable(uri.getScheme()).orElse("file");
+ // URI schemes are case-insensitive (RFC 3986); normalize so e.g. "HTTP"
routes like "http".
+ String scheme =
Optional.ofNullable(uri.getScheme()).orElse("file").toLowerCase(Locale.ROOT);
switch (scheme) {
case "http":
case "https":
case "ftp":
- RemoteUriValidator.validate(
- uri,
- blockUnsafeRemoteUri,
- String.format("'%s' to false", BLOCK_UNSAFE_REMOTE_URI_CONFIG));
- FileUtils.copyURLToFile(uri.toURL(), destFile, timeoutMs, timeoutMs);
+ if (!blockUnsafeRemoteUri) {
+ FileUtils.copyURLToFile(uri.toURL(), destFile, timeoutMs,
timeoutMs);
+ } else if (scheme.equals("ftp")) {
+ // FTP opens its data channel to an address the server chooses in
its PASV/EPSV reply,
+ // which cannot be pinned to the validated host and is therefore
vulnerable to SSRF. The
+ // operator must opt out of blocking to use FTP from a trusted
source.
+ throw new IllegalArgumentException(
+ String.format(
+ "Refusing to fetch ftp uri '%s' from the Gravitino server
side: FTP's data "
+ + "channel cannot be restricted to the validated
address. Set %s to false "
+ + "to allow it if the source is trusted.",
+ SafeUri.redact(uri), BLOCK_UNSAFE_REMOTE_URI_CONFIG));
+ } else {
+ // Resolve and validate the host exactly once, then pin the
download to the validated
+ // address so the hostname cannot be re-resolved to an unsafe
address (DNS rebinding).
+ InetAddress pinnedAddress =
+ RemoteUriValidator.resolveAndValidate(
+ uri, String.format("'%s' to false",
BLOCK_UNSAFE_REMOTE_URI_CONFIG));
+ RemoteFileDownloader.download(uri, pinnedAddress, destFile,
timeoutMs);
+ }
break;
case "file":
@@ -123,7 +141,15 @@ public final class FileFetcher {
}
private synchronized void linkLocalFile(URI uri, File destFile) throws
IOException {
- Path srcPath = new File(uri.getPath()).toPath().normalize();
+ String sourcePath = uri.getPath();
+ if (sourcePath == null) {
+ // Opaque file URIs (e.g. "file:relative", no authority) have a null
path; fail with a clear
+ // message rather than a context-free NullPointerException.
+ throw new IOException("file uri has no path: " + SafeUri.redact(uri));
+ }
+ // Resolve to an absolute path: a relative source would otherwise be
stored as the symlink
+ // target and resolved against the link's own directory, producing a
dangling symlink.
+ Path srcPath = new File(sourcePath).toPath().toAbsolutePath().normalize();
if (!Files.exists(srcPath)) {
throw new IOException(
String.format("Source file does not exist: %s",
srcPath.toAbsolutePath()));
@@ -142,7 +168,19 @@ public final class FileFetcher {
Path tmpPath = destPath.resolveSibling(destPath.getFileName() +
".symlink.tmp");
Files.deleteIfExists(tmpPath);
Files.createSymbolicLink(tmpPath, srcPath);
- Files.move(tmpPath, destPath, StandardCopyOption.REPLACE_EXISTING);
+ try {
+ Files.move(tmpPath, destPath, StandardCopyOption.REPLACE_EXISTING);
+ } catch (Throwable e) {
+ // Do not orphan the temporary symlink if the rename fails (e.g.
read-only or cross-device
+ // destination). This method is synchronized on the singleton, so the
fixed temp name cannot
+ // race with a concurrent local fetch.
+ try {
+ Files.deleteIfExists(tmpPath);
+ } catch (IOException suppressed) {
+ e.addSuppressed(suppressed);
+ }
+ throw e;
+ }
}
/**
@@ -156,7 +194,8 @@ public final class FileFetcher {
() ->
new IllegalArgumentException(
String.format(
- "A Hadoop configuration is required to fetch an 'hdfs'
uri: %s", uri)));
+ "A Hadoop configuration is required to fetch an 'hdfs'
uri: %s",
+ SafeUri.redact(uri))));
try {
Class<?> configurationClass =
Class.forName("org.apache.hadoop.conf.Configuration");
Class<?> fileSystemClass =
Class.forName("org.apache.hadoop.fs.FileSystem");
@@ -170,7 +209,8 @@ public final class FileFetcher {
.getMethod("copyToLocalFile", pathClass, pathClass)
.invoke(fileSystem, srcPath, destPath);
} catch (ReflectiveOperationException e) {
- throw new IOException(String.format("Failed to fetch file from hdfs uri:
%s", uri), e);
+ throw new IOException(
+ String.format("Failed to fetch file from hdfs uri: %s",
SafeUri.redact(uri)), e);
}
}
}
diff --git
a/common/src/main/java/org/apache/gravitino/utils/RemoteFileDownloader.java
b/common/src/main/java/org/apache/gravitino/utils/RemoteFileDownloader.java
new file mode 100644
index 0000000000..8f1fa8c1e4
--- /dev/null
+++ b/common/src/main/java/org/apache/gravitino/utils/RemoteFileDownloader.java
@@ -0,0 +1,396 @@
+/*
+ * 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.apache.gravitino.utils;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import javax.net.ssl.SSLParameters;
+import javax.net.ssl.SSLSocket;
+import javax.net.ssl.SSLSocketFactory;
+
+/**
+ * Downloads an {@code http} or {@code https} file while pinning the TCP
connection to a
+ * pre-validated address.
+ *
+ * <p>This closes the DNS-rebinding TOCTOU window between SSRF validation and
the actual fetch: the
+ * host is resolved and validated once by {@link
RemoteUriValidator#resolveAndValidate}, and the
+ * resulting {@link InetAddress} is connected to directly here, so the
hostname is never
+ * re-resolved. A minimal HTTP/1.1 client is used so the connection can be
pinned at the socket
+ * level (the JDK exposes no per-connection address override for plain {@code
HttpURLConnection},
+ * and silently drops a {@code Host} header set via {@code
setRequestProperty}). The original
+ * hostname is still used for the HTTP {@code Host} header and, for {@code
https}, for TLS SNI and
+ * certificate-hostname verification, so virtual hosting and certificate
validation keep working.
+ * Redirects are not followed, because a redirect target would re-resolve to
an unvalidated address.
+ *
+ * <p>{@code ftp} is intentionally not handled here: the FTP data channel is
opened to an address
+ * the server chooses in its {@code PASV}/{@code EPSV} reply, which cannot be
pinned, so {@link
+ * FileFetcher} rejects {@code ftp} on the SSRF-blocking path instead.
+ *
+ * <p>Only responses framed by {@code Content-Length} or chunked
transfer-encoding are accepted; a
+ * connection-close-delimited body is rejected because a premature close
cannot be told apart from a
+ * complete one. Combined with streaming to a sibling temporary file that is
atomically moved into
+ * place only on success, this means a connection that drops mid-transfer
never leaves a truncated
+ * destination behind. Response and header sizes are bounded to prevent a
malicious pinned host from
+ * exhausting disk or memory.
+ */
+final class RemoteFileDownloader {
+
+ private static final int DEFAULT_HTTP_PORT = 80;
+ private static final int DEFAULT_HTTPS_PORT = 443;
+ private static final int MAX_HEADER_BYTES = 64 * 1024;
+ private static final long MAX_BODY_BYTES = 2L * 1024 * 1024 * 1024;
+ private static final int COPY_BUFFER_BYTES = 8192;
+
+ private RemoteFileDownloader() {}
+
+ /**
+ * Downloads {@code uri} into {@code destFile}, connecting only to {@code
pinnedAddress}.
+ *
+ * @param uri the source URI; must use the {@code http} or {@code https}
scheme
+ * @param pinnedAddress the validated address to connect to
+ * @param destFile the local destination file
+ * @param timeoutMs the connect/read timeout in milliseconds
+ * @throws IOException if the file cannot be fetched or the response is not
successful
+ */
+ static void download(URI uri, InetAddress pinnedAddress, File destFile, int
timeoutMs)
+ throws IOException {
+ String scheme =
Optional.ofNullable(uri.getScheme()).orElse("").toLowerCase(Locale.ROOT);
+ if (!scheme.equals("http") && !scheme.equals("https")) {
+ throw new IllegalArgumentException(
+ String.format("Pinned download does not support scheme '%s'",
scheme));
+ }
+
+ Path destPath = destFile.toPath().toAbsolutePath();
+ Path tempPath =
+ Files.createTempFile(destPath.getParent(), destPath.getFileName() +
".", ".tmp");
+ try {
+ httpDownload(uri, pinnedAddress, tempPath, timeoutMs,
scheme.equals("https"));
+ Files.move(tempPath, destPath, StandardCopyOption.REPLACE_EXISTING);
+ } catch (Throwable e) {
+ // Catch every throwable (incl. Error/OutOfMemoryError) so the temp file
is never orphaned in
+ // the staging directory. Precise rethrow keeps the declared `throws
IOException`.
+ try {
+ Files.deleteIfExists(tempPath);
+ } catch (IOException suppressed) {
+ e.addSuppressed(suppressed);
+ }
+ throw e;
+ }
+ }
+
+ private static void httpDownload(
+ URI uri, InetAddress pinnedAddress, Path destPath, int timeoutMs,
boolean tls)
+ throws IOException {
+ String host = uri.getHost();
+ int port = uri.getPort() != -1 ? uri.getPort() : (tls ? DEFAULT_HTTPS_PORT
: DEFAULT_HTTP_PORT);
+
+ Socket socket = new Socket();
+ try {
+ socket.connect(new InetSocketAddress(pinnedAddress, port), timeoutMs);
+ socket.setSoTimeout(timeoutMs);
+ if (tls) {
+ socket = startTls(socket, host, port);
+ }
+
+ sendGetRequest(socket.getOutputStream(), requestTarget(uri),
hostHeader(host, uri.getPort()));
+
+ InputStream in = new BufferedInputStream(socket.getInputStream());
+ int status = readStatusCode(in);
+ if (status < 200 || status >= 300) {
+ throw new IOException(
+ String.format(
+ "Unexpected HTTP status %d fetching pinned URI '%s'", status,
SafeUri.redact(uri)));
+ }
+ Map<String, String> headers = readHeaders(in);
+ try (OutputStream out = Files.newOutputStream(destPath)) {
+ writeBody(in, headers, out);
+ }
+ } finally {
+ socket.close();
+ }
+ }
+
+ /**
+ * Layers TLS over an already-connected plain socket. The handshake uses
{@code host} (the
+ * original hostname) for SNI and certificate verification ({@code HTTPS}
endpoint
+ * identification), while the TCP connection stays pinned to the address the
plain socket was
+ * connected to.
+ */
+ private static Socket startTls(Socket plainSocket, String host, int port)
throws IOException {
+ SSLSocketFactory factory = (SSLSocketFactory)
SSLSocketFactory.getDefault();
+ SSLSocket sslSocket = (SSLSocket) factory.createSocket(plainSocket, host,
port, true);
+ SSLParameters parameters = sslSocket.getSSLParameters();
+ parameters.setEndpointIdentificationAlgorithm("HTTPS");
+ sslSocket.setSSLParameters(parameters);
+ sslSocket.startHandshake();
+ return sslSocket;
+ }
+
+ private static void sendGetRequest(OutputStream out, String target, String
hostHeader)
+ throws IOException {
+ // HTTP/1.1 with "Connection: close" requests a single non-persistent
exchange while biasing the
+ // origin toward framing the body with Content-Length or chunked encoding.
An unframed
+ // (close-delimited) body is still rejected by writeBody because its
completeness cannot be
+ // verified.
+ String request =
+ "GET "
+ + target
+ + " HTTP/1.1\r\n"
+ + "Host: "
+ + hostHeader
+ + "\r\n"
+ + "Connection: close\r\n"
+ + "\r\n";
+ out.write(request.getBytes(StandardCharsets.US_ASCII));
+ out.flush();
+ }
+
+ private static int readStatusCode(InputStream in) throws IOException {
+ String statusLine = readLine(in);
+ if (statusLine == null) {
+ throw new IOException("Empty HTTP response from pinned host");
+ }
+ // Format: "HTTP/1.x <code> <reason>"; tolerate extra whitespace between
tokens.
+ String[] parts = statusLine.trim().split("\\s+", 3);
+ if (parts.length < 2) {
+ throw new IOException("Malformed HTTP status line: " + statusLine);
+ }
+ try {
+ return Integer.parseInt(parts[1]);
+ } catch (NumberFormatException e) {
+ throw new IOException("Malformed HTTP status line: " + statusLine, e);
+ }
+ }
+
+ private static Map<String, String> readHeaders(InputStream in) throws
IOException {
+ Map<String, String> headers = new HashMap<>();
+ int total = 0;
+ String line;
+ while ((line = readLine(in)) != null) {
+ if (line.isEmpty()) {
+ return headers;
+ }
+ // Reject obsolete line folding (RFC 7230 §3.2.4); a leading space/tab
continuation is treated
+ // differently by different parsers and is a smuggling vector.
+ if (line.charAt(0) == ' ' || line.charAt(0) == '\t') {
+ throw new IOException("Obsolete line folding in HTTP headers is
rejected from pinned host");
+ }
+ total += line.length();
+ if (total > MAX_HEADER_BYTES) {
+ throw new IOException("HTTP response headers exceed the allowed size
from pinned host");
+ }
+ int colon = line.indexOf(':');
+ if (colon <= 0) {
+ throw new IOException("Malformed HTTP header line from pinned host: "
+ line);
+ }
+ String name = line.substring(0, colon).trim().toLowerCase(Locale.ROOT);
+ String value = line.substring(colon + 1).trim();
+ String previous = headers.put(name, value);
+ // A duplicate Content-Length or Transfer-Encoding with a different
value (RFC 7230 §3.3.2 /
+ // §3.3.3) is a request-smuggling vector: parsers that disagree on which
copy wins frame the
+ // body differently. Reject the conflict rather than silently keeping
the last one.
+ if (previous != null
+ && !previous.equals(value)
+ && (name.equals("content-length") ||
name.equals("transfer-encoding"))) {
+ throw new IOException("Conflicting duplicate " + name + " headers from
pinned host");
+ }
+ }
+ throw new IOException("Unexpected end of stream while reading HTTP headers
from pinned host");
+ }
+
+ private static void writeBody(InputStream in, Map<String, String> headers,
OutputStream out)
+ throws IOException {
+ String transferEncoding = headers.get("transfer-encoding");
+ if (transferEncoding != null) {
+ // Accept only a sole "chunked" coding. A value such as "gzip, chunked"
is de-chunkable but
+ // still compressed, and we do not gunzip, so de-chunking alone would
write corrupt bytes; a
+ // substring like "xchunked" must likewise not be mistaken for chunked.
Reject anything other
+ // than an exact "chunked" rather than emit a mis-decoded body.
+ if (!transferEncoding.trim().equalsIgnoreCase("chunked")) {
+ throw new IOException(
+ "Unsupported Transfer-Encoding '"
+ + transferEncoding
+ + "' from pinned host; only 'chunked' is supported");
+ }
+ copyChunked(in, out);
+ return;
+ }
+ String contentLength = headers.get("content-length");
+ if (contentLength != null) {
+ copyExact(in, out, parseContentLength(contentLength));
+ return;
+ }
+ // Reject a connection-close-delimited body (no Content-Length, no chunked
framing): a premature
+ // close cannot be distinguished from a complete body, so the download's
completeness would be
+ // unverifiable and a truncated keytab/jar could be installed silently.
+ throw new IOException(
+ "Response from pinned host has neither Content-Length nor chunked
framing; "
+ + "its completeness cannot be verified");
+ }
+
+ private static long parseContentLength(String value) throws IOException {
+ String trimmed = value.trim();
+ // RFC 7230 §3.3.2 defines Content-Length as 1*DIGIT; reject anything else
(e.g. "+5", "0x10")
+ // that Long.parseLong would otherwise accept, to avoid framing
disagreement with upstreams.
+ if (trimmed.isEmpty() || !isAsciiDigits(trimmed)) {
+ throw new IOException("Malformed Content-Length header from pinned host:
" + value);
+ }
+ long length;
+ try {
+ length = Long.parseLong(trimmed);
+ } catch (NumberFormatException e) {
+ throw new IOException("Malformed Content-Length header from pinned host:
" + value, e);
+ }
+ if (length > MAX_BODY_BYTES) {
+ throw new IOException("Content-Length " + length + " exceeds the maximum
allowed size");
+ }
+ return length;
+ }
+
+ private static boolean isAsciiDigits(String value) {
+ for (int i = 0; i < value.length(); i++) {
+ char c = value.charAt(i);
+ if (c < '0' || c > '9') {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** Copies exactly {@code length} bytes, failing if the stream ends early. */
+ private static void copyExact(InputStream in, OutputStream out, long length)
throws IOException {
+ byte[] buffer = new byte[COPY_BUFFER_BYTES];
+ long remaining = length;
+ while (remaining > 0) {
+ int read = in.read(buffer, 0, (int) Math.min(buffer.length, remaining));
+ if (read == -1) {
+ throw new IOException(
+ String.format(
+ "Truncated response: %d of %d body bytes missing from pinned
host",
+ remaining, length));
+ }
+ out.write(buffer, 0, read);
+ remaining -= read;
+ }
+ }
+
+ private static void copyChunked(InputStream in, OutputStream out) throws
IOException {
+ long total = 0;
+ while (true) {
+ String sizeLine = readLine(in);
+ if (sizeLine == null) {
+ throw new IOException("Unexpected end of stream reading chunk size
from pinned host");
+ }
+ int extension = sizeLine.indexOf(';');
+ String hex = (extension >= 0 ? sizeLine.substring(0, extension) :
sizeLine).trim();
+ int chunkSize;
+ try {
+ chunkSize = Integer.parseInt(hex, 16);
+ } catch (NumberFormatException e) {
+ throw new IOException("Malformed chunk size from pinned host: " +
sizeLine, e);
+ }
+ if (chunkSize < 0) {
+ throw new IOException("Negative chunk size from pinned host: " +
sizeLine);
+ }
+ if (chunkSize == 0) {
+ break;
+ }
+ total += chunkSize;
+ if (total > MAX_BODY_BYTES) {
+ throw new IOException("Chunked response exceeds the maximum allowed
size from pinned host");
+ }
+ copyExact(in, out, chunkSize);
+ // Each chunk's data is followed by a CRLF, which readLine returns as an
empty line.
+ String terminator = readLine(in);
+ if (terminator == null || !terminator.isEmpty()) {
+ throw new IOException("Malformed chunk terminator from pinned host");
+ }
+ }
+ // Discard any trailer headers up to the final blank line, bounding their
total size so a server
+ // cannot flood unbounded trailers.
+ int trailerBytes = 0;
+ String line;
+ while ((line = readLine(in)) != null && !line.isEmpty()) {
+ trailerBytes += line.length();
+ if (trailerBytes > MAX_HEADER_BYTES) {
+ throw new IOException("Chunked trailer headers exceed the allowed size
from pinned host");
+ }
+ }
+ }
+
+ /**
+ * Reads a CRLF- or LF-terminated line, returning it without the line
terminator, or {@code null}
+ * at end of stream. The line is capped at {@link #MAX_HEADER_BYTES} to
bound memory.
+ */
+ private static String readLine(InputStream in) throws IOException {
+ ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+ boolean read = false;
+ int b;
+ while ((b = in.read()) != -1) {
+ read = true;
+ if (b == '\n') {
+ break;
+ }
+ buffer.write(b);
+ if (buffer.size() > MAX_HEADER_BYTES) {
+ throw new IOException("HTTP line exceeds the allowed size from pinned
host");
+ }
+ }
+ if (!read) {
+ return null;
+ }
+ // LF terminates the line; strip a single trailing CR (CRLF), but preserve
any other bytes so
+ // the parser does not silently mangle content that legitimately contains
a CR.
+ byte[] bytes = buffer.toByteArray();
+ int length = bytes.length;
+ if (length > 0 && bytes[length - 1] == '\r') {
+ length--;
+ }
+ return new String(bytes, 0, length, StandardCharsets.US_ASCII);
+ }
+
+ static String requestTarget(URI uri) {
+ String path = uri.getRawPath();
+ if (path == null || path.isEmpty()) {
+ path = "/";
+ }
+ String query = uri.getRawQuery();
+ return query == null ? path : path + "?" + query;
+ }
+
+ static String hostHeader(String host, int port) {
+ return port == -1 ? host : host + ":" + port;
+ }
+}
diff --git
a/common/src/main/java/org/apache/gravitino/utils/RemoteUriValidator.java
b/common/src/main/java/org/apache/gravitino/utils/RemoteUriValidator.java
index 41a60fa870..29f0a471b4 100644
--- a/common/src/main/java/org/apache/gravitino/utils/RemoteUriValidator.java
+++ b/common/src/main/java/org/apache/gravitino/utils/RemoteUriValidator.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.utils;
import java.io.IOException;
import java.net.InetAddress;
import java.net.URI;
+import java.net.UnknownHostException;
/** Validates remote URI hosts before server-side downloads. */
public final class RemoteUriValidator {
@@ -28,23 +29,24 @@ public final class RemoteUriValidator {
private RemoteUriValidator() {}
/**
- * Resolves the host in the given URI and rejects unsafe addresses when
blocking is enabled.
+ * Resolves the host in the given URI exactly once, rejects unsafe
addresses, and returns a
+ * validated address for the caller to pin the subsequent connection to.
+ *
+ * <p>Returning the resolved address closes the DNS-rebinding TOCTOU window:
a caller that
+ * connects to the returned {@link InetAddress} never re-resolves the
hostname, so an attacker
+ * cannot make validation see a safe address and the fetch see an unsafe one.
*
* @param uri The remote URI to validate.
- * @param blockUnsafeAddress Whether unsafe addresses should be blocked.
* @param blockUnsafeAddressHint The configuration hint that disables unsafe
address blocking.
+ * @return The first resolved address, guaranteed to be safe.
* @throws IOException If host resolution fails.
* @throws IllegalArgumentException If the URI has no host or resolves to an
unsafe address.
*/
- public static void validate(URI uri, boolean blockUnsafeAddress, String
blockUnsafeAddressHint)
+ public static InetAddress resolveAndValidate(URI uri, String
blockUnsafeAddressHint)
throws IOException {
String host = uri.getHost();
if (host == null) {
- throw new IllegalArgumentException("URI has no host: " + uri);
- }
-
- if (!blockUnsafeAddress) {
- return;
+ throw new IllegalArgumentException("URI has no host: " +
SafeUri.redact(uri));
}
InetAddress[] addresses = InetAddress.getAllByName(host);
@@ -56,9 +58,10 @@ public final class RemoteUriValidator {
+ "Access to local, private, link-local, multicast,
unspecified, and cloud "
+ "metadata addresses is disabled by default to prevent
SSRF. If this URI is "
+ "trusted and this access is required, set %s.",
- uri, address.getHostAddress(), blockUnsafeAddressHint));
+ SafeUri.redact(uri), address.getHostAddress(),
blockUnsafeAddressHint));
}
}
+ return addresses[0];
}
private static boolean isUnsafeAddress(InetAddress address) {
@@ -71,19 +74,122 @@ public final class RemoteUriValidator {
}
byte[] bytes = address.getAddress();
- if (isCloudMetadataAddress(bytes)) {
+ if (isUnsafeIpv4Address(bytes)) {
return true;
}
+ if (bytes.length == 16) {
+ if (isIpv6UniqueLocalAddress(bytes)) {
+ return true;
+ }
+ // Several IPv6 forms embed an IPv4 address the platform checks above
miss (IPv4-compatible,
+ // NAT64, 6to4, ISATAP; plus IPv4-mapped defensively). Re-classify the
embedded IPv4 so a
+ // blocked address such as cloud metadata cannot be reached via an IPv6
literal.
+ byte[] embeddedIpv4 = embeddedIpv4(bytes);
+ if (embeddedIpv4 != null) {
+ try {
+ return isUnsafeAddress(InetAddress.getByAddress(embeddedIpv4));
+ } catch (UnknownHostException e) {
+ // getByAddress only rejects a wrong-length array; a 4-byte array
never reaches here. Fail
+ // closed if it somehow does.
+ return true;
+ }
+ }
+ }
+ return false;
+ }
- return isIpv6UniqueLocalAddress(bytes);
+ /**
+ * Returns the IPv4 address embedded in a 16-byte IPv6 address whose
embedded IPv4 is the literal
+ * connection target, or {@code null} if there is none. Covers the
IPv4-compatible ({@code
+ * ::a.b.c.d}), NAT64 ({@code 64:ff9b::a.b.c.d}, RFC 6052), 6to4 ({@code
2002:a.b.c.d::}, RFC
+ * 3056) and ISATAP ({@code ::0:5efe:a.b.c.d}, RFC 5214) forms. The
IPv4-mapped ({@code
+ * ::ffff:a.b.c.d}) form is handled defensively: the JDK normally surfaces
it as a 4-byte {@link
+ * java.net.Inet4Address} validated by {@link #isUnsafeIpv4Address}, so this
branch is a fallback.
+ *
+ * <p>Teredo ({@code 2001:0::/32}) is intentionally excluded: its embedded
IPv4 is the
+ * client/relay identifier rather than the connection destination, and it
requires a non-default
+ * Teredo tunnel to route at all.
+ */
+ private static byte[] embeddedIpv4(byte[] bytes) {
+ boolean highTenZero = true;
+ for (int i = 0; i < 10; i++) {
+ if (bytes[i] != 0) {
+ highTenZero = false;
+ break;
+ }
+ }
+ if (highTenZero) {
+ boolean compatible = bytes[10] == 0 && bytes[11] == 0;
+ // Defensive: the JDK normally collapses IPv4-mapped addresses to a
4-byte Inet4Address.
+ boolean mapped = (bytes[10] & 0xFF) == 0xFF && (bytes[11] & 0xFF) ==
0xFF;
+ if (compatible || mapped) {
+ return lowestFourBytes(bytes);
+ }
+ }
+ // NAT64 well-known prefix 64:ff9b::/96 (RFC 6052): the IPv4 is the low 32
bits. Only the
+ // well-known prefix is matched; network-specific prefixes (RFC 6052 §2.2,
e.g. RFC 8215's
+ // 64:ff9b:1::/48) are site-defined and routable only where explicitly
deployed, so detecting
+ // them would require configuration this validator does not have.
+ if ((bytes[0] & 0xFF) == 0x00
+ && (bytes[1] & 0xFF) == 0x64
+ && (bytes[2] & 0xFF) == 0xFF
+ && (bytes[3] & 0xFF) == 0x9B
+ && isZero(bytes, 4, 12)) {
+ return lowestFourBytes(bytes);
+ }
+ // 6to4 (2002::/16, RFC 3056): the gateway IPv4 is the 32 bits after the
prefix. 2002::/16 is
+ // reserved exclusively for 6to4, so no legitimate non-6to4 host occupies
it.
+ if ((bytes[0] & 0xFF) == 0x20 && (bytes[1] & 0xFF) == 0x02) {
+ return new byte[] {bytes[2], bytes[3], bytes[4], bytes[5]};
+ }
+ // ISATAP interface identifier (RFC 5214): the low 64 bits are
00:00:5e:fe:a.b.c.d or
+ // 02:00:5e:fe:a.b.c.d, embedding the IPv4 in the low 32 bits regardless
of the /64 prefix.
+ if ((bytes[8] & 0xFD) == 0x00
+ && bytes[9] == 0x00
+ && (bytes[10] & 0xFF) == 0x5E
+ && (bytes[11] & 0xFF) == 0xFE) {
+ return lowestFourBytes(bytes);
+ }
+ return null;
+ }
+
+ private static byte[] lowestFourBytes(byte[] bytes) {
+ return new byte[] {bytes[12], bytes[13], bytes[14], bytes[15]};
+ }
+
+ private static boolean isZero(byte[] bytes, int fromInclusive, int
toExclusive) {
+ for (int i = fromInclusive; i < toExclusive; i++) {
+ if (bytes[i] != 0) {
+ return false;
+ }
+ }
+ return true;
}
- private static boolean isCloudMetadataAddress(byte[] bytes) {
- return bytes.length == 4
- && (bytes[0] & 0xFF) == 100
- && (bytes[1] & 0xFF) == 100
- && (bytes[2] & 0xFF) == 100
- && (bytes[3] & 0xFF) == 200;
+ private static boolean isUnsafeIpv4Address(byte[] bytes) {
+ if (bytes.length != 4) {
+ return false;
+ }
+ int b0 = bytes[0] & 0xFF;
+ int b1 = bytes[1] & 0xFF;
+ int b2 = bytes[2] & 0xFF;
+ int b3 = bytes[3] & 0xFF;
+
+ // 0.0.0.0/8 "this network" (RFC 1122). isAnyLocalAddress only covers the
single 0.0.0.0.
+ if (b0 == 0) {
+ return true;
+ }
+ // 100.64.0.0/10 carrier-grade NAT / shared address space (RFC 6598). This
range also covers
+ // the Alibaba Cloud metadata endpoint 100.100.100.200. Not caught by
isSiteLocalAddress.
+ if (b0 == 100 && (b1 & 0xC0) == 0x40) {
+ return true;
+ }
+ // 192.0.0.192 Oracle Cloud Infrastructure instance-metadata endpoint.
+ if (b0 == 192 && b1 == 0 && b2 == 0 && b3 == 192) {
+ return true;
+ }
+ // 255.255.255.255 limited broadcast (RFC 919).
+ return b0 == 255 && b1 == 255 && b2 == 255 && b3 == 255;
}
private static boolean isIpv6UniqueLocalAddress(byte[] bytes) {
diff --git a/common/src/main/java/org/apache/gravitino/utils/SafeUri.java
b/common/src/main/java/org/apache/gravitino/utils/SafeUri.java
new file mode 100644
index 0000000000..aa39772dce
--- /dev/null
+++ b/common/src/main/java/org/apache/gravitino/utils/SafeUri.java
@@ -0,0 +1,55 @@
+/*
+ * 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.apache.gravitino.utils;
+
+import java.net.URI;
+
+/** Renders URIs for logs and error messages without their credential-bearing
parts. */
+final class SafeUri {
+
+ private SafeUri() {}
+
+ /**
+ * Returns a log-safe rendering of {@code uri} with the userinfo (e.g.
{@code user:password@}) and
+ * query string (which may carry presigned-URL tokens) removed. The scheme,
host, port and path
+ * are kept so the message stays useful for diagnosis.
+ *
+ * @param uri the URI to redact; may be {@code null}
+ * @return a redacted string safe to log
+ */
+ static String redact(URI uri) {
+ if (uri == null) {
+ return "null";
+ }
+ StringBuilder builder = new StringBuilder();
+ if (uri.getScheme() != null) {
+ builder.append(uri.getScheme()).append("://");
+ }
+ if (uri.getHost() != null) {
+ builder.append(uri.getHost());
+ if (uri.getPort() != -1) {
+ builder.append(':').append(uri.getPort());
+ }
+ }
+ if (uri.getRawPath() != null) {
+ builder.append(uri.getRawPath());
+ }
+ return builder.length() == 0 ? "<redacted uri>" : builder.toString();
+ }
+}
diff --git
a/common/src/test/java/org/apache/gravitino/utils/TestFileFetcher.java
b/common/src/test/java/org/apache/gravitino/utils/TestFileFetcher.java
index 0397e044ee..e088e3a8bf 100644
--- a/common/src/test/java/org/apache/gravitino/utils/TestFileFetcher.java
+++ b/common/src/test/java/org/apache/gravitino/utils/TestFileFetcher.java
@@ -25,6 +25,8 @@ import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@@ -37,11 +39,15 @@ import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+/**
+ * Tests for {@link FileFetcher}.
+ *
+ * <p>The hdfs happy path is exercised reflectively against Hadoop and is
covered by the catalog
+ * Kerberos integration tests; the common module has no Hadoop on its test
classpath, so here we
+ * only assert that an hdfs uri without a Hadoop configuration is rejected.
+ */
public class TestFileFetcher {
- // The hdfs happy path is exercised reflectively against Hadoop and is
covered by the catalog
- // Kerberos integration tests; the common module has no Hadoop on its test
classpath, so here we
- // only assert that an hdfs uri without a Hadoop configuration is rejected.
@TempDir File tempDir;
@Test
@@ -67,6 +73,15 @@ public class TestFileFetcher {
srcFile.toPath().normalize(),
Files.readSymbolicLink(destFile.toPath()).normalize());
}
+ @Test
+ public void testOpaqueFileUriWithNullPathShouldFail() {
+ // An opaque file URI (no authority/path) must fail with a clear
IOException, not a raw NPE.
+ File destFile = new File(tempDir, "dest_opaque");
+ Assertions.assertThrows(
+ IOException.class,
+ () -> FileFetcher.get().fetchFileFromUri("file:relative", destFile,
10, null));
+ }
+
@Test
public void testMissingLocalFileShouldFail() {
File destFile = new File(tempDir, "dest_missing");
@@ -186,6 +201,74 @@ public class TestFileFetcher {
}
}
+ @Test
+ public void testRelativeLocalSourceProducesResolvableSymlink() throws
Exception {
+ File srcFile = new File(tempDir, "rel_src");
+ Assertions.assertTrue(srcFile.createNewFile());
+ File destFile = new File(tempDir, "rel_dest");
+ // A relative source path (as an operator might configure) must still
produce a symlink that
+ // resolves to the real file, not a dangling one.
+ Path cwd = Paths.get("").toAbsolutePath();
+ String relative =
cwd.relativize(srcFile.toPath().toAbsolutePath()).toString();
+
+ FileFetcher.get().fetchFileFromUri(relative, destFile, 10, null);
+
+ Assertions.assertTrue(Files.isSymbolicLink(destFile.toPath()));
+ Assertions.assertTrue(
+ Files.exists(destFile.toPath()), "symlink must resolve to an existing
file, not dangle");
+ }
+
+ @Test
+ public void testLocalFileMoveFailureCleansTempSymlink() throws Exception {
+ File srcFile = new File(tempDir, "src_movefail");
+ Assertions.assertTrue(srcFile.createNewFile());
+ // Make the destination a non-empty directory so the final atomic rename
fails.
+ File destDir = new File(tempDir, "dest_is_dir");
+ Assertions.assertTrue(destDir.mkdir());
+ Assertions.assertTrue(new File(destDir, "occupant").createNewFile());
+
+ Assertions.assertThrows(
+ IOException.class,
+ () -> FileFetcher.get().fetchFileFromUri(srcFile.toURI().toString(),
destDir, 10, null));
+
+ File tmpSymlink = new File(tempDir, "dest_is_dir.symlink.tmp");
+ Assertions.assertFalse(
+ tmpSymlink.exists(), "temp symlink must be cleaned up after a failed
move");
+ }
+
+ @Test
+ public void testUpperCaseSchemeIsCaseInsensitive() {
+ // RFC 3986 schemes are case-insensitive: "HTTP" must route like "http"
(i.e. through the SSRF
+ // validator), not fall through to the unsupported-scheme branch.
+ File destFile = new File(tempDir, "uppercase");
+ FileFetcher.get().initialize(true);
+
+ IllegalArgumentException exception =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ FileFetcher.get().fetchFileFromUri("HTTP://127.0.0.1/keytab",
destFile, 10, null));
+ Assertions.assertTrue(exception.getMessage().contains("Gravitino server
side"));
+ }
+
+ @Test
+ public void testFtpSchemeRejectedWhenBlockingEnabled() {
+ // FTP's data channel cannot be pinned to the validated address (PASV/EPSV
SSRF), so it must be
+ // rejected on the blocking path rather than handed to the JDK FTP client.
+ File destFile = new File(tempDir, "ftp");
+ FileFetcher.get().initialize(true);
+
+ IllegalArgumentException exception =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ FileFetcher.get()
+ .fetchFileFromUri("ftp://files.example.com/keytab",
destFile, 10, null));
+ Assertions.assertTrue(exception.getMessage().contains("FTP"));
+ Assertions.assertTrue(
+
exception.getMessage().contains(FileFetcher.BLOCK_UNSAFE_REMOTE_URI_CONFIG));
+ }
+
@Test
public void testHdfsSchemeWithoutConfShouldFail() {
File destFile = new File(tempDir, "dest_hdfs");
diff --git
a/common/src/test/java/org/apache/gravitino/utils/TestRemoteFileDownloader.java
b/common/src/test/java/org/apache/gravitino/utils/TestRemoteFileDownloader.java
new file mode 100644
index 0000000000..5ce07e6840
--- /dev/null
+++
b/common/src/test/java/org/apache/gravitino/utils/TestRemoteFileDownloader.java
@@ -0,0 +1,686 @@
+/*
+ * 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.apache.gravitino.utils;
+
+import com.sun.net.httpserver.HttpServer;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Tests for {@link RemoteFileDownloader}.
+ *
+ * <p>The {@code https} scheme is exercised end-to-end with TLS fixtures by
the catalog Kerberos
+ * integration tests. Here the {@code http} path is covered directly; the only
{@code
+ * https}-specific production logic is the small {@code startTls} step (SNI
and {@code HTTPS}
+ * endpoint identification over an already-pinned socket), which then reuses
exactly the same
+ * status/header/body parsing verified by these {@code http} tests.
+ */
+public class TestRemoteFileDownloader {
+
+ @TempDir File tempDir;
+
+ @Test
+ public void testHttpDownloadUsesPinnedAddressNotHostnameResolution() throws
Exception {
+ HttpServer server = createLoopbackServer("keytab-content", new
AtomicReference<>());
+ try {
+ server.start();
+ int port = server.getAddress().getPort();
+ File dest = new File(tempDir, "pinned");
+ // The host is a guaranteed-unresolvable name (RFC 6761 reserves
.invalid). A successful fetch
+ // therefore proves the download connected to the pinned loopback
address rather than
+ // re-resolving the hostname (which is the DNS-rebinding TOCTOU this
guards against).
+ URI uri = new URI("http://ssrf-rebind.invalid:" + port + "/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ RemoteFileDownloader.download(uri, pinned, dest, 30000);
+
+ Assertions.assertEquals("keytab-content",
Files.readString(dest.toPath()));
+ // The temp file must be atomically moved into place, leaving no ".tmp"
sibling behind.
+ File[] leftovers = tempDir.listFiles((d, name) -> name.endsWith(".tmp"));
+ Assertions.assertNotNull(leftovers);
+ Assertions.assertEquals(0, leftovers.length, "no temp file should remain
after success");
+ } finally {
+ server.stop(0);
+ }
+ }
+
+ @Test
+ public void testHttpDownloadSendsOriginalHostHeader() throws Exception {
+ AtomicReference<String> hostHeader = new AtomicReference<>();
+ HttpServer server = createLoopbackServer("data", hostHeader);
+ try {
+ server.start();
+ int port = server.getAddress().getPort();
+ File dest = new File(tempDir, "host-header");
+ URI uri = new URI("http://ssrf-rebind.invalid:" + port + "/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ RemoteFileDownloader.download(uri, pinned, dest, 30000);
+
+ // The original hostname must still be sent as the Host header, so
virtual-hosted servers
+ // continue to route correctly even though we connected to a pinned IP.
+ Assertions.assertEquals("ssrf-rebind.invalid:" + port, hostHeader.get());
+ } finally {
+ server.stop(0);
+ }
+ }
+
+ @Test
+ public void testHttpDownloadRejectsNonSuccessStatus() throws Exception {
+ HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1",
0), 0);
+ server.createContext(
+ "/keytab",
+ exchange -> {
+ exchange.sendResponseHeaders(404, -1);
+ exchange.close();
+ });
+ try {
+ server.start();
+ int port = server.getAddress().getPort();
+ File dest = new File(tempDir, "missing");
+ URI uri = new URI("http://ssrf-rebind.invalid:" + port + "/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ Assertions.assertThrows(
+ IOException.class, () -> RemoteFileDownloader.download(uri, pinned,
dest, 30000));
+ Assertions.assertFalse(dest.exists(), "no destination file should be
left on failure");
+ } finally {
+ server.stop(0);
+ }
+ }
+
+ @Test
+ public void testHttpDownloadRejectsRedirect() throws Exception {
+ HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1",
0), 0);
+ server.createContext(
+ "/keytab",
+ exchange -> {
+ // A redirect target would re-resolve to an unvalidated address, so
it must be rejected.
+ exchange.getResponseHeaders().add("Location",
"http://example.com/elsewhere");
+ exchange.sendResponseHeaders(302, -1);
+ exchange.close();
+ });
+ try {
+ server.start();
+ int port = server.getAddress().getPort();
+ File dest = new File(tempDir, "redirect");
+ URI uri = new URI("http://ssrf-rebind.invalid:" + port + "/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ Assertions.assertThrows(
+ IOException.class, () -> RemoteFileDownloader.download(uri, pinned,
dest, 30000));
+ Assertions.assertFalse(dest.exists());
+ } finally {
+ server.stop(0);
+ }
+ }
+
+ @Test
+ public void testHttpDownloadDecodesChunkedResponse() throws Exception {
+ // An explicit chunked response ("Wiki" + "pedia") must be de-chunked to
"Wikipedia"; if the
+ // chunk framing were written verbatim the file would be corrupted.
+ byte[] response =
+ ("HTTP/1.1 200 OK\r\n"
+ + "Transfer-Encoding: chunked\r\n"
+ + "Connection: close\r\n"
+ + "\r\n"
+ + "4\r\nWiki\r\n"
+ + "5\r\npedia\r\n"
+ + "0\r\n"
+ + "\r\n")
+ .getBytes(StandardCharsets.US_ASCII);
+ try (RawServer server = new RawServer(response)) {
+ File dest = new File(tempDir, "chunked");
+ URI uri = new URI("http://ssrf-rebind.invalid:" + server.port() +
"/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ RemoteFileDownloader.download(uri, pinned, dest, 30000);
+
+ Assertions.assertEquals("Wikipedia", Files.readString(dest.toPath()));
+ }
+ }
+
+ @Test
+ public void testChunkedFramingTakesPrecedenceOverContentLength() throws
Exception {
+ // TE.CL ambiguity (both Transfer-Encoding: chunked and Content-Length
present): chunked must
+ // win (RFC 7230), so the body de-chunks to "Wikipedia", not 4 bytes.
+ byte[] response =
+ ("HTTP/1.1 200 OK\r\n"
+ + "Transfer-Encoding: chunked\r\n"
+ + "Content-Length: 4\r\n"
+ + "Connection: close\r\n"
+ + "\r\n"
+ + "4\r\nWiki\r\n"
+ + "5\r\npedia\r\n"
+ + "0\r\n\r\n")
+ .getBytes(StandardCharsets.US_ASCII);
+ try (RawServer server = new RawServer(response)) {
+ File dest = new File(tempDir, "te-cl");
+ URI uri = new URI("http://ssrf-rebind.invalid:" + server.port() +
"/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ RemoteFileDownloader.download(uri, pinned, dest, 30000);
+
+ Assertions.assertEquals("Wikipedia", Files.readString(dest.toPath()));
+ }
+ }
+
+ @Test
+ public void testHttpDownloadRejectsAccumulatedOversizedHeaders() throws
Exception {
+ // Many header lines, each well under the per-line cap, whose total
exceeds the 64 KiB header
+ // cap must be rejected (a distinct guard from the per-line cap).
+ StringBuilder sb = new StringBuilder("HTTP/1.0 200 OK\r\n");
+ String value = new String(new char[1000]).replace('\0', 'A');
+ for (int i = 0; i < 70; i++) {
+ sb.append("X-Pad-").append(i).append(": ").append(value).append("\r\n");
+ }
+ sb.append("Content-Length: 4\r\nConnection: close\r\n\r\nbody");
+ byte[] response = sb.toString().getBytes(StandardCharsets.US_ASCII);
+ try (RawServer server = new RawServer(response)) {
+ File dest = new File(tempDir, "header-flood");
+ URI uri = new URI("http://ssrf-rebind.invalid:" + server.port() +
"/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ IOException exception =
+ Assertions.assertThrows(
+ IOException.class, () -> RemoteFileDownloader.download(uri,
pinned, dest, 30000));
+ Assertions.assertTrue(
+ exception.getMessage().contains("headers exceed the allowed size"),
+ exception.getMessage());
+ Assertions.assertFalse(dest.exists());
+ }
+ }
+
+ @Test
+ public void testHttpDownloadRejectsUnboundedChunkedTrailers() throws
Exception {
+ // After the terminating 0-chunk a malicious server floods trailer
headers; their total size
+ // must be bounded rather than read in an unbounded loop.
+ StringBuilder sb =
+ new StringBuilder(
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding:
chunked\r\n\r\n4\r\nWiki\r\n0\r\n");
+ // ~70 KiB of trailer lines, each well under the per-line cap, exceeding
the 64 KiB total cap.
+ String trailerValue = new String(new char[1000]).replace('\0', 'A');
+ for (int i = 0; i < 70; i++) {
+ sb.append("X-Trailer: ").append(trailerValue).append("\r\n");
+ }
+ byte[] response = sb.toString().getBytes(StandardCharsets.US_ASCII);
+ try (RawServer server = new RawServer(response)) {
+ File dest = new File(tempDir, "trailer-flood");
+ URI uri = new URI("http://ssrf-rebind.invalid:" + server.port() +
"/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ Assertions.assertThrows(
+ IOException.class, () -> RemoteFileDownloader.download(uri, pinned,
dest, 30000));
+ Assertions.assertFalse(dest.exists());
+ }
+ }
+
+ @Test
+ public void testHttpDownloadAcceptsMultiSpaceStatusLine() throws Exception {
+ // RFC 7230 allows one SP, but some servers/proxies emit extra whitespace;
a valid 200 must
+ // still be accepted.
+ byte[] response =
+ ("HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection:
close\r\n\r\nabcd")
+ .getBytes(StandardCharsets.US_ASCII);
+ try (RawServer server = new RawServer(response)) {
+ File dest = new File(tempDir, "multispace");
+ URI uri = new URI("http://ssrf-rebind.invalid:" + server.port() +
"/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ RemoteFileDownloader.download(uri, pinned, dest, 30000);
+
+ Assertions.assertEquals("abcd", Files.readString(dest.toPath()));
+ }
+ }
+
+ @Test
+ public void testHttpDownloadRejectsTruncatedContentLength() throws Exception
{
+ // Content-Length promises 100 bytes but only 10 are delivered before the
connection closes;
+ // the partial download must be rejected, not silently accepted.
+ byte[] response =
+ ("HTTP/1.0 200 OK\r\nContent-Length: 100\r\nConnection:
close\r\n\r\n0123456789")
+ .getBytes(StandardCharsets.US_ASCII);
+ try (RawServer server = new RawServer(response)) {
+ File dest = new File(tempDir, "truncated");
+ URI uri = new URI("http://ssrf-rebind.invalid:" + server.port() +
"/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ Assertions.assertThrows(
+ IOException.class, () -> RemoteFileDownloader.download(uri, pinned,
dest, 30000));
+ Assertions.assertFalse(dest.exists(), "truncated download must not leave
a destination file");
+ // The staging temp file must be cleaned up on a failed download, not
orphaned.
+ File[] leftovers = tempDir.listFiles((d, name) -> name.endsWith(".tmp"));
+ Assertions.assertNotNull(leftovers);
+ Assertions.assertEquals(0, leftovers.length, "no temp file should remain
after a failure");
+ }
+ }
+
+ @Test
+ public void testHttpDownloadRejectsOversizedStatusLine() throws Exception {
+ // The status line is bounded only by readLine's per-line cap (the
header-total cap does not
+ // apply here), so a >64 KiB status line must be rejected by that specific
guard.
+ String huge = new String(new char[70_000]).replace('\0', 'A');
+ byte[] response = ("HTTP/1.1 200 " +
huge).getBytes(StandardCharsets.US_ASCII);
+ try (RawServer server = new RawServer(response)) {
+ File dest = new File(tempDir, "huge-status");
+ URI uri = new URI("http://ssrf-rebind.invalid:" + server.port() +
"/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ IOException exception =
+ Assertions.assertThrows(
+ IOException.class, () -> RemoteFileDownloader.download(uri,
pinned, dest, 30000));
+ Assertions.assertTrue(
+ exception.getMessage().contains("HTTP line exceeds the allowed
size"),
+ exception.getMessage());
+ Assertions.assertFalse(dest.exists());
+ }
+ }
+
+ @Test
+ public void testHttpDownloadRejectsObsoleteLineFolding() throws Exception {
+ // A header continuation line (obsolete line folding, leading SP/TAB) is a
smuggling vector and
+ // must be rejected. The continuation carries a colon so the no-colon
guard cannot mask the
+ // folding guard, and the message is asserted so only the folding guard
can satisfy the test.
+ byte[] response =
+ ("HTTP/1.0 200 OK\r\nX-Folded: a\r\n\tfoo: bar\r\nConnection:
close\r\n\r\nbody")
+ .getBytes(StandardCharsets.US_ASCII);
+ try (RawServer server = new RawServer(response)) {
+ File dest = new File(tempDir, "folded");
+ URI uri = new URI("http://ssrf-rebind.invalid:" + server.port() +
"/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ IOException exception =
+ Assertions.assertThrows(
+ IOException.class, () -> RemoteFileDownloader.download(uri,
pinned, dest, 30000));
+ Assertions.assertTrue(
+ exception.getMessage().contains("Obsolete line folding"),
exception.getMessage());
+ Assertions.assertFalse(dest.exists());
+ }
+ }
+
+ @Test
+ public void testHttpDownloadRejectsHeaderWithoutColon() throws Exception {
+ byte[] response =
+ ("HTTP/1.0 200 OK\r\nBadHeaderNoColon\r\nConnection:
close\r\n\r\nbody")
+ .getBytes(StandardCharsets.US_ASCII);
+ assertDownloadRejected(response, "no-colon");
+ }
+
+ @Test
+ public void testHttpDownloadRejectsNegativeChunkSize() throws Exception {
+ byte[] response =
+ ("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n-1\r\n")
+ .getBytes(StandardCharsets.US_ASCII);
+ try (RawServer server = new RawServer(response)) {
+ File dest = new File(tempDir, "neg-chunk");
+ URI uri = new URI("http://ssrf-rebind.invalid:" + server.port() +
"/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ IOException exception =
+ Assertions.assertThrows(
+ IOException.class, () -> RemoteFileDownloader.download(uri,
pinned, dest, 30000));
+ // Assert on the specific guard so it is independently pinned (a removed
guard would otherwise
+ // still throw later for a different reason).
+ Assertions.assertTrue(
+ exception.getMessage().contains("Negative chunk size"),
exception.getMessage());
+ Assertions.assertFalse(dest.exists());
+ }
+ }
+
+ @Test
+ public void testHttpDownloadRejectsNonNumericChunkSize() throws Exception {
+ // A non-hexadecimal chunk-size line must be rejected as an IOException,
not propagate a raw
+ // unchecked NumberFormatException out of the declared `throws
IOException` contract.
+ byte[] response =
+ ("HTTP/1.1 200 OK\r\nTransfer-Encoding:
chunked\r\n\r\nGG\r\nWiki\r\n0\r\n\r\n")
+ .getBytes(StandardCharsets.US_ASCII);
+ assertDownloadRejected(response, "bad-hex-chunk");
+ }
+
+ @Test
+ public void testHttpDownloadRejectsMalformedChunkTerminator() throws
Exception {
+ // After the 4-byte chunk data "Wiki" the terminator must be CRLF; "XX"
must be rejected.
+ byte[] response =
+ ("HTTP/1.1 200 OK\r\nTransfer-Encoding:
chunked\r\n\r\n4\r\nWikiXX\r\n0\r\n\r\n")
+ .getBytes(StandardCharsets.US_ASCII);
+ assertDownloadRejected(response, "bad-terminator");
+ }
+
+ @Test
+ public void testHttpDownloadRejectsMultiCodingTransferEncoding() throws
Exception {
+ // "gzip, chunked" is de-chunkable but still gzip-compressed; since we do
not gunzip,
+ // de-chunking
+ // alone would write corrupt bytes. Anything other than a sole "chunked"
coding must be
+ // rejected.
+ byte[] response =
+ ("HTTP/1.1 200 OK\r\n"
+ + "Transfer-Encoding: gzip, chunked\r\n"
+ + "Connection: close\r\n"
+ + "\r\n"
+ + "4\r\nWiki\r\n0\r\n\r\n")
+ .getBytes(StandardCharsets.US_ASCII);
+ try (RawServer server = new RawServer(response)) {
+ File dest = new File(tempDir, "multi-coding");
+ URI uri = new URI("http://ssrf-rebind.invalid:" + server.port() +
"/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ IOException exception =
+ Assertions.assertThrows(
+ IOException.class, () -> RemoteFileDownloader.download(uri,
pinned, dest, 30000));
+ Assertions.assertTrue(
+ exception.getMessage().contains("Unsupported Transfer-Encoding"),
exception.getMessage());
+ Assertions.assertFalse(dest.exists());
+ }
+ }
+
+ @Test
+ public void testHttpDownloadRejectsChunkedSubstringTransferEncoding() throws
Exception {
+ // "xchunked" contains the substring "chunked" but is not the chunked
coding; it must not be
+ // mistaken for it. The message is asserted so the unsupported-coding
guard, not a later chunk
+ // parse error, is what satisfies the test.
+ byte[] response =
+ ("HTTP/1.1 200 OK\r\n"
+ + "Transfer-Encoding: xchunked\r\n"
+ + "Connection: close\r\n"
+ + "\r\n"
+ + "4\r\nWiki\r\n0\r\n\r\n")
+ .getBytes(StandardCharsets.US_ASCII);
+ try (RawServer server = new RawServer(response)) {
+ File dest = new File(tempDir, "x-chunked");
+ URI uri = new URI("http://ssrf-rebind.invalid:" + server.port() +
"/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ IOException exception =
+ Assertions.assertThrows(
+ IOException.class, () -> RemoteFileDownloader.download(uri,
pinned, dest, 30000));
+ Assertions.assertTrue(
+ exception.getMessage().contains("Unsupported Transfer-Encoding"),
exception.getMessage());
+ Assertions.assertFalse(dest.exists());
+ }
+ }
+
+ @Test
+ public void testHttpDownloadRejectsConflictingDuplicateContentLength()
throws Exception {
+ // Two conflicting Content-Length values (RFC 7230 §3.3.2) are a
response-splitting/smuggling
+ // vector; a silent last-wins would let parsers disagree, so the conflict
is rejected outright.
+ byte[] response =
+ ("HTTP/1.0 200 OK\r\n"
+ + "Content-Length: 4\r\n"
+ + "Content-Length: 5\r\n"
+ + "Connection: close\r\n"
+ + "\r\n"
+ + "abcd")
+ .getBytes(StandardCharsets.US_ASCII);
+ try (RawServer server = new RawServer(response)) {
+ File dest = new File(tempDir, "dup-cl");
+ URI uri = new URI("http://ssrf-rebind.invalid:" + server.port() +
"/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ IOException exception =
+ Assertions.assertThrows(
+ IOException.class, () -> RemoteFileDownloader.download(uri,
pinned, dest, 30000));
+ Assertions.assertTrue(
+ exception.getMessage().contains("Conflicting duplicate"),
exception.getMessage());
+ Assertions.assertFalse(dest.exists());
+ }
+ }
+
+ @Test
+ public void testHttpDownloadRejectsConflictingDuplicateTransferEncoding()
throws Exception {
+ // Conflicting duplicate Transfer-Encoding headers (a last-wins "chunked"
would otherwise pass
+ // the chunked check) must be rejected as a smuggling vector before any
body decode.
+ byte[] response =
+ ("HTTP/1.1 200 OK\r\n"
+ + "Transfer-Encoding: gzip\r\n"
+ + "Transfer-Encoding: chunked\r\n"
+ + "Connection: close\r\n"
+ + "\r\n"
+ + "4\r\nWiki\r\n0\r\n\r\n")
+ .getBytes(StandardCharsets.US_ASCII);
+ try (RawServer server = new RawServer(response)) {
+ File dest = new File(tempDir, "dup-te");
+ URI uri = new URI("http://ssrf-rebind.invalid:" + server.port() +
"/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ IOException exception =
+ Assertions.assertThrows(
+ IOException.class, () -> RemoteFileDownloader.download(uri,
pinned, dest, 30000));
+ Assertions.assertTrue(
+ exception.getMessage().contains("Conflicting duplicate"),
exception.getMessage());
+ Assertions.assertFalse(dest.exists());
+ }
+ }
+
+ @Test
+ public void testHttpDownloadSendsHttp11Request() throws Exception {
+ AtomicReference<String> protocol = new AtomicReference<>();
+ HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1",
0), 0);
+ server.createContext(
+ "/keytab",
+ exchange -> {
+ protocol.set(exchange.getProtocol());
+ byte[] bytes = "data".getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(200, bytes.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(bytes);
+ }
+ });
+ try {
+ server.start();
+ int port = server.getAddress().getPort();
+ File dest = new File(tempDir, "http11");
+ URI uri = new URI("http://ssrf-rebind.invalid:" + port + "/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ RemoteFileDownloader.download(uri, pinned, dest, 30000);
+
+ // The request must be HTTP/1.1 so origins frame the body
(Content-Length or chunked) rather
+ // than relying on connection close, which the downloader rejects.
+ Assertions.assertEquals("HTTP/1.1", protocol.get());
+ } finally {
+ server.stop(0);
+ }
+ }
+
+ private void assertDownloadRejected(byte[] response, String name) throws
Exception {
+ try (RawServer server = new RawServer(response)) {
+ File dest = new File(tempDir, name);
+ URI uri = new URI("http://ssrf-rebind.invalid:" + server.port() +
"/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ Assertions.assertThrows(
+ IOException.class, () -> RemoteFileDownloader.download(uri, pinned,
dest, 30000));
+ Assertions.assertFalse(dest.exists());
+ }
+ }
+
+ @Test
+ public void testHttpDownloadRejectsUnframedResponse() throws Exception {
+ // No Content-Length and no chunked encoding: a close-delimited body
cannot be verified for
+ // completeness (a premature close looks identical to a complete body), so
it is rejected rather
+ // than risk installing a silently-truncated keytab/jar.
+ byte[] response =
+ ("HTTP/1.0 200 OK\r\nConnection: close\r\n\r\nclose-delimited-body")
+ .getBytes(StandardCharsets.US_ASCII);
+ try (RawServer server = new RawServer(response)) {
+ File dest = new File(tempDir, "unframed");
+ URI uri = new URI("http://ssrf-rebind.invalid:" + server.port() +
"/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ Assertions.assertThrows(
+ IOException.class, () -> RemoteFileDownloader.download(uri, pinned,
dest, 30000));
+ Assertions.assertFalse(dest.exists());
+ }
+ }
+
+ @Test
+ public void testHttpDownloadRejectsOversizedContentLength() throws Exception
{
+ // A Content-Length beyond the 2 GiB body cap must be rejected without
streaming anything.
+ byte[] response =
+ ("HTTP/1.0 200 OK\r\nContent-Length: 3000000000\r\nConnection:
close\r\n\r\n")
+ .getBytes(StandardCharsets.US_ASCII);
+ try (RawServer server = new RawServer(response)) {
+ File dest = new File(tempDir, "too-big");
+ URI uri = new URI("http://ssrf-rebind.invalid:" + server.port() +
"/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ IOException exception =
+ Assertions.assertThrows(
+ IOException.class, () -> RemoteFileDownloader.download(uri,
pinned, dest, 30000));
+ // Assert the specific reason so the Content-Length cap is pinned and a
competing truncation
+ // failure (read == -1 on the empty body) cannot masquerade as it.
+ Assertions.assertTrue(
+ exception.getMessage().contains("exceeds the maximum allowed size"),
+ exception.getMessage());
+ Assertions.assertFalse(dest.exists());
+ }
+ }
+
+ @Test
+ public void testHttpDownloadRejectsOversizedHeader() throws Exception {
+ // A single header line larger than the 64 KiB cap must be rejected, not
buffered unbounded.
+ StringBuilder hugeHeader = new StringBuilder("X-Big: ");
+ for (int i = 0; i < 70_000; i++) {
+ hugeHeader.append('A');
+ }
+ byte[] response =
+ ("HTTP/1.0 200 OK\r\n" + hugeHeader + "\r\nConnection:
close\r\n\r\nbody")
+ .getBytes(StandardCharsets.US_ASCII);
+ try (RawServer server = new RawServer(response)) {
+ File dest = new File(tempDir, "huge-header");
+ URI uri = new URI("http://ssrf-rebind.invalid:" + server.port() +
"/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ Assertions.assertThrows(
+ IOException.class, () -> RemoteFileDownloader.download(uri, pinned,
dest, 30000));
+ Assertions.assertFalse(dest.exists());
+ }
+ }
+
+ @Test
+ public void testHttpDownloadRejectsLeadingPlusContentLength() throws
Exception {
+ // Long.parseLong would accept "+5"; RFC 7230 forbids it. The response
must be rejected.
+ byte[] response =
+ ("HTTP/1.0 200 OK\r\nContent-Length: +5\r\nConnection:
close\r\n\r\nhello")
+ .getBytes(StandardCharsets.US_ASCII);
+ try (RawServer server = new RawServer(response)) {
+ File dest = new File(tempDir, "plus-cl");
+ URI uri = new URI("http://ssrf-rebind.invalid:" + server.port() +
"/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ Assertions.assertThrows(
+ IOException.class, () -> RemoteFileDownloader.download(uri, pinned,
dest, 30000));
+ Assertions.assertFalse(dest.exists());
+ }
+ }
+
+ @Test
+ public void testUnsupportedSchemeRejected() throws Exception {
+ File dest = new File(tempDir, "scp");
+ URI uri = new URI("scp://host:22/keytab");
+ InetAddress pinned = InetAddress.getByName("127.0.0.1");
+
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> RemoteFileDownloader.download(uri, pinned, dest, 30000));
+ }
+
+ @Test
+ public void testHelpersFormatHostHeaderAndTargets() throws Exception {
+ Assertions.assertEquals("h:8080", RemoteFileDownloader.hostHeader("h",
8080));
+ Assertions.assertEquals("h", RemoteFileDownloader.hostHeader("h", -1));
+ Assertions.assertEquals("/", RemoteFileDownloader.requestTarget(new
URI("http://h")));
+ Assertions.assertEquals(
+ "/p?q=1", RemoteFileDownloader.requestTarget(new
URI("http://h/p?q=1")));
+ }
+
+ private HttpServer createLoopbackServer(String response,
AtomicReference<String> hostHeader)
+ throws Exception {
+ HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1",
0), 0);
+ server.createContext(
+ "/keytab",
+ exchange -> {
+ hostHeader.set(exchange.getRequestHeaders().getFirst("Host"));
+ byte[] bytes = response.getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(200, bytes.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(bytes);
+ }
+ });
+ return server;
+ }
+
+ /** A single-shot loopback server that writes a fixed raw response, for
byte-level control. */
+ private static final class RawServer implements AutoCloseable {
+ private final ServerSocket serverSocket;
+ private final Thread thread;
+
+ RawServer(byte[] response) throws IOException {
+ this.serverSocket = new ServerSocket(0, 1,
InetAddress.getByName("127.0.0.1"));
+ this.thread =
+ new Thread(
+ () -> {
+ try (Socket socket = serverSocket.accept()) {
+ drainRequest(socket.getInputStream());
+ socket.getOutputStream().write(response);
+ socket.getOutputStream().flush();
+ } catch (IOException ignored) {
+ // The client may close early; nothing to do.
+ }
+ });
+ this.thread.setDaemon(true);
+ this.thread.start();
+ }
+
+ int port() {
+ return serverSocket.getLocalPort();
+ }
+
+ private static void drainRequest(InputStream in) throws IOException {
+ int state = 0; // counts how far we are through the terminating \r\n\r\n
sequence
+ int b;
+ while (state < 4 && (b = in.read()) != -1) {
+ boolean expectCr = state == 0 || state == 2;
+ if (expectCr) {
+ state = (b == '\r') ? state + 1 : 0;
+ } else {
+ state = (b == '\n') ? state + 1 : 0;
+ }
+ }
+ }
+
+ @Override
+ public void close() throws IOException {
+ serverSocket.close();
+ }
+ }
+}
diff --git
a/common/src/test/java/org/apache/gravitino/utils/TestRemoteUriValidator.java
b/common/src/test/java/org/apache/gravitino/utils/TestRemoteUriValidator.java
index 5afd96fe82..0a751210c8 100644
---
a/common/src/test/java/org/apache/gravitino/utils/TestRemoteUriValidator.java
+++
b/common/src/test/java/org/apache/gravitino/utils/TestRemoteUriValidator.java
@@ -18,6 +18,7 @@
*/
package org.apache.gravitino.utils;
+import java.net.InetAddress;
import java.net.URI;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -26,66 +27,81 @@ public class TestRemoteUriValidator {
private static final String BLOCK_UNSAFE_ADDRESS_CONFIG =
"test.block-unsafe-address";
@Test
- public void testRejectLocalAddressesByDefault() {
+ public void testResolveAndValidateReturnsAddressForSafeHost() throws
Exception {
+ // A public literal IP resolves to itself (no DNS) and is safe, so it must
be returned for the
+ // caller to pin the subsequent download to.
+ InetAddress address =
+ RemoteUriValidator.resolveAndValidate(
+ new URI("http://8.8.8.8/file"), BLOCK_UNSAFE_ADDRESS_CONFIG);
+ Assertions.assertEquals("8.8.8.8", address.getHostAddress());
+ }
+
+ @Test
+ public void testResolveAndValidateRejectsUnsafeHost() {
IllegalArgumentException exception =
Assertions.assertThrows(
IllegalArgumentException.class,
() ->
- RemoteUriValidator.validate(
- new URI("http://127.0.0.1/"), true,
BLOCK_UNSAFE_ADDRESS_CONFIG));
+ RemoteUriValidator.resolveAndValidate(
+ new URI("http://127.0.0.1/file"),
BLOCK_UNSAFE_ADDRESS_CONFIG));
Assertions.assertTrue(exception.getMessage().contains("Gravitino server
side"));
Assertions.assertTrue(exception.getMessage().contains(BLOCK_UNSAFE_ADDRESS_CONFIG));
+ }
- Assertions.assertThrows(
- IllegalArgumentException.class,
- () ->
- RemoteUriValidator.validate(
- new URI("http://localhost/"), true,
BLOCK_UNSAFE_ADDRESS_CONFIG));
- Assertions.assertThrows(
- IllegalArgumentException.class,
- () ->
- RemoteUriValidator.validate(
- new URI("http://169.254.169.254/"), true,
BLOCK_UNSAFE_ADDRESS_CONFIG));
- Assertions.assertThrows(
- IllegalArgumentException.class,
- () ->
- RemoteUriValidator.validate(
- new URI("http://10.0.0.1/"), true,
BLOCK_UNSAFE_ADDRESS_CONFIG));
- Assertions.assertThrows(
- IllegalArgumentException.class,
- () ->
- RemoteUriValidator.validate(
- new URI("http://172.16.0.1/"), true,
BLOCK_UNSAFE_ADDRESS_CONFIG));
- Assertions.assertThrows(
- IllegalArgumentException.class,
- () ->
- RemoteUriValidator.validate(
- new URI("http://192.168.0.1/"), true,
BLOCK_UNSAFE_ADDRESS_CONFIG));
- Assertions.assertThrows(
- IllegalArgumentException.class,
- () ->
- RemoteUriValidator.validate(
- new URI("http://100.100.100.200/"), true,
BLOCK_UNSAFE_ADDRESS_CONFIG));
- Assertions.assertThrows(
- IllegalArgumentException.class,
- () ->
- RemoteUriValidator.validate(
- new URI("http://[fd00::1]/"), true,
BLOCK_UNSAFE_ADDRESS_CONFIG));
+ @Test
+ public void testRejectLocalAddressesByDefault() {
+ // Loopback, link-local (incl. the AWS/cloud metadata 169.254.169.254),
RFC 1918 private ranges,
+ // Alibaba/Oracle metadata, CGNAT, broadcast, "this network", multicast,
and IPv6 unique-local
+ // must all be rejected.
+ String[] unsafeHosts = {
+ "127.0.0.1",
+ "localhost",
+ "169.254.169.254",
+ "10.0.0.1",
+ "172.16.0.1",
+ "192.168.0.1",
+ "100.100.100.200",
+ "100.64.0.1",
+ "192.0.0.192",
+ "255.255.255.255",
+ "0.0.0.1",
+ "224.0.0.1",
+ "[ff02::1]",
+ "[fd00::1]",
+ // IPv4-compatible, NAT64, 6to4 and ISATAP IPv6 forms that embed a
blocked IPv4 are rejected.
+ "[::127.0.0.1]",
+ "[::169.254.169.254]",
+ "[64:ff9b::169.254.169.254]",
+ "[2002:a9fe:a9fe::]",
+ "[2001:db8::5efe:169.254.169.254]"
+ };
+ for (String host : unsafeHosts) {
+ IllegalArgumentException exception =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ RemoteUriValidator.resolveAndValidate(
+ new URI("http://" + host + "/"),
BLOCK_UNSAFE_ADDRESS_CONFIG),
+ "Expected " + host + " to be rejected");
+ Assertions.assertTrue(exception.getMessage().contains("Gravitino server
side"));
+
Assertions.assertTrue(exception.getMessage().contains(BLOCK_UNSAFE_ADDRESS_CONFIG));
+ }
+ }
+
+ @Test
+ public void testResolveAndValidateAllowsGlobalIpv6() throws Exception {
+ // A normal global IPv6 host (no embedded blocked IPv4) must not be
over-blocked by the
+ // embedded-IPv4 re-classification.
+ InetAddress address =
+ RemoteUriValidator.resolveAndValidate(
+ new URI("http://[2001:4860:4860::8888]/file"),
BLOCK_UNSAFE_ADDRESS_CONFIG);
+ Assertions.assertNotNull(address);
}
@Test
- public void testAllowUnsafeAddressesWhenBlockingDisabled() {
- Assertions.assertDoesNotThrow(
- () ->
- RemoteUriValidator.validate(
- new URI("http://127.0.0.1/"), false,
BLOCK_UNSAFE_ADDRESS_CONFIG));
- Assertions.assertDoesNotThrow(
- () ->
- RemoteUriValidator.validate(
- new URI("http://localhost/"), false,
BLOCK_UNSAFE_ADDRESS_CONFIG));
- Assertions.assertDoesNotThrow(
- () ->
- RemoteUriValidator.validate(
- new URI("http://192.168.0.1/"), false,
BLOCK_UNSAFE_ADDRESS_CONFIG));
+ public void testRejectMissingHost() {
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> RemoteUriValidator.resolveAndValidate(new URI("file:///tmp/x"),
"hint"));
}
}
diff --git a/common/src/test/java/org/apache/gravitino/utils/TestSafeUri.java
b/common/src/test/java/org/apache/gravitino/utils/TestSafeUri.java
new file mode 100644
index 0000000000..0db804fcbb
--- /dev/null
+++ b/common/src/test/java/org/apache/gravitino/utils/TestSafeUri.java
@@ -0,0 +1,51 @@
+/*
+ * 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.apache.gravitino.utils;
+
+import java.net.URI;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestSafeUri {
+
+ @Test
+ public void testDropsUserInfo() throws Exception {
+ String redacted = SafeUri.redact(new
URI("ftp://user:s3cr3t@host:21/keytab"));
+ Assertions.assertEquals("ftp://host:21/keytab", redacted);
+ Assertions.assertFalse(redacted.contains("s3cr3t"));
+ Assertions.assertFalse(redacted.contains("user"));
+ }
+
+ @Test
+ public void testDropsQueryToken() throws Exception {
+ String redacted = SafeUri.redact(new
URI("https://host/path/file.jar?token=SECRET&x=1"));
+ Assertions.assertEquals("https://host/path/file.jar", redacted);
+ Assertions.assertFalse(redacted.contains("SECRET"));
+ }
+
+ @Test
+ public void testKeepsSchemeHostPathForHostlessUri() throws Exception {
+ Assertions.assertEquals("file:///tmp/x", SafeUri.redact(new
URI("file:///tmp/x")));
+ }
+
+ @Test
+ public void testNullUri() {
+ Assertions.assertEquals("null", SafeUri.redact(null));
+ }
+}