Content views, gRPC and protobuf¶
Render message bodies readably: protobuf without a schema, gRPC message by message with its status
details, JSON, GraphQL, msgpack, forms, Socket.IO and DNS, with field names from a protoc
descriptor set. Use this page to read binary traffic in logs, or to decode and edit protobuf and
gRPC from Java; scripts get the same through the protobuf and grpc
modules.
Content views¶
org.microproxy.contentviews turns message bodies into readable text, after mitmproxy's content
views. A ContentView has a name, rates how well it suits a body (priority, from the
Content-Type, the request path and the bytes) and renders it. ContentViews holds a set of
views and, in auto mode, renders each body with the best view that can; when none can, there
is no rendering and callers show the body as before. HttpLogger uses them (see
Request/response logging); they also work on their own:
ContentViews views = ContentViews.defaults();
views.render(body, ContentView.Metadata.forResponse(request, response))
.ifPresent(r -> System.out.println(r.view() + ":\n" + r.text()));
| View | Bodies | Rendering |
|---|---|---|
protobuf |
application/x-protobuf, application/protobuf, application/vnd.google.protobuf, ... |
YAML with field numbers (names with a schema) |
grpc |
application/grpc, application/grpc+proto, gRPC-Web (application/grpc-web, -web-text) |
each message, decompressed as grpc-encoding says (gzip, deflate, and zstd with zstd-decoder), separated by ---, then the trailers |
json |
application/json, text/json, +json |
indented by two spaces |
graphql |
JSON with a query (or a batch of them) |
the other members as JSON, then --- and the query, one field per line |
msgpack |
application/msgpack, application/x-msgpack |
YAML: every format of the specification, extensions as !ext<type>, timestamps as instants |
urlencoded, multipart |
application/x-www-form-urlencoded, multipart/form-data |
the fields (a repeated name as a list), files' names, types and sizes |
query |
requests without a body | the query parameters |
socketio |
paths with /socket.io/ or /engine.io/ |
one line per Engine.IO or Socket.IO packet |
dns |
application/dns-message (DNS over HTTPS, and DNS over QUIC's messages) |
as dig shows it: the header, then each section's records |
hex |
anything, when named | a hex dump |
views.with(myView) adds a view (or replaces one of the same name); a view that fails on a body
is skipped. Views get bodies with their Content-Encoding removed, and must throw nothing but
DecodeException for data they cannot render.
Protobuf without a schema¶
1: 150 # !sint: 75
2: hello
3: !fixed32 3.14159 # u32: 1078530000
5:
1: 42 # !sint: 21
6: !binary 038e029ea705 # packed: [3, 270, 86942]
The decoding follows mitmproxy's (ported from mitmproxy_rs):
- Numbers. Varints and fixed-width values are what the wire says; each is shown in its
shortest reading, with the others as a comment: unsigned, signed when the top bit is set (a
negative
int32orint64, which take ten bytes), zigzag (!sint, forsint32andsint64), and as a float or double for!fixed32and!fixed64. Which one the field really is cannot be told from the bytes. - Length-delimited values are guessed per field number, over all of the field's values:
text when every value is UTF-8 without control characters (other than whitespace); else a
nested message when every value parses as one (minimal varints, field numbers in range, one
wire type per field number); else bytes, with a
packed:comment when they read as two or more packed varints. Ambiguity is inherent:08 96 01is a message with field 1 = 150 and also the packed values 8 and 150; the message wins, as in mitmproxy. - Repetition. A field that occurs more than once is a list. Groups (wire types 3 and 4) are
!groupmappings. Fields are sorted by number. - Limits. Messages nest at most 64 deep (deeper values stay bytes), groups too (deeper ones
are an error), and lengths are checked against the input before anything is allocated. Bad
input is a
DecodeException, never anything else; the build fuzzes the decoders with a fixed seed.
The same in Java, with editing:
ProtoMessage m = Protobuf.decode(bytes);
Map<Object, Object> fields = m.toPlain(); // {1=150, 2=hello, 5={1=42}}
fields.put(2, "goodbye");
byte[] edited = Protobuf.encode(fields);
List<byte[]> messages = Grpc.messages(body, "gzip"); // split and decompress
byte[] framed = Grpc.join(messages, "gzip"); // frame and compress
Grpc.Status status = Grpc.statusFromTrailer(trailers.get("grpc-status-details-bin"));
Encoding a decoded ProtoMessage gives back its bytes, except where the input used non-minimal
varints at the top level. The plain form (toPlain) re-encodes to the same bytes when each
field's values were together on the wire, as encoders write them; fixed-width values and groups
stay ProtoValue.Fixed32, Fixed64 and Group in it, so that they keep their wire types.
gRPC¶
A unary call logged with --mitm --log-http-view auto (HTTP/2 both ways by default; headers
shortened):
[conn 4 #2 stream 1] --> POST https://api.example.com/echo.Echo/Chat HTTP/2.0
[conn 4 #2 stream 1] content-type: application/grpc
[conn 4 #2 stream 1] te: trailers
[conn 4 #2 stream 1]
[conn 4 #2 stream 1] 1: unary
[conn 4 #2 stream 1] 2: 7 # !sint: -4
[conn 4 #2 stream 1] --> END POST (14-byte body, grpc view)
[conn 4 #2 stream 1] <-- 200 OK https://api.example.com/echo.Echo/Chat (21 ms, ttfb 9 ms, source=server)
[conn 4 #2 stream 1] content-type: application/grpc
[conn 4 #2 stream 1]
[conn 4 #2 stream 1] 1: UNARY
[conn 4 #2 stream 1] 2: 7 # !sint: -4
[conn 4 #2 stream 1] <-- trailers
[conn 4 #2 stream 1] grpc-status: 0 # OK
[conn 4 #2 stream 1] grpc-message: echoed 1
[conn 4 #2 stream 1] <-- END HTTP (14-byte body, grpc view)
Streams show each message in turn, separated by ---; compressed ones say so (#
gzip-compressed, 23 bytes). An error's grpc-status-details-bin is decoded as a
google.rpc.Status, with the standard error details (BadRequest, ErrorInfo, RetryInfo,
...) by name, and grpc-message is percent-decoded:
grpc-status: 3 # INVALID_ARGUMENT
grpc-message: invalid%20name # invalid name
grpc-status-details-bin: CAMSDGludmFsaWQgbmFtZRo...
code: 3
message: invalid name
details:
- type_url: type.googleapis.com/google.rpc.BadRequest
value:
field_violations:
- field: name
description: must not be empty
Field names from a descriptor set¶
With the FileDescriptorSet that protoc writes, messages decode with field names, enum names
and declared types:
protoc --include_imports --descriptor_set_out=api.desc -I protos protos/*.proto
java -jar microproxy-starlark-all.jar --mitm --proto-descriptors api.desc # the -all jar has HTTP/2
ProtoSchema schema = ProtoSchema.load(Path.of("api.desc"));
HttpLogger logger = HttpLogger.builder().level(HttpLogger.Level.BODY)
.contentViews(ContentViews.defaults().withSchema(schema)).build();
ProtoMessage order = Protobuf.decode(bytes, schema, "shop.Order");
byte[] encoded = Protobuf.encode(Map.of("id", 42L, "status", "SHIPPED"), schema, "shop.Order");
- gRPC requests decode as their method's input type and responses as its output type, found by
the request path (
/shop.Shop/GetOrder). Protobuf bodies use amessageType(orproto,type) parameter of theirContent-Type. - Declared fields show their names and types: enum value names, zigzag-decoded
sints,bools, floats, packed fields as lists, map fields as mappings,Anyas the type itstype_urlnames. Fields the schema does not declare, or whose wire type does not match, are decoded as without a schema. - The descriptor set is parsed with the package's own decoder; nothing else is needed at run time.
Extensions, default values and options other than
packedandmap_entryare ignored. - Every schema knows
google.rpc.Statusand its error details,google.protobuf.Any,DurationandTimestamp, so status details have names even without one.