Skip to content

Commit 20b37ba

Browse files
authored
feat(jsonrpc): add CustomHeader http.Header for multi-value headers
RPCClientOpts.CustomHeaders is a map[string]string applied via http.Header.Set, which can only carry one value per name. Callers that need to forward multi-value headers (Cookie, X-Forwarded-For, or any other RFC 7230 list-form header) currently have to either drop values or comma-join them — and comma-joining is wrong for Cookie, whose RFC 6265 separator is "; ", not ", ". Add a sibling CustomHeader field of type http.Header. Values are copied verbatim into request.Header so multiple values per name become multiple header lines on the wire. Application order in newRequest is: library defaults (Content-Type, Accept), then CustomHeaders (legacy), then CustomHeader. CustomHeader therefore takes precedence for any name present in both, and CustomHeaders behavior is unchanged for callers that don't set CustomHeader, preserving backward compatibility. CustomHeaders is marked Deprecated in its godoc, pointing callers to CustomHeader for new code.
1 parent 20713fb commit 20b37ba

2 files changed

Lines changed: 95 additions & 5 deletions

File tree

rpc/jsonrpc/jsonrpc.go

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -271,15 +271,40 @@ type rpcClient struct {
271271
endpoint string
272272
httpClient HTTPClient
273273
customHeaders map[string]string
274+
customHeader http.Header
274275
}
275276

276-
// RPCClientOpts can be provided to NewClientWithOpts() to change configuration of RPCClient.
277+
// RPCClientOpts can be provided to NewClientWithOpts() to change configuration
278+
// of RPCClient.
277279
//
278-
// HTTPClient: provide a custom http.Client (e.g. to set a proxy, or tls options)
280+
// HTTPClient: provide a custom http.Client (e.g. to set a proxy, or tls
281+
// options).
279282
//
280-
// CustomHeaders: provide custom headers, e.g. to set BasicAuth
283+
// CustomHeader: provide custom request headers as an http.Header. Use this for
284+
// any new code: it preserves multi-value semantics so headers like Cookie,
285+
// X-Forwarded-For, or other RFC 7230 list-form headers are sent as separate
286+
// header lines verbatim instead of being collapsed to a single value.
287+
//
288+
// CustomHeaders is the legacy map[string]string equivalent and is kept for
289+
// backward compatibility. When a name is present in both CustomHeader and
290+
// CustomHeaders, CustomHeader wins.
281291
type RPCClientOpts struct {
282-
HTTPClient HTTPClient
292+
HTTPClient HTTPClient
293+
294+
// CustomHeader applies request headers as an http.Header, preserving
295+
// multi-value entries (each value becomes its own header line on the
296+
// wire). Prefer this over CustomHeaders.
297+
CustomHeader http.Header
298+
299+
// CustomHeaders applies request headers from a name->value map. Each
300+
// entry is applied via http.Header.Set, so only a single value per
301+
// name survives — multiple inbound values for the same header are
302+
// silently dropped and Cookie / X-Forwarded-For style headers cannot
303+
// be forwarded faithfully.
304+
//
305+
// Deprecated: use CustomHeader instead. CustomHeaders is retained for
306+
// backward compatibility; new code should use the http.Header field
307+
// which supports multi-value headers correctly.
283308
CustomHeaders map[string]string
284309
}
285310

@@ -364,6 +389,10 @@ func NewClientWithOpts(endpoint string, opts *RPCClientOpts) RPCClient {
364389
}
365390
}
366391

392+
if len(opts.CustomHeader) > 0 {
393+
rpcClient.customHeader = opts.CustomHeader.Clone()
394+
}
395+
367396
return rpcClient
368397
}
369398

@@ -485,11 +514,19 @@ func (client *rpcClient) newRequest(ctx context.Context, req any) (*http.Request
485514
request.Header.Set("Content-Type", "application/json")
486515
request.Header.Set("Accept", "application/json")
487516

488-
// set default headers first, so that even content type and accept can be overwritten
517+
// CustomHeaders (legacy, single-value) is applied first so that even
518+
// Content-Type and Accept can be overwritten by callers that need to.
489519
for k, v := range client.customHeaders {
490520
request.Header.Set(k, v)
491521
}
492522

523+
// CustomHeader (http.Header) is applied last so it takes precedence
524+
// over CustomHeaders for any name present in both, and so multi-value
525+
// entries land verbatim as separate header lines on the wire.
526+
for k, vs := range client.customHeader {
527+
request.Header[http.CanonicalHeaderKey(k)] = append([]string(nil), vs...)
528+
}
529+
493530
return request, nil
494531
}
495532

rpc/jsonrpc/jsonrpc_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,59 @@ func TestSimpleRpcCallHeaderCorrect(t *testing.T) {
5757
Expect(req.Header.Get("Accept")).To(Equal("application/json"))
5858
}
5959

60+
func TestCustomHeader_PreservesMultiValue(t *testing.T) {
61+
RegisterTestingT(t)
62+
63+
hdr := http.Header{}
64+
hdr.Add("X-Forwarded-For", "1.1.1.1")
65+
hdr.Add("X-Forwarded-For", "2.2.2.2")
66+
hdr.Add("Cookie", "a=1")
67+
hdr.Add("Cookie", "b=2")
68+
hdr.Set("Authorization", "Bearer t")
69+
70+
rpcClient := NewClientWithOpts(httpServer.URL, &RPCClientOpts{
71+
CustomHeader: hdr,
72+
})
73+
rpcClient.Call(context.Background(), "noop")
74+
75+
req := (<-requestChan).request
76+
77+
Expect(req.Header.Values("X-Forwarded-For")).To(Equal([]string{"1.1.1.1", "2.2.2.2"}))
78+
Expect(req.Header.Values("Cookie")).To(Equal([]string{"a=1", "b=2"}))
79+
Expect(req.Header.Get("Authorization")).To(Equal("Bearer t"))
80+
}
81+
82+
func TestCustomHeader_TakesPrecedenceOverCustomHeaders(t *testing.T) {
83+
RegisterTestingT(t)
84+
85+
rpcClient := NewClientWithOpts(httpServer.URL, &RPCClientOpts{
86+
CustomHeaders: map[string]string{
87+
"X-Test": "from-map",
88+
},
89+
CustomHeader: http.Header{
90+
"X-Test": []string{"from-header-a", "from-header-b"},
91+
},
92+
})
93+
rpcClient.Call(context.Background(), "noop")
94+
95+
req := (<-requestChan).request
96+
Expect(req.Header.Values("X-Test")).To(Equal([]string{"from-header-a", "from-header-b"}))
97+
}
98+
99+
func TestCustomHeaders_StillWorks(t *testing.T) {
100+
RegisterTestingT(t)
101+
102+
rpcClient := NewClientWithOpts(httpServer.URL, &RPCClientOpts{
103+
CustomHeaders: map[string]string{
104+
"X-Backcompat": "yes",
105+
},
106+
})
107+
rpcClient.Call(context.Background(), "noop")
108+
109+
req := (<-requestChan).request
110+
Expect(req.Header.Get("X-Backcompat")).To(Equal("yes"))
111+
}
112+
60113
// test if the structure of an rpc request is built correctly by validating the data that arrived on the test server
61114
func TestRpcClient_Call(t *testing.T) {
62115
RegisterTestingT(t)

0 commit comments

Comments
 (0)