#3867·kratos

middleware/metadata: non-ASCII values break gRPC calls, header values should be percent-encoded

Author: yoogocCreated Aug 7, 2026Updated Aug 7, 2026
Labelsbug

What happened:

middleware/metadata writes metadata values into the transport header verbatim, and reads them back verbatim. When a value contains Chinese or any other non-ASCII (multi-byte) character, the RPC fails.

On gRPC the call is rejected by grpc-go before it ever leaves the process, because gRPC requires every non--bin header value to be printable ASCII (%x20-%x7E, see internal/metadata.ValidatePair):

rpc error: code = Internal desc = header key "x-md-global-name" contains value with non-printable ASCII characters

Path: metadata.Client middleware → tr.RequestHeader().Add(k, v)grpcmd.AppendToOutgoingContext (transport/grpc/client.go:213-221) → grpc.newClientStreamimetadata.Validatecodes.Internal.

On HTTP it does not error in a Go↔Go setup (Go's httpguts.ValidHeaderFieldValue tolerates 0x80-0xff obs-text and passes the raw bytes through), but the value is still not a valid header field value per RFC 9110 — header values are opaque octets conventionally decoded as ISO-8859-1, so proxies, gateways, browsers and non-Go servers will mangle it into mojibake.

What you expected to happen:

Putting a UTF-8 value such as 张三 into metadata should just work over both gRPC and HTTP. The middleware owns both ends of the wire format, so it should encode the value when writing it into the header and decode it when reading it back — the user-visible value in metadata.Metadata should be identical on the client and on the server.

How to reproduce it (as minimally and precisely as possible):

go
// client side
md := metadata.New()
md.Set("x-md-global-name", "张三") // any non-ASCII value
ctx := metadata.NewClientContext(context.Background(), md)
_, err := client.SayHello(ctx, &pb.HelloRequest{})
// err: rpc error: code = Internal desc = header key "x-md-global-name"
//      contains value with non-printable ASCII characters

Equivalent minimal repro without a Kratos server, showing the exact layer that rejects it:

go
cc, _ := grpc.NewClient("127.0.0.1:59999", grpc.WithTransportCredentials(insecure.NewCredentials()))
ctx := grpcmd.AppendToOutgoingContext(context.Background(), "x-md-global-name", "张三")
_, err := grpc_health_v1.NewHealthClient(cc).Check(ctx, &grpc_health_v1.HealthCheckRequest{})
// err is returned immediately, before any connection attempt

Anything else we need to know?:

Suggested fix: percent-encode on write in metadata.Client, percent-decode on read in metadata.Server (middleware/metadata/metadata.go).

Two details matter for backward compatibility:

  1. Only escape when necessary. If a value is already header-safe (printable ASCII with no %), write it unchanged. Existing ASCII traffic then stays byte-identical on the wire, so an older peer on either side is unaffected.
  2. Use url.PathEscape / url.PathUnescape, not the Query* pair. QueryEscape encodes space as + and QueryUnescape decodes + back to space, which would silently corrupt existing values containing + (base64 payloads, phone numbers like +8613800000000). The Path* pair only handles %XX and leaves + alone. On decode, values without % are returned as-is and PathUnescape errors (e.g. a legacy raw value like 100% off) fall back to the original string.

This does mean non-ASCII values only work end-to-end once both sides are upgraded; an old server receiving a percent-encoded value would need to decode it itself. That case is already broken today, so nothing that works now regresses.

I have a patch with tests covering the round trip (Chinese / emoji / values containing % and + / plain ASCII) and can send a PR if the approach looks right.

Environment:

  • Kratos version (use kratos -v): v3.0.0 (main @ 668db92)
  • Go version (use go version): go1.26.5 darwin/arm64
  • OS (e.g: cat /etc/os-release): macOS 27.0
  • Others: google.golang.org/grpc v1.81.0