@@ -18,6 +18,7 @@ package rpcserver
1818import (
1919 "context"
2020 "fmt"
21+ "html"
2122 "math"
2223 "net/http"
2324 "net/textproto"
@@ -50,6 +51,7 @@ import (
5051 _ "google.golang.org/grpc/encoding/gzip" // Register gzip compression.
5152 "google.golang.org/grpc/keepalive"
5253 "google.golang.org/grpc/metadata"
54+ grpcstatus "google.golang.org/grpc/status"
5355)
5456
5557func init () {
@@ -132,6 +134,31 @@ func WithRateLimiter(limiter ratelimit.Interface) Option {
132134// ErrRPCRecovered is returned when a panic is caught from an RPC.
133135var ErrRPCRecovered = errors .DefineInternal ("rpc_recovered" , "Internal Server Error" )
134136
137+ // sanitizingHTTPErrorHandler wraps runtime.DefaultHTTPErrorHandler to HTML-escape
138+ // gRPC status messages before they are serialized to JSON. This prevents reflected
139+ // XSS from user-supplied input that flows into error messages (e.g., field mask paths,
140+ // strconv.ParseBool parse errors, route-not-found paths).
141+ //
142+ // Note: ErrorDetailsToProto separately sanitizes error attributes in the details
143+ // payload. This handler covers the top-level "message" field which contains the
144+ // formatted error string with raw attribute values.
145+ func sanitizingHTTPErrorHandler (
146+ ctx context.Context ,
147+ mux * runtime.ServeMux ,
148+ marshaler runtime.Marshaler ,
149+ w http.ResponseWriter ,
150+ r * http.Request ,
151+ err error ,
152+ ) {
153+ if st , ok := grpcstatus .FromError (err ); ok {
154+ // Rebuild the status with escaped message while preserving details.
155+ pb := st .Proto ()
156+ pb .Message = html .EscapeString (pb .Message )
157+ err = grpcstatus .ErrorProto (pb )
158+ }
159+ runtime .DefaultHTTPErrorHandler (ctx , mux , marshaler , w , r , err )
160+ }
161+
135162// New returns a new RPC server with a set of middlewares.
136163// The given context is used in some of the middlewares, the given server options are passed to gRPC
137164//
@@ -234,7 +261,7 @@ func New(ctx context.Context, opts ...Option) *Server {
234261 server .ServeMux = runtime .NewServeMux (
235262 runtime .WithMarshalerOption ("*" , jsonpb .TTN ()),
236263 runtime .WithMarshalerOption ("text/event-stream" , jsonpb .TTNEventStream ()),
237- runtime .WithErrorHandler (runtime . DefaultHTTPErrorHandler ),
264+ runtime .WithErrorHandler (sanitizingHTTPErrorHandler ),
238265 runtime .WithMetadata (func (ctx context.Context , req * http.Request ) metadata.MD {
239266 md := rpcmetadata.MD {
240267 Host : req .Host ,
0 commit comments