Skip to content

Commit 76f630d

Browse files
committed
xds: fail closed when ext_authz response processing throws
CheckResponseHandler.handleResponse() only catches the checked HeaderMutationDisallowedException. An unchecked exception raised while processing a CheckResponse -- for example HeaderValue.create() rejecting a header value that is not valid ASCII -- escaped AuthzCallbackObserver.onNext(). gRPC then cancelled the stream and invoked onError(), where failure_mode_allow sent the request to the backend, turning an explicit PERMISSION_DENIED into an ALLOW. failure_mode_allow covers the authorization service being unreachable or returning an error. It does not cover a failure to process a response that the service successfully returned, so onNext() now handles its own failures and fails the call with INTERNAL instead of letting them reach onError(). Also fail the RPC, rather than silently dropping the mutation, when the authz server attempts to mutate a gRPC-owned header.
1 parent 5c012d2 commit 76f630d

4 files changed

Lines changed: 89 additions & 14 deletions

File tree

‎xds/src/main/java/io/grpc/xds/internal/extauthz/AuthzCallbackObserver.java‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,17 @@ final class AuthzCallbackObserver<ReqT, RespT> implements StreamObserver<CheckRe
7474

7575
@Override
7676
public void onNext(CheckResponse value) {
77-
// Note: This implementation is currently exception-safe.
78-
//
79-
// TODO(sauravz): Revisit hardening if this invariant changes in the future.
80-
// If an unhandled RuntimeException escapes onNext(), gRPC cancels the stream and
81-
// invokes onError(). Under failure_mode_allow: true, this causes the call to fail
82-
// open, which could inadvertently permit an unauthorized request.
77+
try {
78+
handleCheckResponse(value);
79+
} catch (RuntimeException e) {
80+
// A processing failure is not an authz communication failure, so failure_mode_allow
81+
// must not apply here.
82+
setCallAndDrain(new FailingClientCall<>(
83+
Status.INTERNAL.withCause(e).withDescription("Failed to process authz response")));
84+
}
85+
}
86+
87+
private void handleCheckResponse(CheckResponse value) {
8388
AuthzResponse authzResponse = responseHandler.handleResponse(value);
8489
if (authzResponse.decision() == AuthzResponse.Decision.ALLOW) {
8590
ClientCall<ReqT, RespT> delegate = next.newCall(method, callOptions);

‎xds/src/main/java/io/grpc/xds/internal/extauthz/CheckResponseHandler.java‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,10 @@ private ImmutableList<HeaderValueOption> convertHeaders(
121121
} else {
122122
internalHeader = HeaderValue.create(key, header.getValue());
123123
}
124+
// TODO(sauravzg): Confirm failing the RPC is correct for gRPC-owned headers.
124125
if (HeaderValueValidationUtils.isDisallowed(internalHeader)) {
125-
continue;
126+
throw new HeaderMutationDisallowedException(
127+
"Header mutation disallowed for gRPC-owned key: " + key);
126128
}
127129
HeaderValueOption.HeaderAppendAction action;
128130
switch (optionProto.getAppendAction()) {

‎xds/src/test/java/io/grpc/xds/internal/extauthz/AuthzCallbackObserverTest.java‎

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -585,8 +585,8 @@ public void allow_withHeadersToRemoveOnly_backendReceivesMutatedHeaders() {
585585
assertThat(capturedBackendMessage).isEqualTo(request);
586586
}
587587

588-
@Test(expected = IllegalArgumentException.class)
589-
public void deny_withMissingStatus_throwsIllegalArgumentException() {
588+
@Test
589+
public void deny_withMissingStatus_failsCallWithInternal() {
590590
CheckResponseHandler mockHandler = mock(CheckResponseHandler.class);
591591
AuthzResponse fakeAuthzResponse = new AuthzResponse() {
592592
@Override
@@ -621,8 +621,13 @@ public HeaderMutations responseHeaderMutations() {
621621
CallOptions.DEFAULT,
622622
MoreExecutors.directExecutor(),
623623
mockHandler, failClosedConfig(), authzCtx);
624+
CapturingListener<SimpleResponse> listener = new CapturingListener<>();
625+
delayedCall.start(listener, new Metadata());
626+
delayedCall.request(1);
624627

625628
observer.onNext(CheckResponse.getDefaultInstance());
629+
630+
assertThat(listener.getCloseStatus().getCode()).isEqualTo(Status.Code.INTERNAL);
626631
}
627632

628633
@Test
@@ -693,6 +698,55 @@ public void allow_whenDelayedCallNotStarted_setCallReturnsNull() {
693698
}
694699

695700

701+
@Test
702+
public void deny_withMalformedHeader_failOpen_doesNotReachBackend() {
703+
capturedBackendHeaders = null;
704+
capturedBackendMessage = null;
705+
doAnswer(invocation -> {
706+
StreamObserver<CheckResponse> obs = invocation.getArgument(1);
707+
obs.onNext(CheckResponse.newBuilder()
708+
.setStatus(com.google.rpc.Status.newBuilder()
709+
.setCode(com.google.rpc.Code.PERMISSION_DENIED_VALUE))
710+
.setDeniedResponse(DeniedHttpResponse.newBuilder()
711+
.setStatus(HttpStatus.newBuilder().setCode(StatusCode.Forbidden))
712+
.addHeaders(HeaderValueOption.newBuilder()
713+
.setHeader(HeaderValue.newBuilder().setKey("x-deny-reason")
714+
.setValue("policy\nviolation"))))
715+
.build());
716+
obs.onCompleted();
717+
return null;
718+
}).when(authzService).check(any(), any());
719+
720+
TestDelayedCall<SimpleRequest, SimpleResponse> delayedCall =
721+
new TestDelayedCall<>(MoreExecutors.directExecutor(), scheduler, null);
722+
Context.CancellableContext authzCtx = Context.current().withCancellation();
723+
AuthzCallbackObserver<SimpleRequest, SimpleResponse> observer =
724+
new AuthzCallbackObserver<>(
725+
delayedCall, channel,
726+
SimpleServiceGrpc.getUnaryRpcMethod(),
727+
CallOptions.DEFAULT,
728+
MoreExecutors.directExecutor(),
729+
responseHandler,
730+
failOpenConfig(/*headerAdd=*/false), authzCtx);
731+
732+
SimpleRequest request =
733+
SimpleRequest.newBuilder().setRequestMessage("malformed-header-payload").build();
734+
CapturingListener<SimpleResponse> listener = new CapturingListener<>();
735+
delayedCall.start(listener, new Metadata());
736+
delayedCall.sendMessage(request);
737+
delayedCall.halfClose();
738+
delayedCall.request(1);
739+
740+
authzCtx.run(() -> {
741+
AuthorizationGrpc.newStub(channel)
742+
.check(CheckRequest.getDefaultInstance(), observer);
743+
});
744+
745+
assertThat(capturedBackendHeaders).isNull();
746+
assertThat(capturedBackendMessage).isNull();
747+
assertThat(listener.getCloseStatus().getCode()).isEqualTo(Status.Code.INTERNAL);
748+
}
749+
696750
private static final class TestDelayedCall<ReqT, RespT>
697751
extends DelayedClientCall<ReqT, RespT> {
698752
TestDelayedCall(

‎xds/src/test/java/io/grpc/xds/internal/extauthz/CheckResponseHandlerTest.java‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -213,12 +213,10 @@ public void handleResponse_okWithDisallowedMutation() throws HeaderMutationDisal
213213
}
214214

215215
@Test
216-
public void handleResponse_ok_binaryHeadersPreservedAndDisallowedHeadersDropped() {
216+
public void handleResponse_ok_binaryHeadersPreserved() {
217217
HeaderValueOption binaryOption =
218218
HeaderValueOption.newBuilder().setHeader(HeaderValue.newBuilder().setKey("test-bin")
219219
.setRawValue(com.google.protobuf.ByteString.copyFromUtf8("test"))).build();
220-
HeaderValueOption disallowedOption = HeaderValueOption.newBuilder()
221-
.setHeader(HeaderValue.newBuilder().setKey("host").setValue("disallowed")).build();
222220

223221
io.grpc.xds.internal.headermutations.HeaderValueOption expectedBinaryOption =
224222
io.grpc.xds.internal.headermutations.HeaderValueOption.create(
@@ -228,8 +226,7 @@ public void handleResponse_ok_binaryHeadersPreservedAndDisallowedHeadersDropped(
228226

229227
CheckResponse checkResponse = CheckResponse.newBuilder()
230228
.setStatus(com.google.rpc.Status.newBuilder().setCode(Code.OK_VALUE).build())
231-
.setOkResponse(OkHttpResponse.newBuilder().addHeaders(binaryOption)
232-
.addHeaders(disallowedOption).build())
229+
.setOkResponse(OkHttpResponse.newBuilder().addHeaders(binaryOption).build())
233230
.build();
234231
AuthzResponse authzResponse = responseHandler.handleResponse(checkResponse);
235232

@@ -240,6 +237,23 @@ public void handleResponse_ok_binaryHeadersPreservedAndDisallowedHeadersDropped(
240237
assertThat(authzResponse.requestHeaderMutations()).isEqualTo(expectedRequestMutations);
241238
}
242239

240+
@Test
241+
public void handleResponse_ok_grpcOwnedHeader_deniesCall() {
242+
HeaderValueOption disallowedOption = HeaderValueOption.newBuilder()
243+
.setHeader(HeaderValue.newBuilder().setKey("host").setValue("disallowed")).build();
244+
245+
CheckResponse checkResponse = CheckResponse.newBuilder()
246+
.setStatus(com.google.rpc.Status.newBuilder().setCode(Code.OK_VALUE).build())
247+
.setOkResponse(OkHttpResponse.newBuilder().addHeaders(disallowedOption).build())
248+
.build();
249+
AuthzResponse authzResponse = responseHandler.handleResponse(checkResponse);
250+
251+
assertThat(authzResponse.decision()).isEqualTo(Decision.DENY);
252+
assertThat(authzResponse.status().get().getCode()).isEqualTo(Status.INTERNAL.getCode());
253+
assertThat(authzResponse.status().get().getDescription())
254+
.contains("Header mutation disallowed for gRPC-owned key: host");
255+
}
256+
243257
@Test
244258
public void handleResponse_ok_invalidAppendAction_deniesCall() {
245259
HeaderValueOption invalidActionOption = HeaderValueOption.newBuilder()

0 commit comments

Comments
 (0)