Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down
14 changes: 5 additions & 9 deletions api/src/main/java/app/simplecloud/api/CloudApiOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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<List<ManifestEntry>>() {
}.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;
Expand All @@ -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");
}

Expand All @@ -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);
}
Expand All @@ -97,159 +114,95 @@ private List<ManifestEntry> loadManifest() {
}

private List<ManifestEntry> 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<ManifestEntry> 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<ManifestEntry> 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<ManifestEntry> 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) {
Expand Down
Loading