Skip to content

Commit 066486c

Browse files
authored
Return HTTP 400 with informative message for client-side protobuf validation errors (#6989)
When a client sends a protobuf request containing invalid UTF-8 in string fields (e.g., InstrumentationScope name/version), the pipeline currently returns HTTP 500 with a generic error. This change returns HTTP 400 with a client-actionable message explaining the issue. Changes: - Override apply() in GrpcRequestExceptionHandler to intercept InvalidProtocolBufferException before Armeria's default short-circuit - Detect via stable cause-chain type check (not brittle message string) - Apply same fix to HTTP path handlers in otel-logs-source and otel-trace-source - Add wire-level integration test proving HTTP 400 for invalid UTF-8 protobuf - Add test for unsupported grpc-encoding header rejection Signed-off-by: Indraja Babu <indbabu@amazon.com>
1 parent 9ebc07e commit 066486c

6 files changed

Lines changed: 245 additions & 26 deletions

File tree

data-prepper-plugins/armeria-common/src/main/java/org/opensearch/dataprepper/GrpcRequestExceptionHandler.java

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313
import com.linecorp.armeria.common.RequestContext;
1414
import com.linecorp.armeria.common.annotation.Nullable;
1515
import com.linecorp.armeria.common.grpc.GoogleGrpcExceptionHandlerFunction;
16+
import com.linecorp.armeria.common.util.Exceptions;
1617
import com.linecorp.armeria.server.RequestTimeoutException;
1718
import io.grpc.Metadata;
1819
import io.grpc.Status;
19-
import io.grpc.StatusRuntimeException;
2020
import io.micrometer.core.instrument.Counter;
2121

2222
import org.opensearch.dataprepper.exceptions.BadRequestException;
@@ -53,6 +53,25 @@ public GrpcRequestExceptionHandler(final PluginMetrics pluginMetrics, Duration r
5353
retryInfoCalculator = new RetryInfoCalculator(retryInfoMinDelay, retryInfoMaxDelay);
5454
}
5555

56+
@Override
57+
public Status apply(final RequestContext ctx, final Status status, final Throwable throwable, final Metadata metadata) {
58+
// Armeria's GoogleGrpcExceptionHandlerFunction returns the Status of any StatusRuntimeException/
59+
// StatusException verbatim and never invokes applyStatusProto(...) for it. Protobuf deserialization
60+
// failures surface as StatusRuntimeException(INTERNAL, ...) wrapping an InvalidProtocolBufferException,
61+
// so without this interception the client receives an opaque HTTP 500 for what is actually a client-side
62+
// data problem. Remap these to an informative INVALID_ARGUMENT (HTTP 400) before delegating to the default.
63+
final Throwable cause = Exceptions.peel(throwable);
64+
if (InvalidRequestExceptions.isInvalidProtobuf(cause)) {
65+
badRequestsCounter.increment();
66+
return Status.INVALID_ARGUMENT.withDescription(InvalidRequestExceptions.INVALID_PROTOBUF_MESSAGE).withCause(cause);
67+
}
68+
if (InvalidRequestExceptions.isUndecodableCompressedFrame(cause)) {
69+
badRequestsCounter.increment();
70+
return Status.INVALID_ARGUMENT.withDescription(InvalidRequestExceptions.COMPRESSED_FRAME_MESSAGE).withCause(cause);
71+
}
72+
return GoogleGrpcExceptionHandlerFunction.super.apply(ctx, status, throwable, metadata);
73+
}
74+
5675
@Override
5776
public com.google.rpc.@Nullable Status applyStatusProto(RequestContext ctx, Throwable throwable,
5877
Metadata metadata) {
@@ -61,7 +80,6 @@ public GrpcRequestExceptionHandler(final PluginMetrics pluginMetrics, Duration r
6180
}
6281

6382
private com.google.rpc.Status handleExceptions(final Throwable e) {
64-
String message = e.getMessage();
6583
if (e instanceof RequestTimeoutException || e instanceof TimeoutException) {
6684
requestTimeoutsCounter.increment();
6785
return createStatus(e, Status.Code.RESOURCE_EXHAUSTED);
@@ -71,9 +89,6 @@ private com.google.rpc.Status handleExceptions(final Throwable e) {
7189
} else if (e instanceof BadRequestException) {
7290
badRequestsCounter.increment();
7391
return createStatus(e, Status.Code.INVALID_ARGUMENT);
74-
} else if ((e instanceof StatusRuntimeException) && (message.contains("Invalid protobuf byte sequence") || message.contains("Can't decode compressed frame"))) {
75-
badRequestsCounter.increment();
76-
return createStatus(e, Status.Code.INVALID_ARGUMENT);
7792
} else if (e instanceof RequestCancelledException) {
7893
requestTimeoutsCounter.increment();
7994
return createStatus(e, Status.Code.CANCELLED);
@@ -85,12 +100,19 @@ private com.google.rpc.Status handleExceptions(final Throwable e) {
85100
}
86101

87102
private com.google.rpc.Status createStatus(final Throwable e, final Status.Code code) {
88-
com.google.rpc.Status.Builder builder = com.google.rpc.Status.newBuilder().setCode(code.value());
103+
final String message;
89104
if (e instanceof RequestTimeoutException) {
90-
builder.setMessage(ARMERIA_REQUEST_TIMEOUT_MESSAGE);
105+
message = ARMERIA_REQUEST_TIMEOUT_MESSAGE;
91106
} else {
92-
builder.setMessage(e.getMessage() == null ? code.name() :e.getMessage());
107+
message = e.getMessage() == null ? code.name() : e.getMessage();
93108
}
109+
return createStatus(code, message);
110+
}
111+
112+
private com.google.rpc.Status createStatus(final Status.Code code, final String message) {
113+
com.google.rpc.Status.Builder builder = com.google.rpc.Status.newBuilder()
114+
.setCode(code.value())
115+
.setMessage(message);
94116
if (code == Status.Code.RESOURCE_EXHAUSTED) {
95117
builder.addDetails(Any.pack(retryInfoCalculator.createRetryInfo()));
96118
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*
5+
* The OpenSearch Contributors require contributions made to
6+
* this file be licensed under the Apache-2.0 license or a
7+
* compatible open source license.
8+
*/
9+
10+
package org.opensearch.dataprepper;
11+
12+
import com.google.protobuf.InvalidProtocolBufferException;
13+
14+
/**
15+
* Classifies request-level failures that represent client-side data problems (as opposed to server
16+
* errors) so sources can respond with an informative 4xx instead of an opaque 500.
17+
*
18+
* <p>Detection walks the exception cause chain and keys off the stable, public
19+
* {@link InvalidProtocolBufferException} type rather than an Armeria-internal error message, so it does
20+
* not silently regress when the underlying framework rewords its messages.
21+
*/
22+
public final class InvalidRequestExceptions {
23+
24+
/**
25+
* Marker used by Armeria when a compressed request frame cannot be decoded. Decompression failures do
26+
* not wrap an {@link InvalidProtocolBufferException}, so this remains a message-based fallback.
27+
*/
28+
static final String COMPRESSED_FRAME_MARKER = "Can't decode compressed frame";
29+
30+
public static final String INVALID_PROTOBUF_MESSAGE =
31+
"Invalid protobuf byte sequence: the request payload could not be parsed as valid protobuf. "
32+
+ "This is a problem with the client-side request or a misconfiguration of the pipeline source. Protobuf requires every string field to "
33+
+ "contain valid UTF-8. The request likely has a field such as the InstrumentationScope name or version, a resource/scope "
34+
+ "attribute key, or an attribute value that contains non-UTF-8 bytes. Check the instrumentation "
35+
+ "libraries producing this telemetry and correct the source emitting the malformed data.";
36+
37+
public static final String COMPRESSED_FRAME_MESSAGE =
38+
"Unable to decode the compressed request frame. This is a client-side data issue, not a server error. "
39+
+ "Verify that the payload compression (for example, gzip) matches the compression configured on the "
40+
+ "pipeline source and that the payload is not truncated or corrupted.";
41+
42+
private static final int MAX_CAUSE_DEPTH = 20;
43+
44+
private InvalidRequestExceptions() {
45+
}
46+
47+
/**
48+
* @return {@code true} if the throwable, or any cause within it, is a protobuf parse failure such as
49+
* invalid UTF-8 in a string field.
50+
*/
51+
public static boolean isInvalidProtobuf(final Throwable throwable) {
52+
Throwable current = throwable;
53+
for (int depth = 0; current != null && depth < MAX_CAUSE_DEPTH; depth++) {
54+
if (current instanceof InvalidProtocolBufferException) {
55+
return true;
56+
}
57+
if (current.getCause() == current) {
58+
break;
59+
}
60+
current = current.getCause();
61+
}
62+
return false;
63+
}
64+
65+
/**
66+
* @return {@code true} if the throwable, or any cause within it, is a request-frame decompression failure.
67+
*/
68+
public static boolean isUndecodableCompressedFrame(final Throwable throwable) {
69+
Throwable current = throwable;
70+
for (int depth = 0; current != null && depth < MAX_CAUSE_DEPTH; depth++) {
71+
final String message = current.getMessage();
72+
if (message != null && message.contains(COMPRESSED_FRAME_MARKER)) {
73+
return true;
74+
}
75+
if (current.getCause() == current) {
76+
break;
77+
}
78+
current = current.getCause();
79+
}
80+
return false;
81+
}
82+
}

data-prepper-plugins/armeria-common/src/test/java/org/opensearch/dataprepper/GrpcRequestExceptionHandlerTest.java

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,51 @@ public void testHandleRequestCancelledException() {
168168
verify(requestTimeoutsCounter, times(2)).increment();
169169
}
170170

171+
@Test
172+
public void testHandleInvalidProtobufReturnsInformativeBadRequest() {
173+
// Mirrors what Armeria's GrpcMessageMarshaller throws on a protobuf UTF-8 failure:
174+
// Status.INTERNAL.withDescription("Invalid protobuf byte sequence").withCause(InvalidProtocolBufferException).asRuntimeException()
175+
final com.google.protobuf.InvalidProtocolBufferException protobufException =
176+
new com.google.protobuf.InvalidProtocolBufferException("Protocol message had invalid UTF-8.");
177+
final io.grpc.StatusRuntimeException invalidProtobuf = Status.INTERNAL
178+
.withDescription("Invalid protobuf byte sequence")
179+
.withCause(protobufException)
180+
.asRuntimeException();
181+
182+
final Status resultStatus = grpcRequestExceptionHandler.apply(requestContext, Status.INTERNAL, invalidProtobuf, metadata);
183+
184+
assertThat(resultStatus.getCode(), equalTo(Status.Code.INVALID_ARGUMENT));
185+
assertThat(resultStatus.getDescription(), equalTo(InvalidRequestExceptions.INVALID_PROTOBUF_MESSAGE));
186+
187+
verify(badRequestsCounter, times(1)).increment();
188+
}
189+
190+
@Test
191+
public void testHandleCompressedFrameReturnsInformativeBadRequest() {
192+
final io.grpc.StatusRuntimeException compressedFrame =
193+
Status.INTERNAL.withDescription("Can't decode compressed frame").asRuntimeException();
194+
195+
final Status resultStatus = grpcRequestExceptionHandler.apply(requestContext, Status.INTERNAL, compressedFrame, metadata);
196+
197+
assertThat(resultStatus.getCode(), equalTo(Status.Code.INVALID_ARGUMENT));
198+
assertThat(resultStatus.getDescription(), equalTo(InvalidRequestExceptions.COMPRESSED_FRAME_MESSAGE));
199+
200+
verify(badRequestsCounter, times(1)).increment();
201+
}
202+
203+
@Test
204+
public void testHandleGenericStatusRuntimeExceptionPassesThroughUnchanged() {
205+
// A StatusRuntimeException that is NOT a client-data problem must retain its original status,
206+
// matching Armeria's default short-circuit behavior.
207+
final io.grpc.StatusRuntimeException generic =
208+
Status.INTERNAL.withDescription("some internal failure").asRuntimeException();
209+
210+
final Status resultStatus = grpcRequestExceptionHandler.apply(requestContext, Status.INTERNAL, generic, metadata);
211+
212+
assertThat(resultStatus.getCode(), equalTo(Status.Code.INTERNAL));
213+
verify(badRequestsCounter, times(0)).increment();
214+
}
215+
171216
@Test
172217
public void testHandleInternalServerException() {
173218
final RuntimeException runtimeExceptionNoMessage = new RuntimeException();

data-prepper-plugins/otel-logs-source/src/main/java/org/opensearch/dataprepper/plugins/source/otellogs/http/HttpExceptionHandler.java

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import java.time.Duration;
1515
import java.util.concurrent.TimeoutException;
1616

17+
import org.opensearch.dataprepper.InvalidRequestExceptions;
1718
import org.opensearch.dataprepper.RetryInfoCalculator;
1819
import org.opensearch.dataprepper.exceptions.BadRequestException;
1920
import org.opensearch.dataprepper.exceptions.BufferWriteException;
@@ -38,7 +39,6 @@
3839
import com.linecorp.armeria.server.annotation.ExceptionHandlerFunction;
3940

4041
import io.grpc.Status;
41-
import io.grpc.StatusRuntimeException;
4242
import io.micrometer.core.instrument.Counter;
4343

4444
public class HttpExceptionHandler implements ExceptionHandlerFunction {
@@ -101,9 +101,14 @@ private StatusHolder createStatus(Throwable e) {
101101
} else if (e instanceof BadRequestException) {
102102
badRequestsCounter.increment();
103103
return new StatusHolder(createStatus(e, Status.Code.INVALID_ARGUMENT), createHttpStatusFromProtoBufStatus(Status.Code.INVALID_ARGUMENT));
104-
} else if ((e instanceof StatusRuntimeException) && (e.getMessage().contains("Invalid protobuf byte sequence") || e.getMessage().contains("Can't decode compressed frame"))) {
104+
} else if (InvalidRequestExceptions.isInvalidProtobuf(e)) {
105105
badRequestsCounter.increment();
106-
return new StatusHolder(createStatus(e, Status.Code.INVALID_ARGUMENT), createHttpStatusFromProtoBufStatus(Status.Code.INVALID_ARGUMENT));
106+
return new StatusHolder(createStatus(Status.Code.INVALID_ARGUMENT, InvalidRequestExceptions.INVALID_PROTOBUF_MESSAGE),
107+
createHttpStatusFromProtoBufStatus(Status.Code.INVALID_ARGUMENT));
108+
} else if (InvalidRequestExceptions.isUndecodableCompressedFrame(e)) {
109+
badRequestsCounter.increment();
110+
return new StatusHolder(createStatus(Status.Code.INVALID_ARGUMENT, InvalidRequestExceptions.COMPRESSED_FRAME_MESSAGE),
111+
createHttpStatusFromProtoBufStatus(Status.Code.INVALID_ARGUMENT));
107112
} else if (e instanceof RequestCancelledException) {
108113
requestTimeoutsCounter.increment();
109114
return new StatusHolder(createStatus(e, Status.Code.CANCELLED), createHttpStatusFromProtoBufStatus(Status.Code.CANCELLED));
@@ -125,12 +130,16 @@ private HttpStatus createHttpStatusFromProtoBufStatus(Status.Code status) {
125130
}
126131

127132
private com.google.rpc.Status createStatus(final Throwable e, final Status.Code code) {
128-
com.google.rpc.Status.Builder builder = com.google.rpc.Status.newBuilder().setCode(code.value());
129-
if (e instanceof RequestTimeoutException) {
130-
builder.setMessage(ARMERIA_REQUEST_TIMEOUT_MESSAGE);
131-
} else {
132-
builder.setMessage(e.getMessage() == null ? code.name() :e.getMessage());
133-
}
133+
final String message = e instanceof RequestTimeoutException
134+
? ARMERIA_REQUEST_TIMEOUT_MESSAGE
135+
: (e.getMessage() == null ? code.name() : e.getMessage());
136+
return createStatus(code, message);
137+
}
138+
139+
private com.google.rpc.Status createStatus(final Status.Code code, final String message) {
140+
com.google.rpc.Status.Builder builder = com.google.rpc.Status.newBuilder()
141+
.setCode(code.value())
142+
.setMessage(message);
134143
if (code == Status.Code.RESOURCE_EXHAUSTED) {
135144
builder.addDetails(Any.pack(retryInfoCalculator.createRetryInfo()));
136145
}

data-prepper-plugins/otel-logs-source/src/test/java/org/opensearch/dataprepper/plugins/source/otellogs/OTelLogsSourceUnframedRequestTest.java

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010

1111
package org.opensearch.dataprepper.plugins.source.otellogs;
1212

13+
import static com.linecorp.armeria.common.HttpStatus.BAD_REQUEST;
1314
import static com.linecorp.armeria.common.HttpStatus.OK;
1415
import static com.linecorp.armeria.common.HttpStatus.UNSUPPORTED_MEDIA_TYPE;
1516
import static org.hamcrest.MatcherAssert.assertThat;
17+
import static org.hamcrest.Matchers.containsString;
1618
import static org.hamcrest.Matchers.equalTo;
1719
import static org.hamcrest.Matchers.hasItem;
1820
import static org.hamcrest.Matchers.is;
@@ -169,6 +171,56 @@ void unframedRequests_unframeRequestsAreDisabledAndHttpRequestIsSentToGrpcEndpoi
169171
.join();
170172
}
171173

174+
@Test
175+
void unframedRequests_protobufWithInvalidUtf8_returns400WithInformativeMessage() {
176+
configureSource(createDefaultConfigBuilder().enableUnframedRequests(true).build());
177+
SOURCE.start(buffer);
178+
179+
// Hand-crafted ExportLogsServiceRequest wire bytes whose nested InstrumentationScope.name (field 1)
180+
// contains a lone 0xFF byte, which is invalid UTF-8. Protobuf's String fields require valid UTF-8, so
181+
// deserialization fails exactly as it did for the customer. Structure:
182+
// ExportLogsServiceRequest{ resource_logs[0]{ scope_logs[0]{ scope{ name = 0xFF } } } }
183+
final byte[] invalidUtf8Protobuf = new byte[] {
184+
0x0A, 0x07, // ExportLogsServiceRequest.resource_logs (field 1, len 7)
185+
0x12, 0x05, // ResourceLogs.scope_logs (field 2, len 5)
186+
0x0A, 0x03, // ScopeLogs.scope (field 1, len 3)
187+
0x0A, 0x01, (byte) 0xFF // InstrumentationScope.name (field 1, len 1) = 0xFF (invalid UTF-8)
188+
};
189+
190+
final AggregatedHttpResponse response = WebClient.of().execute(getDefaultRequestHeadersBuilder()
191+
.path(CONFIG_GRPC_PATH)
192+
.contentType(MediaType.PROTOBUF)
193+
.build(),
194+
HttpData.wrap(invalidUtf8Protobuf))
195+
.aggregate()
196+
.join();
197+
198+
assertThat("Invalid UTF-8 protobuf must be rejected as a client error, not a 500",
199+
response.status(), equalTo(BAD_REQUEST));
200+
final String grpcMessage = response.headers().get("grpc-message");
201+
final String detail = grpcMessage != null ? grpcMessage : response.contentUtf8();
202+
assertThat("Client should receive an informative, client-side-oriented message",
203+
detail, containsString("contain valid UTF-8"));
204+
}
205+
206+
@Test
207+
void unframedRequests_requestWithUnsupportedGrpcEncoding_isRejected() {
208+
configureSource(createDefaultConfigBuilder().enableUnframedRequests(true).build());
209+
SOURCE.start(buffer);
210+
211+
final AggregatedHttpResponse response = WebClient.of().execute(getDefaultRequestHeadersBuilder()
212+
.path(CONFIG_GRPC_PATH)
213+
.contentType(MediaType.PROTOBUF)
214+
.add("grpc-encoding", "gzip")
215+
.build(),
216+
HttpData.wrap(new byte[] {0x00}))
217+
.aggregate()
218+
.join();
219+
220+
assertThat("A request the pipeline source cannot decode must not be a 500",
221+
response.status().isServerError(), equalTo(false));
222+
}
223+
172224
private void assertSecureResponseWithStatusCode(final AggregatedHttpResponse response,
173225
final HttpStatus expectedStatus,
174226
final Throwable throwable) {

0 commit comments

Comments
 (0)