Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
99 changes: 97 additions & 2 deletions src/main/java/org/stellar/sdk/federation/Federation.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -23,6 +31,16 @@
* @see <a href="https://developers.stellar.org/docs/learn/glossary#federation">Federation</a>
*/
public class Federation {
/**
* Maximum allowed size for HTTP responses (100KB).
*
* <p>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;

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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();
Expand All @@ -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.
*
* <p>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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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);
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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);
}
}
111 changes: 74 additions & 37 deletions src/main/java/org/stellar/sdk/requests/ResponseHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>This method is useful when the caller needs to limit the response body size before
* processing, for example to prevent denial-of-service attacks.
*
* <p><b>Note:</b> 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.
Expand All @@ -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) {
Expand All @@ -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<T>) 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<T>) 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);
}
}
Loading
Loading