From 5902c2e455ae68101ab9049de781dd741446177b Mon Sep 17 00:00:00 2001 From: Jun Luo <4catcode@gmail.com> Date: Tue, 3 Feb 2026 21:51:48 +0800 Subject: [PATCH] fix: prevent DoS attacks in Federation HTTP client --- CHANGELOG.md | 1 + .../stellar/sdk/federation/Federation.java | 99 +++++++++++++++- .../FederationResponseTooLargeException.java | 15 +++ .../StellarTomlTooLargeException.java | 15 +++ .../stellar/sdk/requests/ResponseHandler.java | 111 ++++++++++++------ .../stellar/sdk/federation/FederationTest.kt | 63 ++++++++++ 6 files changed, 265 insertions(+), 39 deletions(-) create mode 100644 src/main/java/org/stellar/sdk/federation/exception/FederationResponseTooLargeException.java create mode 100644 src/main/java/org/stellar/sdk/federation/exception/StellarTomlTooLargeException.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a80e30c6..90c0a8cbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Pending ### Update +- fix: prevent DoS attacks in `Federation` by limiting stellar.toml and federation response sizes to 100KB, adding proper timeouts, and handling UTF-8 BOM. - fix: add stricter validation for Ed25519 Signed Payload. - fix: replace assert statements with explicit null checks in `Federation` class to ensure validation is not bypassed when assertions are disabled. - fix: add overflow check in `TimeBounds.expiresAfter()` to prevent integer overflow when timeout is too large. diff --git a/src/main/java/org/stellar/sdk/federation/Federation.java b/src/main/java/org/stellar/sdk/federation/Federation.java index 89ed39d7e..8e35d8a5a 100644 --- a/src/main/java/org/stellar/sdk/federation/Federation.java +++ b/src/main/java/org/stellar/sdk/federation/Federation.java @@ -3,18 +3,26 @@ import com.google.gson.reflect.TypeToken; import com.moandjiezana.toml.Toml; import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.util.concurrent.TimeUnit; import lombok.NonNull; import okhttp3.HttpUrl; +import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; +import okhttp3.ResponseBody; +import okio.Buffer; +import okio.BufferedSource; import org.stellar.sdk.exception.ConnectionErrorException; import org.stellar.sdk.exception.TooManyRequestsException; +import org.stellar.sdk.federation.exception.FederationResponseTooLargeException; import org.stellar.sdk.federation.exception.FederationServerInvalidException; import org.stellar.sdk.federation.exception.NoFederationServerException; import org.stellar.sdk.federation.exception.NotFoundException; import org.stellar.sdk.federation.exception.StellarTomlNotFoundInvalidException; +import org.stellar.sdk.federation.exception.StellarTomlTooLargeException; import org.stellar.sdk.requests.ResponseHandler; /** @@ -23,6 +31,16 @@ * @see Federation */ public class Federation { + /** + * Maximum allowed size for HTTP responses (100KB). + * + *
This limit prevents denial-of-service attacks where a malicious server could send an + * infinite stream of data, causing OutOfMemoryError. Legitimate stellar.toml files and federation + * responses should be well under this limit. If you need to handle larger responses, use the + * constructor that accepts a custom OkHttpClient. + */ + private static final long MAX_RESPONSE_SIZE = 100 * 1024; + private final OkHttpClient httpClient; /** @@ -52,6 +70,9 @@ public Federation() { * @throws StellarTomlNotFoundInvalidException Stellar.toml file not found or invalid * @throws NoFederationServerException No federation server defined in stellar.toml file * @throws FederationServerInvalidException Federation server is invalid + * @throws StellarTomlTooLargeException if the stellar.toml file exceeds maximum allowed size + * @throws FederationResponseTooLargeException if the federation server response exceeds maximum + * allowed size * @throws org.stellar.sdk.exception.BadRequestException if the request fails due to a bad request * (4xx) * @throws org.stellar.sdk.exception.BadResponseException if the request fails due to a bad @@ -90,6 +111,9 @@ public FederationResponse resolveAddress(String address) { * @throws StellarTomlNotFoundInvalidException Stellar.toml file not found or invalid * @throws FederationServerInvalidException Federation server is invalid * @throws NoFederationServerException No federation server defined in stellar.toml file + * @throws StellarTomlTooLargeException if the stellar.toml file exceeds maximum allowed size + * @throws FederationResponseTooLargeException if the federation server response exceeds maximum + * allowed size * @throws org.stellar.sdk.exception.BadRequestException if the request fails due to a bad request * (4xx) * @throws org.stellar.sdk.exception.BadResponseException if the request fails due to a bad @@ -125,7 +149,17 @@ private FederationResponse resolve(String q, String domain, QueryType queryType) throw new NotFoundException(); } - return responseHandler.handleResponse(response); + if (response.body() == null) { + throw new ConnectionErrorException(new IOException("Empty response body")); + } + + // Limit response size to prevent DoS attacks + String body = readResponseBodyWithLimit(response.body(), MAX_RESPONSE_SIZE); + if (body == null) { + throw new FederationResponseTooLargeException(MAX_RESPONSE_SIZE); + } + + return responseHandler.handleResponse(response, body); } catch (IOException e) { throw new ConnectionErrorException(e); } @@ -158,7 +192,14 @@ private HttpUrl getFederationServerUri(@NonNull String domain) { if (response.body() == null) { throw new StellarTomlNotFoundInvalidException("Empty response body"); } - Toml stellarToml = new Toml().read(response.body().string()); + + // Limit response size to prevent DoS attacks + String body = readResponseBodyWithLimit(response.body(), MAX_RESPONSE_SIZE); + if (body == null) { + throw new StellarTomlTooLargeException(MAX_RESPONSE_SIZE); + } + + Toml stellarToml = new Toml().read(body); String federationServer = stellarToml.getString("FEDERATION_SERVER"); if (federationServer == null || federationServer.isEmpty()) { throw new NoFederationServerException(); @@ -177,10 +218,64 @@ private static OkHttpClient createHttpClient() { return new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) + .callTimeout(60, TimeUnit.SECONDS) .retryOnConnectionFailure(false) .build(); } + /** UTF-8 BOM (Byte Order Mark) character. */ + private static final char UTF8_BOM = '\uFEFF'; + + /** + * Reads the response body with a size limit to prevent DoS attacks. + * + *
This method reads directly from the byte stream, ignoring Content-Length headers, to prevent + * attacks where a malicious server sends more data than declared. It respects the charset + * specified in the Content-Type header, defaulting to UTF-8 if not specified. UTF-8 BOM is + * automatically stripped if present. + * + * @param responseBody The response body to read from + * @param maxSize Maximum number of bytes to read + * @return The response body as a string, or null if the response exceeds maxSize + * @throws IOException If an I/O error occurs + */ + private static String readResponseBodyWithLimit(ResponseBody responseBody, long maxSize) + throws IOException { + // Get charset from Content-Type, default to UTF-8 + Charset charset = StandardCharsets.UTF_8; + MediaType contentType = responseBody.contentType(); + if (contentType != null) { + Charset contentTypeCharset = contentType.charset(); + if (contentTypeCharset != null) { + charset = contentTypeCharset; + } + } + + BufferedSource source = responseBody.source(); + Buffer buffer = new Buffer(); + long totalRead = 0; + + while (!source.exhausted()) { + long read = source.read(buffer, 8192); + if (read == -1) { + break; + } + totalRead += read; + if (totalRead > maxSize) { + return null; + } + } + + String result = buffer.readString(charset); + + // Strip UTF-8 BOM if present (consistent with OkHttp ResponseBody.string() behavior) + if (!result.isEmpty() && result.charAt(0) == UTF8_BOM) { + result = result.substring(1); + } + + return result; + } + private enum QueryType { NAME("name"), ID("id"); diff --git a/src/main/java/org/stellar/sdk/federation/exception/FederationResponseTooLargeException.java b/src/main/java/org/stellar/sdk/federation/exception/FederationResponseTooLargeException.java new file mode 100644 index 000000000..cf31a3121 --- /dev/null +++ b/src/main/java/org/stellar/sdk/federation/exception/FederationResponseTooLargeException.java @@ -0,0 +1,15 @@ +package org.stellar.sdk.federation.exception; + +import org.stellar.sdk.exception.NetworkException; + +/** + * Thrown when the federation server response exceeds the maximum allowed size. + * + *
This limit prevents denial-of-service attacks where a malicious server could send an infinite + * stream of data, causing OutOfMemoryError. + */ +public class FederationResponseTooLargeException extends NetworkException { + public FederationResponseTooLargeException(long maxSize) { + super("Federation response exceeds maximum allowed size of " + maxSize + " bytes", null, null); + } +} diff --git a/src/main/java/org/stellar/sdk/federation/exception/StellarTomlTooLargeException.java b/src/main/java/org/stellar/sdk/federation/exception/StellarTomlTooLargeException.java new file mode 100644 index 000000000..ee6d81958 --- /dev/null +++ b/src/main/java/org/stellar/sdk/federation/exception/StellarTomlTooLargeException.java @@ -0,0 +1,15 @@ +package org.stellar.sdk.federation.exception; + +import org.stellar.sdk.exception.NetworkException; + +/** + * Thrown when the stellar.toml file exceeds the maximum allowed size. + * + *
This limit prevents denial-of-service attacks where a malicious server could send an infinite + * stream of data, causing OutOfMemoryError. + */ +public class StellarTomlTooLargeException extends NetworkException { + public StellarTomlTooLargeException(long maxSize) { + super("stellar.toml exceeds maximum allowed size of " + maxSize + " bytes", null, null); + } +} diff --git a/src/main/java/org/stellar/sdk/requests/ResponseHandler.java b/src/main/java/org/stellar/sdk/requests/ResponseHandler.java index 7e999fac0..17abd4574 100644 --- a/src/main/java/org/stellar/sdk/requests/ResponseHandler.java +++ b/src/main/java/org/stellar/sdk/requests/ResponseHandler.java @@ -47,6 +47,27 @@ public T handleResponse(final Response response) { return handleResponse(response, false); } + /** + * Handles the HTTP response with pre-read body content and converts it to the appropriate object + * or throws exceptions based on the response status. + * + *
This method is useful when the caller needs to limit the response body size before + * processing, for example to prevent denial-of-service attacks. + * + *
Note: This method does NOT close the response. The caller is responsible for closing
+ * the response, typically using try-with-resources on the response object.
+ *
+ * @param response The HTTP response to handle (used for status code and headers)
+ * @param content The pre-read response body content
+ * @return The parsed object of type T
+ * @throws TooManyRequestsException If the response code is 429 (Too Many Requests)
+ * @throws BadRequestException If the response code is in the 4xx range
+ * @throws BadResponseException If the response code is in the 5xx range
+ */
+ public T handleResponse(final Response response, String content) {
+ return handleResponseContent(response, content, false);
+ }
+
/**
* Handles the HTTP response and converts it to the appropriate object or throws exceptions based
* on the response status.
@@ -63,9 +84,7 @@ public T handleResponse(final Response response) {
*/
public T handleResponse(final Response response, boolean submitTransactionAsync) {
try {
- // Too Many Requests
if (response.code() == 429) {
-
Integer retryAfter = null;
String header = response.header("Retry-After");
if (header != null) {
@@ -77,60 +96,78 @@ public T handleResponse(final Response response, boolean submitTransactionAsync)
throw new TooManyRequestsException(retryAfter);
}
- String content = null;
if (response.body() == null) {
throw new UnexpectedException("Unexpected empty response body");
}
+ String content;
try {
content = response.body().string();
} catch (IOException e) {
throw new UnexpectedException("Unexpected error reading response", e);
}
- if (response.code() >= 200 && response.code() < 300) {
- T object = GsonSingleton.getInstance().fromJson(content, type.getType());
- if (object instanceof TypedResponse) {
- ((TypedResponse