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" }