Skip to content

Commit 0bde383

Browse files
jkebingerclaude
andauthored
Security: Add zero-byte config data rejection (#12)
* Add zero-byte config data rejection for security Implement comprehensive zero-byte protobuf rejection across all config loading mechanisms to prevent potential security issues from malformed data: Security Features: - Reject zero-byte config data from HTTP responses with IllegalArgumentException - Reject zero-byte config data from cache with IllegalArgumentException - Ignore zero-byte config data from SSE streams with warning logs - Exceptions trigger internal retry logic without propagating to embedding applications Implementation Details: - HttpClient: Added zero-byte validation in requestConfigsFromURI() for HTTP responses and cached data - SseConfigStreamingSubscriber: Added zero-byte detection in FlowSubscriber.onNext() for SSE streams - All rejections logged with appropriate warning messages for debugging Test Coverage: - 4 new HttpClient tests covering zero-byte rejection from HTTP responses, cache, and valid config processing - 5 new SseConfigStreamingSubscriber tests covering zero-byte SSE data, empty payloads, and valid processing - Fixed existing tests to use valid non-zero protobuf data instead of empty default instances 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Bump version to 0.3.27 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Update child module versions to 0.3.27 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 76e528f commit 0bde383

File tree

7 files changed

+423
-14
lines changed

7 files changed

+423
-14
lines changed

micronaut/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<parent>
55
<groupId>com.reforge</groupId>
66
<artifactId>sdk-parent</artifactId>
7-
<version>0.3.26</version>
7+
<version>0.3.27</version>
88
</parent>
99

1010
<artifactId>sdk-micronaut-extension</artifactId>

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<groupId>com.reforge</groupId>
1212
<artifactId>sdk-parent</artifactId>
1313

14-
<version>0.3.26</version>
14+
<version>0.3.27</version>
1515
<packaging>pom</packaging>
1616
<name>Reforge SDK Parent POM</name>
1717
<description>Parent POM for Reforge SDK modules providing feature flags, configuration management, and A/B testing capabilities</description>

sdk/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<parent>
55
<groupId>com.reforge</groupId>
66
<artifactId>sdk-parent</artifactId>
7-
<version>0.3.26</version>
7+
<version>0.3.27</version>
88
</parent>
99

1010
<artifactId>sdk</artifactId>

sdk/src/main/java/com/reforge/sdk/internal/HttpClient.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,10 @@ private CompletableFuture<HttpResponse<Supplier<Prefab.Configs>>> requestConfigs
275275
// Build a synthetic response for the 200 case.
276276
Supplier<Prefab.Configs> supplier = () -> {
277277
try {
278+
if (bodyBytes.length == 0) {
279+
LOG.warn("Rejecting zero-byte config data from HTTP response");
280+
throw new IllegalArgumentException("Zero-byte config data is not valid");
281+
}
278282
return Prefab.Configs.parseFrom(bodyBytes);
279283
} catch (IOException e) {
280284
throw new UncheckedIOException(e);
@@ -287,6 +291,10 @@ private CompletableFuture<HttpResponse<Supplier<Prefab.Configs>>> requestConfigs
287291
// For other status codes, simply wrap the response.
288292
Supplier<Prefab.Configs> supplier = () -> {
289293
try (ByteArrayInputStream bais = new ByteArrayInputStream(response.body())) {
294+
if (response.body().length == 0) {
295+
LOG.warn("Rejecting zero-byte config data from HTTP response");
296+
throw new IllegalArgumentException("Zero-byte config data is not valid");
297+
}
290298
return Prefab.Configs.parseFrom(bais);
291299
} catch (IOException e) {
292300
throw new UncheckedIOException(e);
@@ -324,6 +332,10 @@ private HttpResponse<Supplier<Prefab.Configs>> createCachedHitResponse(
324332
) {
325333
Supplier<Prefab.Configs> supplier = () -> {
326334
try {
335+
if (entry.data.length == 0) {
336+
LOG.warn("Rejecting zero-byte config data from cache");
337+
throw new IllegalArgumentException("Zero-byte config data is not valid");
338+
}
327339
return Prefab.Configs.parseFrom(entry.data);
328340
} catch (IOException e) {
329341
throw new UncheckedIOException(e);

sdk/src/main/java/com/reforge/sdk/internal/SseConfigStreamingSubscriber.java

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,16 @@ public void onNext(Event item) {
117117
hasReceivedData.set(true);
118118
String dataPayload = dataEvent.getData().trim();
119119
if (!dataPayload.isEmpty()) {
120-
Prefab.Configs configs = Prefab.Configs.parseFrom(
121-
Base64.getDecoder().decode(dataPayload)
122-
);
123-
if (!configs.hasConfigServicePointer()) {
124-
LOG.debug("Ignoring empty config keep-alive");
120+
byte[] decodedData = Base64.getDecoder().decode(dataPayload);
121+
if (decodedData.length == 0) {
122+
LOG.warn("Ignoring zero-byte config data from SSE stream");
125123
} else {
126-
configConsumer.accept(configs);
124+
Prefab.Configs configs = Prefab.Configs.parseFrom(decodedData);
125+
if (!configs.hasConfigServicePointer()) {
126+
LOG.debug("Ignoring empty config keep-alive");
127+
} else {
128+
configConsumer.accept(configs);
129+
}
127130
}
128131
}
129132
} catch (InvalidProtocolBufferException e) {

sdk/src/test/java/com/reforge/sdk/internal/HttpClientTest.java

Lines changed: 196 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,12 @@ void setup() {
4848
@Test
4949
void testFailoverForConfigFetch() throws Exception {
5050
// Use byte[]–based mocks since requestConfigsFromURI uses BodyHandlers.ofByteArray().
51-
Prefab.Configs dummyConfigs = Prefab.Configs.newBuilder().build();
51+
Prefab.Configs dummyConfigs = Prefab.Configs
52+
.newBuilder()
53+
.setConfigServicePointer(
54+
Prefab.ConfigServicePointer.newBuilder().setProjectId(123L)
55+
)
56+
.build();
5257
byte[] dummyBytes = dummyConfigs.toByteArray();
5358

5459
HttpResponse<byte[]> failureResponse = mock(HttpResponse.class);
@@ -128,7 +133,12 @@ void testFailoverForSSEConnection() throws Exception {
128133

129134
@Test
130135
void testBasicCaching() throws Exception {
131-
Prefab.Configs dummyConfigs = Prefab.Configs.newBuilder().build();
136+
Prefab.Configs dummyConfigs = Prefab.Configs
137+
.newBuilder()
138+
.setConfigServicePointer(
139+
Prefab.ConfigServicePointer.newBuilder().setProjectId(123L)
140+
)
141+
.build();
132142
byte[] dummyBytes = dummyConfigs.toByteArray();
133143

134144
HttpResponse<byte[]> httpResponse200 = mock(HttpResponse.class);
@@ -175,7 +185,12 @@ void testBasicCaching() throws Exception {
175185
@Test
176186
void testConditionalGet304() throws Exception {
177187
// In order to trigger a conditional GET, we insert a cached entry that is expired.
178-
Prefab.Configs dummyConfigs = Prefab.Configs.newBuilder().build();
188+
Prefab.Configs dummyConfigs = Prefab.Configs
189+
.newBuilder()
190+
.setConfigServicePointer(
191+
Prefab.ConfigServicePointer.newBuilder().setProjectId(123L)
192+
)
193+
.build();
179194
byte[] dummyBytes = dummyConfigs.toByteArray();
180195
// Use a time far enough in the past to ensure expiration.
181196
long past = System.currentTimeMillis() - 10_000;
@@ -225,7 +240,12 @@ void testConditionalGet304() throws Exception {
225240

226241
@Test
227242
void testClearCache() throws Exception {
228-
Prefab.Configs dummyConfigs = Prefab.Configs.newBuilder().build();
243+
Prefab.Configs dummyConfigs = Prefab.Configs
244+
.newBuilder()
245+
.setConfigServicePointer(
246+
Prefab.ConfigServicePointer.newBuilder().setProjectId(123L)
247+
)
248+
.build();
229249
byte[] dummyBytes = dummyConfigs.toByteArray();
230250

231251
HttpResponse<byte[]> httpResponse200 = mock(HttpResponse.class);
@@ -280,7 +300,12 @@ void testClearCache() throws Exception {
280300
@Test
281301
void testNoCacheResponseAlwaysRevalidates() throws Exception {
282302
// Create a valid Prefab.Configs instance and its serialized form.
283-
Prefab.Configs dummyConfigs = Prefab.Configs.newBuilder().build();
303+
Prefab.Configs dummyConfigs = Prefab.Configs
304+
.newBuilder()
305+
.setConfigServicePointer(
306+
Prefab.ConfigServicePointer.newBuilder().setProjectId(123L)
307+
)
308+
.build();
284309
byte[] dummyBytes = dummyConfigs.toByteArray();
285310

286311
// Simulate a 200 response with Cache-Control: no-cache and an ETag.
@@ -365,4 +390,170 @@ void testNoCacheResponseAlwaysRevalidates() throws Exception {
365390
assertThat(sentRequest.headers().firstValue("If-None-Match"))
366391
.contains("etag-no-cache");
367392
}
393+
394+
@Test
395+
void testZeroByteConfigRejectionFromHttpResponse() throws Exception {
396+
// Mock a 200 response that returns zero bytes
397+
byte[] zeroBytes = new byte[0];
398+
HttpResponse<byte[]> zeroByteResponse = mock(HttpResponse.class);
399+
when(zeroByteResponse.statusCode()).thenReturn(200);
400+
when(zeroByteResponse.body()).thenReturn(zeroBytes);
401+
when(zeroByteResponse.headers()).thenReturn(HttpHeaders.of(Map.of(), (k, v) -> true));
402+
403+
CompletableFuture<HttpResponse<byte[]>> futureZeroBytes = CompletableFuture.completedFuture(
404+
zeroByteResponse
405+
);
406+
when(
407+
mockHttpClient.sendAsync(
408+
any(HttpRequest.class),
409+
any(HttpResponse.BodyHandler.class)
410+
)
411+
)
412+
.thenReturn(futureZeroBytes);
413+
414+
// Request configs - this should eventually fail after retries
415+
CompletableFuture<HttpResponse<Supplier<Prefab.Configs>>> result = prefabHttpClient.requestConfigs(
416+
0L
417+
);
418+
419+
HttpResponse<Supplier<Prefab.Configs>> response = result.get();
420+
assertThat(response.statusCode()).isEqualTo(200);
421+
422+
// Try to get the body - this should throw IllegalArgumentException
423+
try {
424+
response.body().get();
425+
assertThat(false)
426+
.as("Expected IllegalArgumentException for zero-byte config")
427+
.isTrue();
428+
} catch (IllegalArgumentException e) {
429+
// Should get IllegalArgumentException from zero-byte rejection
430+
assertThat(e.getMessage()).contains("Zero-byte config data is not valid");
431+
}
432+
}
433+
434+
@Test
435+
void testZeroByteConfigRejectionFromNon200Response() throws Exception {
436+
// Mock a 404 response that returns zero bytes
437+
byte[] zeroBytes = new byte[0];
438+
HttpResponse<byte[]> zeroByteResponse = mock(HttpResponse.class);
439+
when(zeroByteResponse.statusCode()).thenReturn(404);
440+
when(zeroByteResponse.body()).thenReturn(zeroBytes);
441+
when(zeroByteResponse.headers()).thenReturn(HttpHeaders.of(Map.of(), (k, v) -> true));
442+
443+
CompletableFuture<HttpResponse<byte[]>> futureZeroBytes = CompletableFuture.completedFuture(
444+
zeroByteResponse
445+
);
446+
when(
447+
mockHttpClient.sendAsync(
448+
any(HttpRequest.class),
449+
any(HttpResponse.BodyHandler.class)
450+
)
451+
)
452+
.thenReturn(futureZeroBytes);
453+
454+
// Request configs - this should eventually fail after retries
455+
CompletableFuture<HttpResponse<Supplier<Prefab.Configs>>> result = prefabHttpClient.requestConfigs(
456+
0L
457+
);
458+
459+
HttpResponse<Supplier<Prefab.Configs>> response = result.get();
460+
assertThat(response.statusCode()).isEqualTo(404);
461+
462+
// Try to get the body - this should throw IllegalArgumentException
463+
try {
464+
response.body().get();
465+
assertThat(false)
466+
.as("Expected IllegalArgumentException for zero-byte config")
467+
.isTrue();
468+
} catch (IllegalArgumentException e) {
469+
// Should get IllegalArgumentException from zero-byte rejection
470+
assertThat(e.getMessage()).contains("Zero-byte config data is not valid");
471+
}
472+
}
473+
474+
@Test
475+
void testZeroByteConfigRejectionFromCache() throws Exception {
476+
// Insert zero-byte data directly into cache
477+
URI uri = URI.create("http://a.example.com/api/v2/configs/0");
478+
Field cacheField = HttpClient.class.getDeclaredField("configCache");
479+
cacheField.setAccessible(true);
480+
@SuppressWarnings("unchecked")
481+
Cache<URI, HttpClient.CacheEntry> cache = (Cache<URI, HttpClient.CacheEntry>) cacheField.get(
482+
prefabHttpClient
483+
);
484+
485+
// Create a cache entry with zero-byte data that is still fresh
486+
long future = System.currentTimeMillis() + 60_000;
487+
HttpClient.CacheEntry zeroByteCacheEntry = new HttpClient.CacheEntry(
488+
new byte[0],
489+
"zero-byte-etag",
490+
future
491+
);
492+
cache.put(uri, zeroByteCacheEntry);
493+
494+
// Request configs - should return cached response but fail when accessing body
495+
CompletableFuture<HttpResponse<Supplier<Prefab.Configs>>> result = prefabHttpClient.requestConfigs(
496+
0L
497+
);
498+
499+
HttpResponse<Supplier<Prefab.Configs>> response = result.get();
500+
assertThat(response.statusCode()).isEqualTo(200);
501+
assertThat(response.headers().firstValue("X-Cache")).contains("HIT");
502+
503+
// Try to get the body - this should throw IllegalArgumentException
504+
try {
505+
response.body().get();
506+
assertThat(false)
507+
.as("Expected IllegalArgumentException for zero-byte cached config")
508+
.isTrue();
509+
} catch (IllegalArgumentException e) {
510+
// Should get IllegalArgumentException from zero-byte rejection
511+
assertThat(e.getMessage()).contains("Zero-byte config data is not valid");
512+
}
513+
}
514+
515+
@Test
516+
void testValidConfigProcessingAfterZeroByteRejectionImplementation() throws Exception {
517+
// This test verifies that valid configs can still be processed normally
518+
// even when zero-byte rejection is in place
519+
520+
Prefab.Configs validConfigs = Prefab.Configs
521+
.newBuilder()
522+
.setConfigServicePointer(
523+
Prefab.ConfigServicePointer.newBuilder().setProjectId(456L)
524+
)
525+
.build();
526+
byte[] validBytes = validConfigs.toByteArray();
527+
HttpResponse<byte[]> validResponse = mock(HttpResponse.class);
528+
when(validResponse.statusCode()).thenReturn(200);
529+
when(validResponse.body()).thenReturn(validBytes);
530+
when(validResponse.headers()).thenReturn(HttpHeaders.of(Map.of(), (k, v) -> true));
531+
532+
CompletableFuture<HttpResponse<byte[]>> futureValidBytes = CompletableFuture.completedFuture(
533+
validResponse
534+
);
535+
536+
when(
537+
mockHttpClient.sendAsync(
538+
any(HttpRequest.class),
539+
any(HttpResponse.BodyHandler.class)
540+
)
541+
)
542+
.thenReturn(futureValidBytes);
543+
544+
// Request configs - should succeed with valid data
545+
CompletableFuture<HttpResponse<Supplier<Prefab.Configs>>> result = prefabHttpClient.requestConfigs(
546+
0L
547+
);
548+
549+
HttpResponse<Supplier<Prefab.Configs>> response = result.get();
550+
assertThat(response.statusCode()).isEqualTo(200);
551+
552+
// Should be able to get valid configs without exception
553+
Prefab.Configs configs = response.body().get();
554+
assertThat(configs).isEqualTo(validConfigs);
555+
556+
// Should have called sendAsync once
557+
verify(mockHttpClient, times(1)).sendAsync(any(), any());
558+
}
368559
}

0 commit comments

Comments
 (0)