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) object).setType(type); + return handleResponseContent(response, content, submitTransactionAsync); + } finally { + response.close(); + } + } + + private T handleResponseContent( + final Response response, String content, boolean submitTransactionAsync) { + // Too Many Requests + if (response.code() == 429) { + Integer retryAfter = null; + String header = response.header("Retry-After"); + if (header != null) { + try { + retryAfter = Integer.parseInt(header); + } catch (NumberFormatException ignored) { } - return object; } + throw new TooManyRequestsException(retryAfter); + } - // Other errors - if (response.code() >= 400 && response.code() < 600) { - Problem problem = null; - SubmitTransactionAsyncResponse submitTransactionAsyncProblem = null; + if (response.code() >= 200 && response.code() < 300) { + T object = GsonSingleton.getInstance().fromJson(content, type.getType()); + if (object instanceof TypedResponse) { + ((TypedResponse) object).setType(type); + } + return object; + } + + // Other errors + if (response.code() >= 400 && response.code() < 600) { + Problem problem = null; + SubmitTransactionAsyncResponse submitTransactionAsyncProblem = null; + try { + problem = GsonSingleton.getInstance().fromJson(content, Problem.class); + } catch (Exception e) { + // if we can't parse the response, we just ignore it + } + + if (submitTransactionAsync) { try { - problem = GsonSingleton.getInstance().fromJson(content, Problem.class); + submitTransactionAsyncProblem = + GsonSingleton.getInstance().fromJson(content, SubmitTransactionAsyncResponse.class); } catch (Exception e) { // if we can't parse the response, we just ignore it } - - if (submitTransactionAsync) { - try { - submitTransactionAsyncProblem = - GsonSingleton.getInstance().fromJson(content, SubmitTransactionAsyncResponse.class); - } catch (Exception e) { - // if we can't parse the response, we just ignore it - } - } - - if (response.code() == 504) { - throw new RequestTimeoutException(response.code(), content, problem); - } else if (response.code() < 500) { - // Codes in the 4xx range indicate an error that failed given the information provided - throw new BadRequestException( - response.code(), content, problem, submitTransactionAsyncProblem); - } else { - // Codes in the 5xx range indicate an error with the Horizon server. - throw new BadResponseException( - response.code(), content, problem, submitTransactionAsyncProblem); - } } - throw new UnknownResponseException(response.code(), content); - } finally { - response.close(); + if (response.code() == 504) { + throw new RequestTimeoutException(response.code(), content, problem); + } else if (response.code() < 500) { + // Codes in the 4xx range indicate an error that failed given the information provided + throw new BadRequestException( + response.code(), content, problem, submitTransactionAsyncProblem); + } else { + // Codes in the 5xx range indicate an error with the Horizon server. + throw new BadResponseException( + response.code(), content, problem, submitTransactionAsyncProblem); + } } + + throw new UnknownResponseException(response.code(), content); } } diff --git a/src/test/kotlin/org/stellar/sdk/federation/FederationTest.kt b/src/test/kotlin/org/stellar/sdk/federation/FederationTest.kt index 4f296c666..2dbc2bb1e 100644 --- a/src/test/kotlin/org/stellar/sdk/federation/FederationTest.kt +++ b/src/test/kotlin/org/stellar/sdk/federation/FederationTest.kt @@ -8,10 +8,12 @@ import okhttp3.OkHttpClient import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.stellar.sdk.SslCertificateUtils +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 class FederationTest : FunSpec({ @@ -163,4 +165,65 @@ class FederationTest : shouldThrow { federation().resolveAddress("bob*$domain") } } + + test("stellar.toml too large throws exception") { + // Create a response larger than 100KB limit + val largeBody = buildString { + append("""FEDERATION_SERVER = "https://$domain/federation"""") + append("\n") + while (length < 110 * 1024) { // 110KB > 100KB limit + append("# padding comment to make the file larger\n") + } + } + + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(largeBody)) + + shouldThrow { federation().resolveAddress("bob*$domain") } + } + + test("federation response too large throws exception") { + val stellarToml = """FEDERATION_SERVER = "https://$domain/federation"""" + // Create a federation response larger than 100KB + val largeResponse = buildString { + append("""{"stellar_address":"bob*$domain","account_id":"GABC","memo":"""") + while (length < 110 * 1024) { // 110KB > 100KB limit + append("x") + } + append(""""}""") + } + + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(stellarToml)) + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(largeResponse)) + + shouldThrow { + federation().resolveAddress("bob*$domain") + } + } + + test("utf8 bom is stripped from stellar.toml") { + // UTF-8 BOM (0xEF 0xBB 0xBF) + content + val bom = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) + val content = """FEDERATION_SERVER = "https://$domain/federation"""" + val bodyWithBom = bom + content.toByteArray(Charsets.UTF_8) + + val successResponse = + """ + { + "stellar_address": "bob*$domain", + "account_id": "GCW667JUHCOP5Y7KY6KGDHNPHFM4CS3FCBQ7QWDUALXTX3PGXLSOEALY" + } + """ + .trimIndent() + + mockWebServer.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "text/plain; charset=utf-8") + .setBody(okio.Buffer().write(bodyWithBom)) + ) + mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(successResponse)) + + val response = federation().resolveAddress("bob*$domain") + response.accountId shouldBe "GCW667JUHCOP5Y7KY6KGDHNPHFM4CS3FCBQ7QWDUALXTX3PGXLSOEALY" + } })