diff --git a/api/build.gradle.kts b/api/build.gradle.kts
index 89b8e33..257ba50 100644
--- a/api/build.gradle.kts
+++ b/api/build.gradle.kts
@@ -32,6 +32,8 @@ dependencies {
implementation(rootProject.libs.caffeine)
testImplementation(rootProject.libs.junit.jupiter)
+ testImplementation(rootProject.libs.okhttp.mockwebserver)
+ testImplementation(rootProject.libs.okhttp.tls)
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
diff --git a/api/src/main/java/app/simplecloud/api/CloudApiOptions.java b/api/src/main/java/app/simplecloud/api/CloudApiOptions.java
index 58f7bf0..7a4657a 100644
--- a/api/src/main/java/app/simplecloud/api/CloudApiOptions.java
+++ b/api/src/main/java/app/simplecloud/api/CloudApiOptions.java
@@ -178,19 +178,15 @@ public Builder networkSecret(String networkSecret) {
* Sets the manifest URL used to resolve software versions into concrete download URLs
* for inline blueprint creation.
*
+ *
The manifest is only fetched over HTTPS from publicly routable addresses; redirects
+ * are re-checked and the response size is capped. If a JVM-wide proxy is configured, the proxy
+ * resolves the manifest host, so the address check applies to the proxy host instead.
+ *
* @param serverVersionManifestUrl manifest endpoint returning {@code server_versions.json}
* @return this builder
*/
public Builder serverVersionManifestUrl(String serverVersionManifestUrl) {
- if (serverVersionManifestUrl == null || serverVersionManifestUrl.isBlank()) {
- throw new IllegalArgumentException("serverVersionManifestUrl must not be blank");
- }
- String trimmed = serverVersionManifestUrl.trim();
- // Early SSRF guard: https-only.
- if (!trimmed.regionMatches(true, 0, "https://", 0, 8)) {
- throw new IllegalArgumentException("serverVersionManifestUrl must use https:// - got: " + trimmed);
- }
- this.serverVersionManifestUrl = trimmed;
+ this.serverVersionManifestUrl = serverVersionManifestUrl;
return this;
}
diff --git a/api/src/main/java/app/simplecloud/api/internal/blueprint/InlineBlueprintSupport.java b/api/src/main/java/app/simplecloud/api/internal/blueprint/InlineBlueprintSupport.java
index cfafa22..3632b7b 100644
--- a/api/src/main/java/app/simplecloud/api/internal/blueprint/InlineBlueprintSupport.java
+++ b/api/src/main/java/app/simplecloud/api/internal/blueprint/InlineBlueprintSupport.java
@@ -192,7 +192,6 @@ private ModelsCreateBlueprintRequest convertCreateBlueprintRequest(String bluepr
private @Nullable String resolveServerUrl(CreateBlueprintRequest request) {
String explicitServerUrl = normalize(request.getServerUrl());
if (explicitServerUrl != null) {
- ManifestServerUrlResolver.validateDownloadLink(explicitServerUrl);
return explicitServerUrl;
}
diff --git a/api/src/main/java/app/simplecloud/api/internal/blueprint/ManifestServerUrlResolver.java b/api/src/main/java/app/simplecloud/api/internal/blueprint/ManifestServerUrlResolver.java
index 5a388a3..910c7ca 100644
--- a/api/src/main/java/app/simplecloud/api/internal/blueprint/ManifestServerUrlResolver.java
+++ b/api/src/main/java/app/simplecloud/api/internal/blueprint/ManifestServerUrlResolver.java
@@ -3,33 +3,46 @@
import app.simplecloud.api.CloudApiOptions;
import app.simplecloud.api.blueprint.CreateBlueprintRequest;
import com.google.gson.Gson;
+import com.google.gson.JsonParseException;
import com.google.gson.reflect.TypeToken;
+import okhttp3.Call;
+import okhttp3.Dns;
+import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
+import okhttp3.ResponseBody;
+import okio.BufferedSource;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
-import java.io.Reader;
import java.lang.reflect.Type;
-import java.net.InetAddress;
-import java.net.URI;
-import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
+/**
+ * Resolves inline blueprint download links from the server version manifest.
+ *
+ *
The manifest URL is configurable, so the fetch is restricted to HTTPS and public addresses
+ * (see {@link PublicAddressDns}). Redirects are followed manually so every hop is checked again,
+ * and the body is capped before it is parsed.
+ */
final class ManifestServerUrlResolver implements InlineBlueprintSupport.ServerUrlResolver {
+ static final int MAX_REDIRECTS = 5;
+ static final long MAX_MANIFEST_BYTES = 1024 * 1024;
+
+ private static final Duration FETCH_TIMEOUT = Duration.ofSeconds(30);
private static final Duration CACHE_TTL = Duration.ofMinutes(5);
- private static final int MAX_REDIRECTS = 2;
- private static final long MAX_MANIFEST_BYTES = 1_048_576L;
private static final Type MANIFEST_TYPE = new TypeToken>() {
}.getType();
- private final String manifestUrl;
+ private final @Nullable HttpUrl manifestUrl;
+ private final String manifestUrlForErrors;
private final OkHttpClient httpClient;
+ private final Duration fetchTimeout;
private final Gson gson;
private volatile CachedManifest cachedManifest;
@@ -41,18 +54,26 @@ final class ManifestServerUrlResolver implements InlineBlueprintSupport.ServerUr
.connectTimeout(options.getHttpConnectTimeout().toMillis(), TimeUnit.MILLISECONDS)
.readTimeout(options.getHttpReadTimeout().toMillis(), TimeUnit.MILLISECONDS)
.writeTimeout(options.getHttpWriteTimeout().toMillis(), TimeUnit.MILLISECONDS)
- .followRedirects(false)
- .followSslRedirects(false)
+ .dns(new PublicAddressDns(Dns.SYSTEM))
.build(),
+ FETCH_TIMEOUT,
new Gson()
);
}
- ManifestServerUrlResolver(String manifestUrl, OkHttpClient httpClient, Gson gson) {
- this.manifestUrl = Objects.requireNonNull(manifestUrl, "manifestUrl");
- // Fail fast for obvious SSRF vectors before any network call
- validateManifestUrl(this.manifestUrl);
- this.httpClient = Objects.requireNonNull(httpClient, "httpClient");
+ /**
+ * Address filtering is the caller's responsibility via the client's {@link Dns};
+ * redirects are always disabled so {@link #fetchManifest()} can check each hop.
+ * An invalid URL only fails once the manifest is needed, so unrelated SDK features keep working.
+ */
+ ManifestServerUrlResolver(String manifestUrl, OkHttpClient httpClient, Duration fetchTimeout, Gson gson) {
+ this.manifestUrl = HttpUrl.parse(Objects.requireNonNull(manifestUrl, "manifestUrl").trim());
+ this.manifestUrlForErrors = this.manifestUrl != null ? withoutSecrets(this.manifestUrl) : "an invalid URL";
+ this.httpClient = Objects.requireNonNull(httpClient, "httpClient").newBuilder()
+ .followRedirects(false)
+ .followSslRedirects(false)
+ .build();
+ this.fetchTimeout = Objects.requireNonNull(fetchTimeout, "fetchTimeout");
this.gson = Objects.requireNonNull(gson, "gson");
}
@@ -70,10 +91,6 @@ final class ManifestServerUrlResolver implements InlineBlueprintSupport.ServerUr
.filter(downloadLink -> version.equals(normalize(downloadLink.version)))
.map(downloadLink -> normalize(downloadLink.link))
.filter(Objects::nonNull)
- .map(link -> {
- validateDownloadLink(link);
- return link;
- })
.findFirst()
.orElse(null);
}
@@ -97,159 +114,95 @@ private List loadManifest() {
}
private List fetchManifest() {
- String currentUrl = manifestUrl;
- for (int redirect = 0; redirect <= MAX_REDIRECTS; redirect++) {
- validateManifestUrl(currentUrl);
- Request request = new Request.Builder()
- .url(currentUrl)
- .get()
- .build();
-
- try (Response response = httpClient.newCall(request).execute()) {
- // Manual redirect handling with SSRF re-validation
- if (isRedirect(response.code())) {
- if (redirect == MAX_REDIRECTS) {
- throw new IllegalStateException("Too many redirects fetching manifest from " + manifestUrl);
- }
- String location = response.header("Location");
- if (location == null || location.isBlank()) {
- throw new IllegalStateException("Redirect without Location from " + currentUrl);
- }
- currentUrl = resolveRedirect(currentUrl, location);
- continue;
- }
-
- if (!response.isSuccessful()) {
- throw new IllegalStateException("Failed to fetch server version manifest from " + manifestUrl
- + ": HTTP " + response.code());
- }
+ HttpUrl url = manifestUrl;
+ if (url == null) {
+ throw fetchFailure("invalid URL");
+ }
- if (response.body() == null) {
- throw new IllegalStateException("Failed to fetch server version manifest from " + manifestUrl
- + ": empty response body");
+ // The per-read timeouts don't bound a slow trickle across several hops while the cache lock is held.
+ Instant deadline = Instant.now().plus(fetchTimeout);
+ try {
+ for (int redirects = 0; ; redirects++) {
+ if (!url.isHttps()) {
+ throw fetchFailure("refusing non-HTTPS URL " + withoutSecrets(url));
}
+ PublicAddressDns.requirePublicLiteral(url);
- // Content-Length + streaming cap to avoid OOM
- String cl = response.header("Content-Length");
- if (cl != null) {
- try {
- long len = Long.parseLong(cl.trim());
- if (len > MAX_MANIFEST_BYTES) {
- throw new IllegalStateException("Manifest too large (" + len + " bytes) from " + manifestUrl);
- }
- } catch (NumberFormatException ignored) {}
+ long remainingMillis = Duration.between(Instant.now(), deadline).toMillis();
+ if (remainingMillis <= 0) {
+ throw fetchFailure("timed out after " + fetchTimeout.toMillis() + " ms");
}
-
- try (Reader reader = new BoundedReader(response.body().charStream(), MAX_MANIFEST_BYTES)) {
- List manifest = gson.fromJson(reader, MANIFEST_TYPE);
- return manifest != null ? manifest : List.of();
+ Request request = new Request.Builder()
+ .url(url)
+ .get()
+ .build();
+ Call call = httpClient.newCall(request);
+ call.timeout().timeout(remainingMillis, TimeUnit.MILLISECONDS);
+ try (Response response = call.execute()) {
+ if (!response.isRedirect()) {
+ return readManifest(response);
+ }
+ if (redirects == MAX_REDIRECTS) {
+ throw fetchFailure("more than " + MAX_REDIRECTS + " redirects");
+ }
+ url = redirectTarget(response);
}
- } catch (IOException e) {
- throw new IllegalStateException("Failed to fetch server version manifest from " + manifestUrl, e);
}
+ } catch (IOException e) {
+ throw new IllegalStateException("Failed to fetch server version manifest from " + manifestUrlForErrors, e);
}
- throw new IllegalStateException("Failed to fetch server version manifest from " + manifestUrl + ": redirect loop");
- }
-
- private static boolean isRedirect(int code) {
- return code == 301 || code == 302 || code == 303 || code == 307 || code == 308;
}
- private static String resolveRedirect(String currentUrl, String location) {
- try {
- URI base = URI.create(currentUrl);
- URI resolved = base.resolve(location.trim());
- return resolved.toString();
- } catch (IllegalArgumentException e) {
- throw new IllegalStateException("Invalid redirect Location: " + location, e);
+ private HttpUrl redirectTarget(Response response) {
+ String location = response.header("Location");
+ if (location == null) {
+ throw fetchFailure("HTTP " + response.code() + " without Location header");
}
+ HttpUrl target = response.request().url().resolve(location);
+ if (target == null) {
+ throw fetchFailure("HTTP " + response.code() + " with invalid Location header");
+ }
+ return target;
}
- static void validateManifestUrl(String url) {
- validateUrlForSsrf(url, "manifestUrl");
- }
-
- static void validateDownloadLink(String url) {
- validateUrlForSsrf(url, "download link");
- }
-
- private static void validateUrlForSsrf(String urlString, String context) {
- URI uri;
- try {
- uri = URI.create(urlString.trim());
- } catch (IllegalArgumentException e) {
- throw new IllegalArgumentException("Invalid " + context + " URL: " + urlString, e);
+ private List readManifest(Response response) throws IOException {
+ if (!response.isSuccessful()) {
+ throw fetchFailure("HTTP " + response.code());
}
- String scheme = uri.getScheme();
- if (scheme == null || !scheme.equalsIgnoreCase("https")) {
- throw new IllegalArgumentException(context + " must use https:// - got: " + urlString);
+
+ ResponseBody body = response.body();
+ if (body == null) {
+ throw fetchFailure("empty response body");
}
- String host = uri.getHost();
- if (host == null || host.isBlank()) {
- // Trying authority if host return null for some reason
- String authority = uri.getAuthority();
- throw new IllegalArgumentException(context + " must have a valid host - got: " + urlString + (authority != null ? " (authority=" + authority + ")" : ""));
+
+ // Content-Length may be absent or lie, so also probe the stream for one byte past the limit.
+ BufferedSource source = body.source();
+ if (body.contentLength() > MAX_MANIFEST_BYTES || source.request(MAX_MANIFEST_BYTES + 1)) {
+ throw fetchFailure("response exceeds " + MAX_MANIFEST_BYTES + " bytes");
}
- // DNS resolution + private IP deny (covers literal IPs and rebinding)
+
try {
- for (InetAddress addr : InetAddress.getAllByName(host)) {
- if (isBlockedAddress(addr)) {
- throw new IllegalArgumentException(
- context + " resolves to blocked private/link-local address " + addr.getHostAddress() + " - url: " + urlString);
- }
- }
- } catch (java.net.UnknownHostException e) {
- throw new IllegalArgumentException("Unknown host for " + context + ": " + host, e);
+ List manifest = gson.fromJson(source.getBuffer().readUtf8(), MANIFEST_TYPE);
+ return manifest != null ? manifest : List.of();
+ } catch (JsonParseException e) {
+ throw new IllegalStateException("Failed to parse server version manifest from " + manifestUrlForErrors, e);
}
}
- private static boolean isBlockedAddress(InetAddress addr) {
- return addr.isLoopbackAddress()
- || addr.isLinkLocalAddress()
- || addr.isSiteLocalAddress()
- || addr.isAnyLocalAddress()
- || isCarrierGradeNat(addr)
- || isPrivateExtra(addr);
- }
-
- private static boolean isCarrierGradeNat(InetAddress addr) {
- // 100.64.0.0/10
- byte[] b = addr.getAddress();
- if (b.length != 4) return false;
- int first = b[0] & 0xFF;
- int second = b[1] & 0xFF;
- return first == 100 && second >= 64 && second <= 127;
- }
-
- private static boolean isPrivateExtra(InetAddress addr) {
- byte[] b = addr.getAddress();
- if (b.length == 4) {
- // 192.0.2.0/24 TEST-NET, 198.51.100.0/24, 203.0.113.0/24 deny as well to avoid test leakage
- int f = b[0] & 0xFF, s = b[1] & 0xFF, t = b[2] & 0xFF;
- if (f == 192 && s == 0 && t == 2) return true;
- if (f == 198 && s == 51 && t == 100) return true;
- if (f == 203 && s == 0 && t == 113) return true;
- }
- return false;
+ private IllegalStateException fetchFailure(String reason) {
+ return new IllegalStateException(
+ "Failed to fetch server version manifest from " + manifestUrlForErrors + ": " + reason
+ );
}
- private static final class BoundedReader extends Reader {
- private final Reader delegate;
- private long remaining;
- BoundedReader(Reader delegate, long maxBytes) {
- this.delegate = delegate;
- this.remaining = maxBytes;
- }
- @Override public int read(char[] cbuf, int off, int len) throws IOException {
- if (remaining <= 0) throw new IOException("Manifest exceeds " + MAX_MANIFEST_BYTES + " bytes");
- int toRead = (int) Math.min(len, remaining);
- int n = delegate.read(cbuf, off, toRead);
- if (n > 0) {
- remaining -= n * 2L;
- }
- return n;
- }
- @Override public void close() throws IOException { delegate.close(); }
+ private static String withoutSecrets(HttpUrl url) {
+ return url.newBuilder()
+ .username("")
+ .password("")
+ .query(null)
+ .fragment(null)
+ .build()
+ .toString();
}
private static @Nullable String resolveRequestedVersion(CreateBlueprintRequest request) {
diff --git a/api/src/main/java/app/simplecloud/api/internal/blueprint/PublicAddressDns.java b/api/src/main/java/app/simplecloud/api/internal/blueprint/PublicAddressDns.java
new file mode 100644
index 0000000..57cd433
--- /dev/null
+++ b/api/src/main/java/app/simplecloud/api/internal/blueprint/PublicAddressDns.java
@@ -0,0 +1,133 @@
+package app.simplecloud.api.internal.blueprint;
+
+import okhttp3.Dns;
+import okhttp3.HttpUrl;
+import org.jetbrains.annotations.Nullable;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.regex.Pattern;
+
+/**
+ * {@link Dns} that refuses to resolve hosts to non-public addresses.
+ *
+ * Filtering at resolution time means the checked addresses are exactly the ones OkHttp connects to,
+ * for the initial request and every redirect hop. OkHttp skips {@link Dns} for IP literals, so callers
+ * must also check those with {@link #requirePublicLiteral(HttpUrl)}.
+ */
+final class PublicAddressDns implements Dns {
+ // Same heuristic OkHttp uses to decide whether a host is an IP literal and bypasses Dns.
+ private static final Pattern IP_LITERAL = Pattern.compile("([0-9a-fA-F]*:[0-9a-fA-F:.]*)|([\\d.]+)");
+ private static final String OCTET = "(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)";
+ private static final Pattern DOTTED_QUAD = Pattern.compile(OCTET + "(\\." + OCTET + "){3}");
+
+ private static final byte[] IPV4_COMPATIBLE_PREFIX = new byte[12];
+ private static final byte[] IPV4_MAPPED_PREFIX = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, (byte) 0xFF, (byte) 0xFF};
+ private static final byte[] IPV4_TRANSLATED_PREFIX = {0, 0, 0, 0, 0, 0, 0, 0, (byte) 0xFF, (byte) 0xFF, 0, 0};
+ private static final byte[] NAT64_PREFIX = {0x00, 0x64, (byte) 0xFF, (byte) 0x9B, 0, 0, 0, 0, 0, 0, 0, 0};
+ private static final byte[] NAT64_LOCAL_PREFIX = {0x00, 0x64, (byte) 0xFF, (byte) 0x9B, 0x00, 0x01};
+ private static final byte[] SIX_TO_FOUR_PREFIX = {0x20, 0x02};
+
+ private final Dns delegate;
+
+ PublicAddressDns(Dns delegate) {
+ this.delegate = Objects.requireNonNull(delegate, "delegate");
+ }
+
+ @Override
+ public List lookup(String hostname) throws UnknownHostException {
+ List addresses = delegate.lookup(hostname);
+ for (InetAddress address : addresses) {
+ if (!isPublic(address)) {
+ throw new UnknownHostException(
+ hostname + " resolves to non-public address " + address.getHostAddress()
+ );
+ }
+ }
+ return addresses;
+ }
+
+ static void requirePublicLiteral(HttpUrl url) throws UnknownHostException {
+ String host = url.host();
+ if (!IP_LITERAL.matcher(host).matches()) {
+ return;
+ }
+ // HttpUrl only accepts valid IPv6 literals, but numeric hosts like "127.1" or "1.2.3.4.5" reach
+ // OkHttp's InetAddress.getByName unchanged and may hit system DNS. Only plain dotted quads are allowed.
+ if (!host.contains(":") && !DOTTED_QUAD.matcher(host).matches()) {
+ throw new UnknownHostException("Ambiguous numeric host " + host);
+ }
+ InetAddress address = InetAddress.getByName(host);
+ if (!isPublic(address)) {
+ throw new UnknownHostException("Non-public address " + address.getHostAddress());
+ }
+ }
+
+ static boolean isPublic(InetAddress address) {
+ if (address.isAnyLocalAddress()
+ || address.isLoopbackAddress()
+ || address.isLinkLocalAddress()
+ || address.isSiteLocalAddress()
+ || address.isMulticastAddress()) {
+ return false;
+ }
+
+ byte[] bytes = address.getAddress();
+ return bytes.length == 4 ? isPublicIpv4(bytes) : isPublicIpv6(bytes);
+ }
+
+ private static boolean isPublicIpv4(byte[] bytes) {
+ int first = bytes[0] & 0xFF;
+ int second = bytes[1] & 0xFF;
+ int third = bytes[2] & 0xFF;
+ return first != 0 // 0.0.0.0/8 "this network"
+ && !(first == 100 && (second & 0xC0) == 64) // 100.64.0.0/10 carrier-grade NAT
+ && !(first == 192 && second == 0 && (third == 0 || third == 2)) // 192.0.0.0/24 IETF, 192.0.2.0/24 TEST-NET-1
+ && !(first == 198 && (second & 0xFE) == 18) // 198.18.0.0/15 benchmarking
+ && !(first == 198 && second == 51 && third == 100) // 198.51.100.0/24 TEST-NET-2
+ && !(first == 203 && second == 0 && third == 113) // 203.0.113.0/24 TEST-NET-3
+ && first < 240; // 240.0.0.0/4 reserved, incl. broadcast
+ }
+
+ private static boolean isPublicIpv6(byte[] bytes) {
+ if ((bytes[0] & 0xFE) == 0xFC || startsWith(bytes, NAT64_LOCAL_PREFIX)) {
+ return false; // fc00::/7 unique local, 64:ff9b:1::/48 local-use NAT64
+ }
+ InetAddress embedded = embeddedIpv4(bytes);
+ return embedded == null || isPublic(embedded);
+ }
+
+ /**
+ * Returns the IPv4 address carried inside IPv4-compatible (::/96), IPv4-mapped (::ffff:0:0/96),
+ * IPv4-translated (::ffff:0:0:0/96), NAT64 (64:ff9b::/96) or 6to4 (2002::/16) addresses.
+ * The JDK only converts IPv4-mapped literals to {@link java.net.Inet4Address}; resolver answers
+ * stay IPv6, and dual-stack sockets still connect to the embedded IPv4 address.
+ */
+ private static @Nullable InetAddress embeddedIpv4(byte[] bytes) {
+ if (startsWith(bytes, IPV4_COMPATIBLE_PREFIX)
+ || startsWith(bytes, IPV4_MAPPED_PREFIX)
+ || startsWith(bytes, IPV4_TRANSLATED_PREFIX)
+ || startsWith(bytes, NAT64_PREFIX)) {
+ return ipv4(bytes, 12);
+ }
+ if (startsWith(bytes, SIX_TO_FOUR_PREFIX)) {
+ return ipv4(bytes, 2);
+ }
+ return null;
+ }
+
+ private static boolean startsWith(byte[] bytes, byte[] prefix) {
+ return Arrays.equals(bytes, 0, prefix.length, prefix, 0, prefix.length);
+ }
+
+ private static InetAddress ipv4(byte[] bytes, int offset) {
+ try {
+ return InetAddress.getByAddress(Arrays.copyOfRange(bytes, offset, offset + 4));
+ } catch (UnknownHostException e) {
+ throw new AssertionError("4-byte address is always valid", e);
+ }
+ }
+}
diff --git a/api/src/test/java/app/simplecloud/api/internal/blueprint/ManifestServerUrlResolverTest.java b/api/src/test/java/app/simplecloud/api/internal/blueprint/ManifestServerUrlResolverTest.java
index 9839a49..b5ac00f 100644
--- a/api/src/test/java/app/simplecloud/api/internal/blueprint/ManifestServerUrlResolverTest.java
+++ b/api/src/test/java/app/simplecloud/api/internal/blueprint/ManifestServerUrlResolverTest.java
@@ -2,99 +2,285 @@
import app.simplecloud.api.CloudApiOptions;
import app.simplecloud.api.blueprint.CreateBlueprintRequest;
-import com.sun.net.httpserver.HttpServer;
+import com.google.gson.Gson;
+import okhttp3.Dns;
+import okhttp3.OkHttpClient;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.tls.HandshakeCertificates;
+import okhttp3.tls.HeldCertificate;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
-import java.io.OutputStream;
-import java.net.InetSocketAddress;
-import java.nio.charset.StandardCharsets;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
class ManifestServerUrlResolverTest {
+ private static final String HOST = "manifest.test";
+ private static final String MANIFEST = """
+ [
+ {
+ "name": "paper",
+ "downloadLinks": [
+ { "version": "1.21.11", "link": "https://example.com/paper-1.21.11.jar" }
+ ]
+ }
+ ]
+ """;
+
+ private static final HeldCertificate CERTIFICATE = new HeldCertificate.Builder()
+ .addSubjectAlternativeName(HOST)
+ .build();
+ private static final Dns LOOPBACK_DNS = hostname -> List.of(InetAddress.getLoopbackAddress());
+
+ private MockWebServer server;
+
+ @BeforeEach
+ void startServer() throws IOException {
+ server = new MockWebServer();
+ server.useHttps(new HandshakeCertificates.Builder()
+ .heldCertificate(CERTIFICATE)
+ .build()
+ .sslSocketFactory(), false);
+ server.start();
+ }
+
+ @AfterEach
+ void stopServer() throws IOException {
+ server.close();
+ }
@Test
- void resolve_returnsDownloadLinkForMinecraftVersion() throws Exception {
- try (ManifestHttpServer server = new ManifestHttpServer("""
- [
- {
- "name": "paper",
- "downloadLinks": [
- { "version": "1.21.11", "link": "https://example.com/paper-1.21.11.jar" }
- ]
- }
- ]
- """)) {
- ManifestServerUrlResolver resolver = new ManifestServerUrlResolver(
- CloudApiOptions.builder()
- .serverVersionManifestUrl(server.url())
- .build()
- );
-
- String resolvedUrl = resolver.resolve(
- CreateBlueprintRequest.builder()
- .serverSoftware("paper")
- .minecraftVersion("1.21.11")
- .build()
- );
-
- assertEquals("https://example.com/paper-1.21.11.jar", resolvedUrl);
- }
+ void resolve_returnsDownloadLinkForMinecraftVersion() {
+ server.enqueue(new MockResponse().setBody(MANIFEST));
+
+ assertEquals("https://example.com/paper-1.21.11.jar", resolver(manifestUrl()).resolve(paper("1.21.11")));
}
@Test
- void resolve_returnsNullWhenVersionIsMissingFromManifest() throws Exception {
- try (ManifestHttpServer server = new ManifestHttpServer("""
- [
- {
- "name": "paper",
- "downloadLinks": [
- { "version": "1.21.10", "link": "https://example.com/paper-1.21.10.jar" }
- ]
- }
- ]
- """)) {
- ManifestServerUrlResolver resolver = new ManifestServerUrlResolver(
- CloudApiOptions.builder()
- .serverVersionManifestUrl(server.url())
- .build()
- );
-
- String resolvedUrl = resolver.resolve(
- CreateBlueprintRequest.builder()
- .serverSoftware("paper")
- .minecraftVersion("1.21.11")
- .build()
- );
-
- assertNull(resolvedUrl);
- }
+ void resolve_returnsNullWhenVersionIsMissingFromManifest() {
+ server.enqueue(new MockResponse().setBody(MANIFEST));
+
+ assertNull(resolver(manifestUrl()).resolve(paper("1.21.10")));
}
- private static final class ManifestHttpServer implements AutoCloseable {
- private final HttpServer server;
-
- private ManifestHttpServer(String body) throws Exception {
- this.server = HttpServer.create(new InetSocketAddress(0), 0);
- this.server.createContext("/server_versions.json", exchange -> {
- byte[] responseBody = body.getBytes(StandardCharsets.UTF_8);
- exchange.getResponseHeaders().add("Content-Type", "application/json");
- exchange.sendResponseHeaders(200, responseBody.length);
- try (OutputStream outputStream = exchange.getResponseBody()) {
- outputStream.write(responseBody);
- }
- });
- this.server.start();
- }
+ @Test
+ void resolve_doesNotTouchManifestWithoutSoftwareOrVersion() {
+ assertNull(resolver(manifestUrl()).resolve(CreateBlueprintRequest.builder().serverSoftware("paper").build()));
+ assertEquals(0, server.getRequestCount());
+ }
- private String url() {
- return "http://127.0.0.1:" + server.getAddress().getPort() + "/server_versions.json";
- }
+ @Test
+ void resolve_rejectsHttpManifestUrl() {
+ String httpUrl = server.url("/server_versions.json").newBuilder().scheme("http").host(HOST).build().toString();
+
+ IllegalStateException failure = assertThrows(
+ IllegalStateException.class,
+ () -> resolver(httpUrl).resolve(paper("1.21.11"))
+ );
+
+ assertTrue(failure.getMessage().contains("non-HTTPS"), failure.getMessage());
+ assertEquals(0, server.getRequestCount());
+ }
+
+ @Test
+ void resolve_rejectsPrivateIpLiteralManifestUrl() {
+ IllegalStateException failure = assertThrows(
+ IllegalStateException.class,
+ () -> resolver("https://127.0.0.1:" + server.getPort() + "/server_versions.json").resolve(paper("1.21.11"))
+ );
+
+ assertInstanceOf(UnknownHostException.class, failure.getCause());
+ assertEquals(0, server.getRequestCount());
+ }
+
+ @Test
+ void resolve_rejectsHostResolvingToPrivateAddress() throws UnknownHostException {
+ InetAddress privateAddress = InetAddress.getByName("10.0.0.5");
+ ManifestServerUrlResolver resolver = resolver(
+ manifestUrl(),
+ new PublicAddressDns(hostname -> List.of(privateAddress))
+ );
+
+ IllegalStateException failure = assertThrows(IllegalStateException.class, () -> resolver.resolve(paper("1.21.11")));
+
+ assertInstanceOf(UnknownHostException.class, failure.getCause());
+ }
+
+ @Test
+ void resolve_productionClientRejectsHostResolvingToLoopback() {
+ ManifestServerUrlResolver resolver = new ManifestServerUrlResolver(CloudApiOptions.builder()
+ .serverVersionManifestUrl("https://localhost:" + server.getPort() + "/server_versions.json")
+ .build());
+
+ IllegalStateException failure = assertThrows(IllegalStateException.class, () -> resolver.resolve(paper("1.21.11")));
+
+ assertInstanceOf(UnknownHostException.class, failure.getCause());
+ assertEquals(0, server.getRequestCount());
+ }
+
+ @Test
+ void resolve_followsRedirects() {
+ server.enqueue(redirectTo("/moved.json"));
+ server.enqueue(new MockResponse().setBody(MANIFEST));
+
+ assertEquals("https://example.com/paper-1.21.11.jar", resolver(manifestUrl()).resolve(paper("1.21.11")));
+ assertEquals(2, server.getRequestCount());
+ }
+
+ @Test
+ void resolve_rejectsRedirectToHttp() {
+ server.enqueue(redirectTo("http://" + HOST + ":" + server.getPort() + "/server_versions.json"));
+
+ IllegalStateException failure = assertThrows(
+ IllegalStateException.class,
+ () -> resolver(manifestUrl()).resolve(paper("1.21.11"))
+ );
- @Override
- public void close() {
- server.stop(0);
+ assertTrue(failure.getMessage().contains("non-HTTPS"), failure.getMessage());
+ assertEquals(1, server.getRequestCount());
+ }
+
+ @Test
+ void resolve_rejectsRedirectToPrivateAddress() {
+ server.enqueue(redirectTo("https://169.254.169.254/latest/meta-data/"));
+
+ IllegalStateException failure = assertThrows(
+ IllegalStateException.class,
+ () -> resolver(manifestUrl()).resolve(paper("1.21.11"))
+ );
+
+ assertInstanceOf(UnknownHostException.class, failure.getCause());
+ }
+
+ @Test
+ void resolve_rejectsRedirectWithoutLocation() {
+ server.enqueue(new MockResponse().setResponseCode(302));
+
+ IllegalStateException failure = assertThrows(
+ IllegalStateException.class,
+ () -> resolver(manifestUrl()).resolve(paper("1.21.11"))
+ );
+
+ assertTrue(failure.getMessage().contains("without Location"), failure.getMessage());
+ }
+
+ @Test
+ void resolve_capsRedirects() {
+ for (int i = 0; i <= ManifestServerUrlResolver.MAX_REDIRECTS; i++) {
+ server.enqueue(redirectTo("/server_versions.json"));
}
+
+ assertThrows(IllegalStateException.class, () -> resolver(manifestUrl()).resolve(paper("1.21.11")));
+ assertEquals(ManifestServerUrlResolver.MAX_REDIRECTS + 1, server.getRequestCount());
+ }
+
+ @Test
+ void resolve_acceptsManifestAtSizeLimit() {
+ server.enqueue(new MockResponse().setBody(paddedManifest(ManifestServerUrlResolver.MAX_MANIFEST_BYTES)));
+
+ assertNull(resolver(manifestUrl()).resolve(paper("1.21.11")));
+ }
+
+ @Test
+ void resolve_rejectsManifestOverSizeLimit() {
+ server.enqueue(new MockResponse().setBody(paddedManifest(ManifestServerUrlResolver.MAX_MANIFEST_BYTES + 1)));
+
+ assertThrows(IllegalStateException.class, () -> resolver(manifestUrl()).resolve(paper("1.21.11")));
+ }
+
+ @Test
+ void resolve_rejectsChunkedManifestOverSizeLimit() {
+ server.enqueue(new MockResponse().setChunkedBody(
+ paddedManifest(ManifestServerUrlResolver.MAX_MANIFEST_BYTES + 1),
+ 64 * 1024
+ ));
+
+ assertThrows(IllegalStateException.class, () -> resolver(manifestUrl()).resolve(paper("1.21.11")));
+ }
+
+ @Test
+ void resolve_enforcesOverallDeadline() {
+ // Each read arrives well within the read timeout, but the whole body takes longer than the deadline.
+ server.enqueue(new MockResponse().setBody(MANIFEST).throttleBody(16, 100, TimeUnit.MILLISECONDS));
+
+ assertThrows(
+ IllegalStateException.class,
+ () -> resolver(manifestUrl(), LOOPBACK_DNS, Duration.ofMillis(300)).resolve(paper("1.21.11"))
+ );
+ }
+
+ @Test
+ void resolve_keepsCredentialsOutOfErrors() {
+ server.enqueue(new MockResponse().setResponseCode(500));
+ String url = server.url("/server_versions.json").newBuilder()
+ .host(HOST)
+ .username("user")
+ .password("hunter2")
+ .addQueryParameter("token", "s3cret")
+ .build()
+ .toString();
+
+ IllegalStateException failure = assertThrows(IllegalStateException.class, () -> resolver(url).resolve(paper("1.21.11")));
+
+ assertFalse(failure.getMessage().contains("hunter2"), failure.getMessage());
+ assertFalse(failure.getMessage().contains("s3cret"), failure.getMessage());
+ }
+
+ @Test
+ void resolve_wrapsMalformedManifest() {
+ server.enqueue(new MockResponse().setBody("{ not json"));
+
+ assertThrows(IllegalStateException.class, () -> resolver(manifestUrl()).resolve(paper("1.21.11")));
+ }
+
+ private ManifestServerUrlResolver resolver(String manifestUrl) {
+ return resolver(manifestUrl, LOOPBACK_DNS);
+ }
+
+ private ManifestServerUrlResolver resolver(String manifestUrl, Dns dns) {
+ return resolver(manifestUrl, dns, Duration.ofSeconds(10));
+ }
+
+ private ManifestServerUrlResolver resolver(String manifestUrl, Dns dns, Duration fetchTimeout) {
+ HandshakeCertificates trust = new HandshakeCertificates.Builder()
+ .addTrustedCertificate(CERTIFICATE.certificate())
+ .build();
+ OkHttpClient client = new OkHttpClient.Builder()
+ .sslSocketFactory(trust.sslSocketFactory(), trust.trustManager())
+ .dns(dns)
+ .build();
+ return new ManifestServerUrlResolver(manifestUrl, client, fetchTimeout, new Gson());
+ }
+
+ private String manifestUrl() {
+ return server.url("/server_versions.json").newBuilder().host(HOST).build().toString();
+ }
+
+ private MockResponse redirectTo(String location) {
+ return new MockResponse().setResponseCode(302).setHeader("Location", location);
+ }
+
+ private static CreateBlueprintRequest paper(String minecraftVersion) {
+ return CreateBlueprintRequest.builder()
+ .serverSoftware("paper")
+ .minecraftVersion(minecraftVersion)
+ .build();
+ }
+
+ private static String paddedManifest(long sizeBytes) {
+ return "[" + " ".repeat((int) sizeBytes - 2) + "]";
}
}
diff --git a/api/src/test/java/app/simplecloud/api/internal/blueprint/PublicAddressDnsTest.java b/api/src/test/java/app/simplecloud/api/internal/blueprint/PublicAddressDnsTest.java
new file mode 100644
index 0000000..0e4c529
--- /dev/null
+++ b/api/src/test/java/app/simplecloud/api/internal/blueprint/PublicAddressDnsTest.java
@@ -0,0 +1,117 @@
+package app.simplecloud.api.internal.blueprint;
+
+import okhttp3.HttpUrl;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class PublicAddressDnsTest {
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "0.0.0.0", "0.1.2.3", // this network
+ "127.0.0.1", "127.1.2.3", // loopback
+ "10.0.0.1", "172.16.0.1", "192.168.1.1", // RFC 1918
+ "169.254.169.254", // link-local / cloud metadata
+ "100.64.0.1", "100.127.255.255", // carrier-grade NAT
+ "192.0.0.1", "192.0.2.1", // IETF protocol assignments / TEST-NET-1
+ "198.18.0.1", "198.19.255.255", // benchmarking
+ "198.51.100.1", "203.0.113.1", // TEST-NET-2 / TEST-NET-3
+ "224.0.0.1", "239.255.255.250", // multicast
+ "240.0.0.1", "255.255.255.255", // reserved / broadcast
+ "::", "::1", // unspecified / loopback
+ "fe80::1", // link-local
+ "fc00::1", "fd00:ec2::254", // unique local
+ "ff02::1", // multicast
+ "::ffff:127.0.0.1", "::ffff:10.0.0.1", // IPv4-mapped
+ "::10.0.0.1", // IPv4-compatible
+ "64:ff9b::a9fe:a9fe", // NAT64 of 169.254.169.254
+ "2002:c0a8:0101::1", // 6to4 of 192.168.1.1
+ "64:ff9b:1::a00:1", "64:ff9b:1::a9fe:a9fe", // local-use NAT64
+ })
+ void isPublic_rejectsNonPublicAddresses(String literal) throws UnknownHostException {
+ assertFalse(PublicAddressDns.isPublic(InetAddress.getByName(literal)));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "1.1.1.1", "8.8.8.8", "185.199.108.133",
+ "100.63.255.255", "100.128.0.1", "198.17.255.255", "198.20.0.1",
+ "192.0.1.1", "192.0.3.1", "198.51.101.1", "203.0.114.1",
+ "2606:4700:4700::1111", "2a00:1450:4001:80b::200e",
+ "64:ff9b::808:808", // NAT64 of 8.8.8.8
+ })
+ void isPublic_acceptsPublicAddresses(String literal) throws UnknownHostException {
+ assertTrue(PublicAddressDns.isPublic(InetAddress.getByName(literal)));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "::ffff:127.0.0.1", "::ffff:169.254.169.254", "::ffff:10.0.0.1", // IPv4-mapped
+ "::ffff:0:127.0.0.1", // IPv4-translated
+ "64:ff9b:1::808:808", // local-use NAT64
+ })
+ void isPublic_rejectsNonPublicIpv4EmbeddedInResolverAnswers(String literal) throws UnknownHostException {
+ // Resolver answers keep their IPv6 form; InetAddress.getByName would convert mapped literals to IPv4.
+ assertFalse(PublicAddressDns.isPublic(ipv6Answer(literal)));
+ }
+
+ @Test
+ void isPublic_acceptsPublicIpv4MappedResolverAnswer() throws UnknownHostException {
+ assertTrue(PublicAddressDns.isPublic(ipv6Answer("::ffff:8.8.8.8")));
+ }
+
+ @Test
+ void lookup_rejectsWhenAnyResolvedAddressIsNonPublic() throws UnknownHostException {
+ List addresses = List.of(InetAddress.getByName("1.1.1.1"), InetAddress.getByName("10.0.0.1"));
+ PublicAddressDns dns = new PublicAddressDns(hostname -> addresses);
+
+ assertThrows(UnknownHostException.class, () -> dns.lookup("mixed.test"));
+ }
+
+ @Test
+ void lookup_returnsPublicAddresses() throws UnknownHostException {
+ List addresses = List.of(InetAddress.getByName("1.1.1.1"), InetAddress.getByName("2606:4700:4700::1111"));
+ PublicAddressDns dns = new PublicAddressDns(hostname -> addresses);
+
+ assertEquals(addresses, dns.lookup("public.test"));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "https://127.0.0.1/", "https://[::1]/", "https://[fd00::1]/", "https://[::ffff:a9fe:a9fe]/",
+ "https://127.1/", "https://2130706433/", "https://1.2.3.4.5/", "https://999.1.1.1/", // ambiguous numeric hosts
+ })
+ void requirePublicLiteral_rejectsNonPublicOrAmbiguousLiterals(String url) {
+ assertThrows(UnknownHostException.class, () -> PublicAddressDns.requirePublicLiteral(HttpUrl.get(url)));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"https://1.1.1.1/", "https://[2606:4700:4700::1111]/", "https://localhost/"})
+ void requirePublicLiteral_ignoresPublicLiteralsAndHostnames(String url) {
+ // Hostnames are left to the Dns lookup.
+ assertDoesNotThrow(() -> PublicAddressDns.requirePublicLiteral(HttpUrl.get(url)));
+ }
+
+ private static InetAddress ipv6Answer(String literal) throws UnknownHostException {
+ byte[] bytes = new byte[16];
+ byte[] parsed = InetAddress.getByName(literal).getAddress();
+ System.arraycopy(parsed, 0, bytes, 16 - parsed.length, parsed.length);
+ if (parsed.length == 4) {
+ bytes[10] = (byte) 0xFF;
+ bytes[11] = (byte) 0xFF;
+ }
+ return Inet6Address.getByAddress(null, bytes, -1);
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b83f7f4..c22f52b 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -47,6 +47,8 @@ player-proto = { module = "build.buf.gen:simplecloud_player_protocolbuffers_java
jnats = { module = "io.nats:jnats", version.ref = "jnats" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp-logging" }
+okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
+okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
gson-fire = { module = "io.gsonfire:gson-fire", version.ref = "gson-fire" }
jakarta-annotation = { module = "jakarta.annotation:jakarta.annotation-api", version.ref = "jakarta-annotation" }