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
10 changes: 9 additions & 1 deletion api/src/main/java/app/simplecloud/api/CloudApiOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,15 @@ public Builder networkSecret(String networkSecret) {
* @return this builder
*/
public Builder serverVersionManifestUrl(String serverVersionManifestUrl) {
this.serverVersionManifestUrl = 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;
return this;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ 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 @@ -10,7 +10,11 @@
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;
Expand All @@ -19,6 +23,8 @@

final class ManifestServerUrlResolver implements InlineBlueprintSupport.ServerUrlResolver {
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();

Expand All @@ -35,13 +41,17 @@ 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)
.build(),
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");
this.gson = Objects.requireNonNull(gson, "gson");
}
Expand All @@ -60,6 +70,10 @@ 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 @@ -83,27 +97,159 @@ private List<ManifestEntry> loadManifest() {
}

private List<ManifestEntry> fetchManifest() {
Request request = new Request.Builder()
.url(manifestUrl)
.get()
.build();

try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new IllegalStateException("Failed to fetch server version manifest from " + manifestUrl
+ ": HTTP " + response.code());
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());
}

if (response.body() == null) {
throw new IllegalStateException("Failed to fetch server version manifest from " + manifestUrl
+ ": empty response body");
}

// 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) {}
}

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();
}
} catch (IOException e) {
throw new IllegalStateException("Failed to fetch server version manifest from " + manifestUrl, 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);
}
}

static void validateManifestUrl(String url) {
validateUrlForSsrf(url, "manifestUrl");
}

static void validateDownloadLink(String url) {
validateUrlForSsrf(url, "download link");
}

if (response.body() == null) {
throw new IllegalStateException("Failed to fetch server version manifest from " + manifestUrl
+ ": empty response body");
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);
}
String scheme = uri.getScheme();
if (scheme == null || !scheme.equalsIgnoreCase("https")) {
throw new IllegalArgumentException(context + " must use https:// - got: " + urlString);
}
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 + ")" : ""));
}
// 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);
}
}

private static boolean isBlockedAddress(InetAddress addr) {
return addr.isLoopbackAddress()
|| addr.isLinkLocalAddress()
|| addr.isSiteLocalAddress()
|| addr.isAnyLocalAddress()
|| isCarrierGradeNat(addr)
|| isPrivateExtra(addr);
}

List<ManifestEntry> manifest = gson.fromJson(response.body().charStream(), MANIFEST_TYPE);
return manifest != null ? manifest : List.of();
} catch (IOException e) {
throw new IllegalStateException("Failed to fetch server version manifest from " + manifestUrl, e);
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 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 @Nullable String resolveRequestedVersion(CreateBlueprintRequest request) {
Expand Down