Skip to content

Commit

Permalink
Be more resilient to broken deployments (#460)
Browse files Browse the repository at this point in the history
Require 200 HTTP status and a supported Content-Type
header to be present in a response.

When handling malformed responses make effort
to translate HTTP statuses into gRPC statuses as
gRPC protocol specification recommends.

Fixes #421
Fixes #458 

Co-authored-by: Vyacheslav Egorov <vegorov@google.com>
  • Loading branch information
LeFrosch and mraleph committed Mar 22, 2021
1 parent fb0c27a commit 6c16fce
Show file tree
Hide file tree
Showing 13 changed files with 448 additions and 133 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
## 3.0.1-dev

* Require `package:googleapis_auth` `^1.1.0`
* Fix issues [#421](https://github.com/grpc/grpc-dart/issues/421) and
[#458](https://github.com/grpc/grpc-dart/issues/458). Validate
responses according to gRPC/gRPC-Web protocol specifications: require
200 HTTP status and a supported `Content-Type` header to be present, as well
as `grpc-status: 0` header. When handling malformed responses make effort
to translate HTTP statuses into gRPC statuses.

## 3.0.0

Expand Down
48 changes: 5 additions & 43 deletions lib/src/client/call.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,8 @@
// limitations under the License.

import 'dart:async';
import 'dart:convert';
import 'dart:developer';

import 'package:grpc/src/generated/google/rpc/status.pb.dart';
import 'package:meta/meta.dart';
import 'package:protobuf/protobuf.dart';

import '../shared/codec.dart';
import '../shared/message.dart';
import '../shared/profiler.dart';
Expand All @@ -38,7 +33,6 @@ const _reservedHeaders = [
'grpc-encoding',
'user-agent',
];
const _statusDetailsHeader = 'grpc-status-details-bin';

/// Provides per-RPC metadata.
///
Expand Down Expand Up @@ -343,23 +337,11 @@ class ClientCall<Q, R> implements Response {
_stream!.terminate();
}

/// If there's an error status then process it as a response error
void _checkForErrorStatus(Map<String, String> metadata) {
final status = metadata['grpc-status'];
final statusCode = int.parse(status ?? '0');

if (statusCode != 0) {
final messageMetadata = metadata['grpc-message'];
final message =
messageMetadata == null ? null : Uri.decodeFull(messageMetadata);

final statusDetails = metadata[_statusDetailsHeader];
_responseError(GrpcError.custom(
statusCode,
message,
statusDetails == null
? const <GeneratedMessage>[]
: decodeStatusDetails(statusDetails)));
/// If there's an error status then process it as a response error.
void _checkForErrorStatus(Map<String, String> trailers) {
final error = grpcErrorFromTrailers(trailers);
if (error != null) {
_responseError(error);
}
}

Expand Down Expand Up @@ -512,23 +494,3 @@ class ClientCall<Q, R> implements Response {
} catch (_) {}
}
}

/// Given a string of base64url data, attempt to parse a Status object from it.
/// Once parsed, it will then map each detail item and attempt to parse it into
/// its respective GeneratedMessage type, returning the list of parsed detail items
/// as a `List<GeneratedMessage>`.
///
/// Prior to creating the Status object we pad the data to ensure its length is
/// an even multiple of 4, which is a requirement in Dart when decoding base64url data.
///
/// If any errors are thrown during decoding/parsing, it will return an empty list.
@visibleForTesting
List<GeneratedMessage> decodeStatusDetails(String data) {
try {
final parsedStatus = Status.fromBuffer(
base64Url.decode(data.padRight((data.length + 3) & ~3, '=')));
return parsedStatus.details.map(parseErrorDetailsFromAny).toList();
} catch (e) {
return <GeneratedMessage>[];
}
}
2 changes: 1 addition & 1 deletion lib/src/client/transport/http2_transport.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class Http2TransportStream extends GrpcTransportStream {
CodecRegistry? codecRegistry,
Codec? compression,
) : incomingMessages = _transportStream.incomingMessages
.transform(GrpcHttpDecoder())
.transform(GrpcHttpDecoder(forResponse: true))
.transform(grpcDecompressor(codecRegistry: codecRegistry)) {
_outgoingMessages.stream
.map((payload) => frame(payload, compression))
Expand Down
49 changes: 17 additions & 32 deletions lib/src/client/transport/xhr_transport.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,11 @@ import 'web_streams.dart';

const _contentTypeKey = 'Content-Type';

/// All accepted content-type header's prefix.
const _validContentTypePrefix = [
'application/grpc',
'application/json+protobuf',
'application/x-protobuf'
];

class XhrTransportStream implements GrpcTransportStream {
final HttpRequest _request;
final ErrorHandler _onError;
final Function(XhrTransportStream stream) _onDone;
bool _headersReceived = false;
int _requestBytesRead = 0;
final StreamController<ByteBuffer> _incomingProcessor = StreamController();
final StreamController<GrpcMessage> _incomingMessages = StreamController();
Expand Down Expand Up @@ -104,37 +98,28 @@ class XhrTransportStream implements GrpcTransportStream {
onError: _onError, onDone: _incomingMessages.close);
}

bool _checkContentType(String contentType) {
return _validContentTypePrefix.any(contentType.startsWith);
bool _validateResponseState() {
try {
validateHttpStatusAndContentType(
_request.status, _request.responseHeaders,
rawResponse: _request.responseText);
return true;
} catch (e, st) {
_onError(e, st);
return false;
}
}

void _onHeadersReceived() {
// Force a metadata message with headers.
final headers = GrpcMetadata(_request.responseHeaders);
_incomingMessages.add(headers);
_headersReceived = true;
if (!_validateResponseState()) {
return;
}
_incomingMessages.add(GrpcMetadata(_request.responseHeaders));
}

void _onRequestDone() {
final contentType = _request.getResponseHeader(_contentTypeKey);
if (_request.status != 200) {
_onError(
GrpcError.unavailable('XhrConnection status ${_request.status}', null,
_request.responseText),
StackTrace.current);
return;
}
if (contentType == null) {
_onError(
GrpcError.unavailable('XhrConnection missing Content-Type', null,
_request.responseText),
StackTrace.current);
return;
}
if (!_checkContentType(contentType)) {
_onError(
GrpcError.unavailable('XhrConnection bad Content-Type $contentType',
null, _request.responseText),
StackTrace.current);
if (!_headersReceived && !_validateResponseState()) {
return;
}
if (_request.response == null) {
Expand Down
160 changes: 159 additions & 1 deletion lib/src/shared/status.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,16 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import 'dart:convert';
import 'dart:io' show HttpStatus;

import 'package:meta/meta.dart';
import 'package:protobuf/protobuf.dart';

import 'package:grpc/src/generated/google/protobuf/any.pb.dart';
import 'package:grpc/src/generated/google/rpc/code.pbenum.dart';
import 'package:grpc/src/generated/google/rpc/error_details.pb.dart';
import 'package:protobuf/protobuf.dart';
import 'package:grpc/src/generated/google/rpc/status.pb.dart';

class StatusCode {
/// The operation completed successfully.
Expand Down Expand Up @@ -120,6 +126,29 @@ class StatusCode {
/// The request does not have valid authentication credentials for the
/// operation.
static const unauthenticated = 16;

/// Mapping taken from gRPC-Web JS implementation:
/// https://github.com/grpc/grpc-web/blob/master/javascript/net/grpc/web/statuscode.js
static const _httpStatusToGrpcStatus = <int, int>{
HttpStatus.ok: StatusCode.ok,
HttpStatus.badRequest: StatusCode.invalidArgument,
HttpStatus.unauthorized: StatusCode.unauthenticated,
HttpStatus.forbidden: StatusCode.permissionDenied,
HttpStatus.notFound: StatusCode.notFound,
HttpStatus.conflict: StatusCode.aborted,
HttpStatus.preconditionFailed: StatusCode.failedPrecondition,
HttpStatus.tooManyRequests: StatusCode.resourceExhausted,
HttpStatus.clientClosedRequest: StatusCode.cancelled,
HttpStatus.internalServerError: StatusCode.unknown,
HttpStatus.notImplemented: StatusCode.unimplemented,
HttpStatus.serviceUnavailable: StatusCode.unavailable,
HttpStatus.gatewayTimeout: StatusCode.deadlineExceeded,
};

/// Creates a gRPC Status code from a HTTP Status code
static int fromHttpStatus(int status) {
return _httpStatusToGrpcStatus[status] ?? StatusCode.unknown;
}
}

class GrpcError implements Exception {
Expand Down Expand Up @@ -309,3 +338,132 @@ GeneratedMessage parseErrorDetailsFromAny(Any any) {
return any;
}
}

/// Validate HTTP status and Content-Type which arrived with the response:
/// reject reponses with non-ok (200) status or unsupported Content-Type.
///
/// Note that grpc-status arrives in trailers and will be handled by
/// [ClientCall._onResponseData].
///
/// gRPC over HTTP2 protocol specification mandates the following:
///
/// Implementations should expect broken deployments to send non-200 HTTP
/// status codes in responses as well as a variety of non-GRPC content-types
/// and to omit Status & Status-Message. Implementations must synthesize a
/// Status & Status-Message to propagate to the application layer when this
/// occurs.
///
void validateHttpStatusAndContentType(
int? httpStatus, Map<String, String> headers,
{Object? rawResponse}) {
if (httpStatus == null) {
throw GrpcError.unknown(
'HTTP response status is unknown', null, rawResponse);
}

if (httpStatus == 0) {
throw GrpcError.unknown(
'HTTP request completed without a status (potential CORS issue)',
null,
rawResponse);
}

final status = StatusCode.fromHttpStatus(httpStatus);
if (status != StatusCode.ok) {
// [httpStatus] itself already indicates an error. Check if we also
// received grpc-status/message (i.e. this is a Trailers-Only response)
// and use this information to report a better error to the application
// layer. However prefer to use status code derived from HTTP status
// if grpc-status itself does not provide an informative error.
final error = grpcErrorFromTrailers(headers);
if (error == null || error.code == StatusCode.unknown) {
throw GrpcError.custom(
status,
error?.message ??
'HTTP connection completed with ${httpStatus} instead of 200',
error?.details,
rawResponse);
}
throw error;
}

final contentType = headers['content-type'];
if (contentType == null) {
throw GrpcError.unknown('missing content-type header', null, rawResponse);
}

// Check if content-type header indicates a supported format.
if (!_validContentTypePrefix.any(contentType.startsWith)) {
throw GrpcError.unknown(
'unsupported content-type (${contentType})', null, rawResponse);
}
}

GrpcError? grpcErrorFromTrailers(Map<String, String> trailers) {
final status = trailers['grpc-status'];
final statusCode = status != null ? int.parse(status) : StatusCode.unknown;

if (statusCode != StatusCode.ok) {
final message = _tryDecodeStatusMessage(trailers['grpc-message']);
final statusDetails = trailers[_statusDetailsHeader];
return GrpcError.custom(
statusCode,
message,
statusDetails == null
? const <GeneratedMessage>[]
: decodeStatusDetails(statusDetails));
}

return null;
}

const _statusDetailsHeader = 'grpc-status-details-bin';

/// All accepted content-type header's prefix. We are being more permissive
/// then gRPC and gRPC-Web specifications because some of the services
/// return slightly different content-types.
const _validContentTypePrefix = [
'application/grpc',
'application/json+protobuf',
'application/x-protobuf'
];

/// Given a string of base64url data, attempt to parse a Status object from it.
/// Once parsed, it will then map each detail item and attempt to parse it into
/// its respective GeneratedMessage type, returning the list of parsed detail items
/// as a `List<GeneratedMessage>`.
///
/// Prior to creating the Status object we pad the data to ensure its length is
/// an even multiple of 4, which is a requirement in Dart when decoding base64url data.
///
/// If any errors are thrown during decoding/parsing, it will return an empty list.
@visibleForTesting
List<GeneratedMessage> decodeStatusDetails(String data) {
try {
final parsedStatus = Status.fromBuffer(
base64Url.decode(data.padRight((data.length + 3) & ~3, '=')));
return parsedStatus.details.map(parseErrorDetailsFromAny).toList();
} catch (e) {
return <GeneratedMessage>[];
}
}

/// Decode percent encoded status message contained in 'grpc-message' trailer.
String? _tryDecodeStatusMessage(String? statusMessage) {
if (statusMessage == null) {
return statusMessage;
}

try {
return Uri.decodeFull(statusMessage);
} catch (_) {
// gRPC over HTTP2 protocol specification mandates:
//
// When decoding invalid values, implementations MUST NOT error or throw
// away the message. At worst, the implementation can abort decoding the
// status message altogether such that the user would received the raw
// percent-encoded form.
//
return statusMessage;
}
}
Loading

0 comments on commit 6c16fce

Please sign in to comment.