Skip to content

PSA: Use json.Decoder & Decode instead of json.Unmarshal when using the original request body bytes in business logic #6349

@Kumm-Kai

Description

@Kumm-Kai

🐛 Bug Report

Not an actual bug, more a PSA for others, with similar issues, to stumble upon.

We had a component consisting of a runtime.WithMetadata annotator which added multiple fields to the gRPC context and a UnaryServerInterceptor that reads the previously added metadata fields from the gRPC context.

One of the added fields was the original HTTP request body.

The grpc-gateway usually creates a NewDecoder from the marshaler of the specific request and calls Decode on the request body.
In our setup we've used the default marshaler, so runtime.HTTPBodyMarshaler which is wrapping runtime.JSONPb.

Now to our actual problem:
The UnaryServerInterceptor retrieves the original HTTP request body from the gRPC context and calls json.Unmarshal on the raw bytes.
This is not a problem for normal JSON bodies that are entirely valid.
But for requests where, for whatever reason, additional bytes are present after the initial JSON object, it will result in grpc-gateway being able to properly Decode the body, passing it to the business logic, and json.Unmarshal in the UnaryServerInterceptor later failing to unmarshal.

Such an invalid body might look like this:
{"a": "b"}x or {"a":"b"}{"x":"y"}

This happens because of the difference between runtime.JSONPb, which uses json.Decoder and json.Unmarshal.
The json.Decoder decodes the input []byte JSON object by JSON object, while json.Unmarshal tries to decode the entire input []byte.

The solution:

  • If you control the code which uses json.Unmarshal, is quite easy: Just use the json.Decoder & Decode instead.
  • If you are unable to change the code and/or instead want to ensure that the original HTTP request body only contains the valid JSON object and nothing else: Configure grpc-gateway to use a custom marshaler (runtime.WithMarshalerOption) that wraps runtime.jsonPb.

An implementation of such a custom marshaler wrapper might look like this:

Custom Marshaler
package jsonpb

import (
	"bytes"
	"encoding/json"
	"errors"
	"io"

	"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
)

type JSONPb struct {
	runtime.JSONPb
}

func (j *JSONPb) NewDecoder(r io.Reader) runtime.Decoder {
	d := json.NewDecoder(r)
	return DecoderWrapper{
		original: runtime.DecoderWrapper{
			Decoder:          d,
			UnmarshalOptions: j.UnmarshalOptions,
		},
	}
}

type DecoderWrapper struct {
	original runtime.DecoderWrapper
}

func (d DecoderWrapper) Decode(v any) error {
	if err := d.original.Decode(v); err != nil {
		return err
	}
	_, err := d.original.Token()
	if err != io.EOF {
		return errors.New("request body is not a valid JSON object: body contains additional bytes after the JSON object")
	}
	return nil
}

func (j *JSONPb) Unmarshal(data []byte, v any) error {
	return j.NewDecoder(bytes.NewReader(data)).Decode(v)
}

Configure grpc-gateway like this:

runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.HTTPBodyMarshaler{
	Marshaler: &jsonpb.JSONPb{
		JSONPb: runtime.JSONPb{
			MarshalOptions: protojson.MarshalOptions{
				EmitUnpopulated: true,
			},
			UnmarshalOptions: protojson.UnmarshalOptions{
				DiscardUnknown: true,
			},
		},
	},
}),

I hope this is useful to someone 🙂

(Feel free to close this issue)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions