Skip to content

Commit 719df36

Browse files
committed
fix: prevent DoS attacks in Federation HTTP client
1 parent 028b666 commit 719df36

6 files changed

Lines changed: 265 additions & 51 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Pending
44

55
### Update
6+
- fix: prevent DoS attacks in `Federation` by limiting stellar.toml and federation response sizes to 100KB, adding proper timeouts, and handling UTF-8 BOM.
67
- fix: add stricter validation for Ed25519 Signed Payload.
78
- fix: replace assert statements with explicit null checks in `Federation` class to ensure validation is not bypassed when assertions are disabled.
89
- fix: add overflow check in `TimeBounds.expiresAfter()` to prevent integer overflow when timeout is too large.

src/main/java/org/stellar/sdk/federation/Federation.java

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,26 @@
33
import com.google.gson.reflect.TypeToken;
44
import com.moandjiezana.toml.Toml;
55
import java.io.IOException;
6+
import java.nio.charset.Charset;
7+
import java.nio.charset.StandardCharsets;
68
import java.util.concurrent.TimeUnit;
79
import lombok.NonNull;
810
import okhttp3.HttpUrl;
11+
import okhttp3.MediaType;
912
import okhttp3.OkHttpClient;
1013
import okhttp3.Request;
1114
import okhttp3.Response;
15+
import okhttp3.ResponseBody;
16+
import okio.Buffer;
17+
import okio.BufferedSource;
1218
import org.stellar.sdk.exception.ConnectionErrorException;
1319
import org.stellar.sdk.exception.TooManyRequestsException;
20+
import org.stellar.sdk.federation.exception.FederationResponseTooLargeException;
1421
import org.stellar.sdk.federation.exception.FederationServerInvalidException;
1522
import org.stellar.sdk.federation.exception.NoFederationServerException;
1623
import org.stellar.sdk.federation.exception.NotFoundException;
1724
import org.stellar.sdk.federation.exception.StellarTomlNotFoundInvalidException;
25+
import org.stellar.sdk.federation.exception.StellarTomlTooLargeException;
1826
import org.stellar.sdk.requests.ResponseHandler;
1927

2028
/**
@@ -23,6 +31,16 @@
2331
* @see <a href="https://developers.stellar.org/docs/learn/glossary#federation">Federation</a>
2432
*/
2533
public class Federation {
34+
/**
35+
* Maximum allowed size for HTTP responses (100KB).
36+
*
37+
* <p>This limit prevents denial-of-service attacks where a malicious server could send an
38+
* infinite stream of data, causing OutOfMemoryError. Legitimate stellar.toml files and federation
39+
* responses should be well under this limit. If you need to handle larger responses, use the
40+
* constructor that accepts a custom OkHttpClient.
41+
*/
42+
private static final long MAX_RESPONSE_SIZE = 100 * 1024;
43+
2644
private final OkHttpClient httpClient;
2745

2846
/**
@@ -52,6 +70,9 @@ public Federation() {
5270
* @throws StellarTomlNotFoundInvalidException Stellar.toml file not found or invalid
5371
* @throws NoFederationServerException No federation server defined in stellar.toml file
5472
* @throws FederationServerInvalidException Federation server is invalid
73+
* @throws StellarTomlTooLargeException if the stellar.toml file exceeds maximum allowed size
74+
* @throws FederationResponseTooLargeException if the federation server response exceeds maximum
75+
* allowed size
5576
* @throws org.stellar.sdk.exception.BadRequestException if the request fails due to a bad request
5677
* (4xx)
5778
* @throws org.stellar.sdk.exception.BadResponseException if the request fails due to a bad
@@ -90,6 +111,9 @@ public FederationResponse resolveAddress(String address) {
90111
* @throws StellarTomlNotFoundInvalidException Stellar.toml file not found or invalid
91112
* @throws FederationServerInvalidException Federation server is invalid
92113
* @throws NoFederationServerException No federation server defined in stellar.toml file
114+
* @throws StellarTomlTooLargeException if the stellar.toml file exceeds maximum allowed size
115+
* @throws FederationResponseTooLargeException if the federation server response exceeds maximum
116+
* allowed size
93117
* @throws org.stellar.sdk.exception.BadRequestException if the request fails due to a bad request
94118
* (4xx)
95119
* @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)
125149
throw new NotFoundException();
126150
}
127151

128-
return responseHandler.handleResponse(response);
152+
if (response.body() == null) {
153+
throw new ConnectionErrorException(new IOException("Empty response body"));
154+
}
155+
156+
// Limit response size to prevent DoS attacks
157+
String body = readResponseBodyWithLimit(response.body(), MAX_RESPONSE_SIZE);
158+
if (body == null) {
159+
throw new FederationResponseTooLargeException(MAX_RESPONSE_SIZE);
160+
}
161+
162+
return responseHandler.handleResponse(response, body);
129163
} catch (IOException e) {
130164
throw new ConnectionErrorException(e);
131165
}
@@ -158,7 +192,14 @@ private HttpUrl getFederationServerUri(@NonNull String domain) {
158192
if (response.body() == null) {
159193
throw new StellarTomlNotFoundInvalidException("Empty response body");
160194
}
161-
Toml stellarToml = new Toml().read(response.body().string());
195+
196+
// Limit response size to prevent DoS attacks
197+
String body = readResponseBodyWithLimit(response.body(), MAX_RESPONSE_SIZE);
198+
if (body == null) {
199+
throw new StellarTomlTooLargeException(MAX_RESPONSE_SIZE);
200+
}
201+
202+
Toml stellarToml = new Toml().read(body);
162203
String federationServer = stellarToml.getString("FEDERATION_SERVER");
163204
if (federationServer == null || federationServer.isEmpty()) {
164205
throw new NoFederationServerException();
@@ -177,10 +218,64 @@ private static OkHttpClient createHttpClient() {
177218
return new OkHttpClient.Builder()
178219
.connectTimeout(10, TimeUnit.SECONDS)
179220
.readTimeout(30, TimeUnit.SECONDS)
221+
.callTimeout(60, TimeUnit.SECONDS)
180222
.retryOnConnectionFailure(false)
181223
.build();
182224
}
183225

226+
/** UTF-8 BOM (Byte Order Mark) character. */
227+
private static final char UTF8_BOM = '\uFEFF';
228+
229+
/**
230+
* Reads the response body with a size limit to prevent DoS attacks.
231+
*
232+
* <p>This method reads directly from the byte stream, ignoring Content-Length headers, to prevent
233+
* attacks where a malicious server sends more data than declared. It respects the charset
234+
* specified in the Content-Type header, defaulting to UTF-8 if not specified. UTF-8 BOM is
235+
* automatically stripped if present.
236+
*
237+
* @param responseBody The response body to read from
238+
* @param maxSize Maximum number of bytes to read
239+
* @return The response body as a string, or null if the response exceeds maxSize
240+
* @throws IOException If an I/O error occurs
241+
*/
242+
private static String readResponseBodyWithLimit(ResponseBody responseBody, long maxSize)
243+
throws IOException {
244+
// Get charset from Content-Type, default to UTF-8
245+
Charset charset = StandardCharsets.UTF_8;
246+
MediaType contentType = responseBody.contentType();
247+
if (contentType != null) {
248+
Charset contentTypeCharset = contentType.charset();
249+
if (contentTypeCharset != null) {
250+
charset = contentTypeCharset;
251+
}
252+
}
253+
254+
BufferedSource source = responseBody.source();
255+
Buffer buffer = new Buffer();
256+
long totalRead = 0;
257+
258+
while (!source.exhausted()) {
259+
long read = source.read(buffer, 8192);
260+
if (read == -1) {
261+
break;
262+
}
263+
totalRead += read;
264+
if (totalRead > maxSize) {
265+
return null;
266+
}
267+
}
268+
269+
String result = buffer.readString(charset);
270+
271+
// Strip UTF-8 BOM if present (consistent with OkHttp ResponseBody.string() behavior)
272+
if (!result.isEmpty() && result.charAt(0) == UTF8_BOM) {
273+
result = result.substring(1);
274+
}
275+
276+
return result;
277+
}
278+
184279
private enum QueryType {
185280
NAME("name"),
186281
ID("id");
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package org.stellar.sdk.federation.exception;
2+
3+
import org.stellar.sdk.exception.NetworkException;
4+
5+
/**
6+
* Thrown when the federation server response exceeds the maximum allowed size.
7+
*
8+
* <p>This limit prevents denial-of-service attacks where a malicious server could send an infinite
9+
* stream of data, causing OutOfMemoryError.
10+
*/
11+
public class FederationResponseTooLargeException extends NetworkException {
12+
public FederationResponseTooLargeException(long maxSize) {
13+
super("Federation response exceeds maximum allowed size of " + maxSize + " bytes", null, null);
14+
}
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package org.stellar.sdk.federation.exception;
2+
3+
import org.stellar.sdk.exception.NetworkException;
4+
5+
/**
6+
* Thrown when the stellar.toml file exceeds the maximum allowed size.
7+
*
8+
* <p>This limit prevents denial-of-service attacks where a malicious server could send an infinite
9+
* stream of data, causing OutOfMemoryError.
10+
*/
11+
public class StellarTomlTooLargeException extends NetworkException {
12+
public StellarTomlTooLargeException(long maxSize) {
13+
super("stellar.toml exceeds maximum allowed size of " + maxSize + " bytes", null, null);
14+
}
15+
}

src/main/java/org/stellar/sdk/requests/ResponseHandler.java

Lines changed: 74 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,27 @@ public T handleResponse(final Response response) {
4747
return handleResponse(response, false);
4848
}
4949

50+
/**
51+
* Handles the HTTP response with pre-read body content and converts it to the appropriate object
52+
* or throws exceptions based on the response status.
53+
*
54+
* <p>This method is useful when the caller needs to limit the response body size before
55+
* processing, for example to prevent denial-of-service attacks.
56+
*
57+
* <p><b>Note:</b> This method does NOT close the response. The caller is responsible for closing
58+
* the response, typically using try-with-resources on the response object.
59+
*
60+
* @param response The HTTP response to handle (used for status code and headers)
61+
* @param content The pre-read response body content
62+
* @return The parsed object of type T
63+
* @throws TooManyRequestsException If the response code is 429 (Too Many Requests)
64+
* @throws BadRequestException If the response code is in the 4xx range
65+
* @throws BadResponseException If the response code is in the 5xx range
66+
*/
67+
public T handleResponse(final Response response, String content) {
68+
return handleResponseContent(response, content, false);
69+
}
70+
5071
/**
5172
* Handles the HTTP response and converts it to the appropriate object or throws exceptions based
5273
* on the response status.
@@ -63,74 +84,78 @@ public T handleResponse(final Response response) {
6384
*/
6485
public T handleResponse(final Response response, boolean submitTransactionAsync) {
6586
try {
66-
// Too Many Requests
67-
if (response.code() == 429) {
68-
69-
Integer retryAfter = null;
70-
String header = response.header("Retry-After");
71-
if (header != null) {
72-
try {
73-
retryAfter = Integer.parseInt(header);
74-
} catch (NumberFormatException ignored) {
75-
}
76-
}
77-
throw new TooManyRequestsException(retryAfter);
78-
}
79-
80-
String content = null;
8187
if (response.body() == null) {
8288
throw new UnexpectedException("Unexpected empty response body");
8389
}
8490

91+
String content;
8592
try {
8693
content = response.body().string();
8794
} catch (IOException e) {
8895
throw new UnexpectedException("Unexpected error reading response", e);
8996
}
9097

91-
if (response.code() >= 200 && response.code() < 300) {
92-
T object = GsonSingleton.getInstance().fromJson(content, type.getType());
93-
if (object instanceof TypedResponse) {
94-
((TypedResponse<T>) object).setType(type);
98+
return handleResponseContent(response, content, submitTransactionAsync);
99+
} finally {
100+
response.close();
101+
}
102+
}
103+
104+
private T handleResponseContent(
105+
final Response response, String content, boolean submitTransactionAsync) {
106+
// Too Many Requests
107+
if (response.code() == 429) {
108+
Integer retryAfter = null;
109+
String header = response.header("Retry-After");
110+
if (header != null) {
111+
try {
112+
retryAfter = Integer.parseInt(header);
113+
} catch (NumberFormatException ignored) {
95114
}
96-
return object;
115+
}
116+
throw new TooManyRequestsException(retryAfter);
117+
}
118+
119+
if (response.code() >= 200 && response.code() < 300) {
120+
T object = GsonSingleton.getInstance().fromJson(content, type.getType());
121+
if (object instanceof TypedResponse) {
122+
((TypedResponse<T>) object).setType(type);
123+
}
124+
return object;
125+
}
126+
127+
// Other errors
128+
if (response.code() >= 400 && response.code() < 600) {
129+
Problem problem = null;
130+
SubmitTransactionAsyncResponse submitTransactionAsyncProblem = null;
131+
try {
132+
problem = GsonSingleton.getInstance().fromJson(content, Problem.class);
133+
} catch (Exception e) {
134+
// if we can't parse the response, we just ignore it
97135
}
98136

99-
// Other errors
100-
if (response.code() >= 400 && response.code() < 600) {
101-
Problem problem = null;
102-
SubmitTransactionAsyncResponse submitTransactionAsyncProblem = null;
137+
if (submitTransactionAsync) {
103138
try {
104-
problem = GsonSingleton.getInstance().fromJson(content, Problem.class);
139+
submitTransactionAsyncProblem =
140+
GsonSingleton.getInstance().fromJson(content, SubmitTransactionAsyncResponse.class);
105141
} catch (Exception e) {
106142
// if we can't parse the response, we just ignore it
107143
}
108-
109-
if (submitTransactionAsync) {
110-
try {
111-
submitTransactionAsyncProblem =
112-
GsonSingleton.getInstance().fromJson(content, SubmitTransactionAsyncResponse.class);
113-
} catch (Exception e) {
114-
// if we can't parse the response, we just ignore it
115-
}
116-
}
117-
118-
if (response.code() == 504) {
119-
throw new RequestTimeoutException(response.code(), content, problem);
120-
} else if (response.code() < 500) {
121-
// Codes in the 4xx range indicate an error that failed given the information provided
122-
throw new BadRequestException(
123-
response.code(), content, problem, submitTransactionAsyncProblem);
124-
} else {
125-
// Codes in the 5xx range indicate an error with the Horizon server.
126-
throw new BadResponseException(
127-
response.code(), content, problem, submitTransactionAsyncProblem);
128-
}
129144
}
130145

131-
throw new UnknownResponseException(response.code(), content);
132-
} finally {
133-
response.close();
146+
if (response.code() == 504) {
147+
throw new RequestTimeoutException(response.code(), content, problem);
148+
} else if (response.code() < 500) {
149+
// Codes in the 4xx range indicate an error that failed given the information provided
150+
throw new BadRequestException(
151+
response.code(), content, problem, submitTransactionAsyncProblem);
152+
} else {
153+
// Codes in the 5xx range indicate an error with the Horizon server.
154+
throw new BadResponseException(
155+
response.code(), content, problem, submitTransactionAsyncProblem);
156+
}
134157
}
158+
159+
throw new UnknownResponseException(response.code(), content);
135160
}
136161
}

0 commit comments

Comments
 (0)