From e00fb474d5d0ba1710548a25fa4e3804d49f1491 Mon Sep 17 00:00:00 2001 From: Matt Braun Date: Tue, 14 Jul 2026 16:47:14 -0500 Subject: [PATCH 1/2] Support query parameter destinations in DstProcessor Some APIs only accept credentials in the URL query string (e.g. NetActuate's vapi2 rejects Authorization and X-API-Key headers). Extend the dst config/param with a "query:" form that sets the secret as a query parameter on the request URL, preserving existing parameters. Header and query dsts are separate namespaces: a sealed or allowlisted header dst never authorizes a query destination of the same name, and vice versa. Query parameter names are matched case sensitively, without the MIME header canonicalization applied to header dsts. Prior art: sprites-api's Connectors gateway supports the same query_param injection method alongside header injection. --- README.md | 17 ++++++++++++++++ processor.go | 32 ++++++++++++++++++++++++++---- processor_test.go | 50 +++++++++++++++++++++++++++++++++++++++++++++++ tokenizer_test.go | 32 ++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6d2bfb9..af41e0d 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,23 @@ This will result in the header getting injected like this: X-Stripe-Token: token=my-stripe-api-key ``` +The destination can also be a URL query parameter using the `query:` form, for APIs that only accept credentials in the query string: + +```ruby +secret = { + inject_processor: { + token: "my-api-token", + dst: "query:key", + fmt: "%s", + }, + bearer_auth: { + digest: Digest::SHA256.base64digest('trustno1') + } +} +``` + +This appends `key=my-api-token` to the request's query string, preserving any other query parameters. Unlike header names, query parameter names in `dst`/`allowed_dst` are matched case sensitively. Note that query strings are more likely than headers to end up in server access logs, so prefer sealing `dst` in the secret over allowing the client to select a query destination at request time. + Aside from `inject_processor`, we also have `inject_hmac_processor`. This creates an HMAC signatures using the key stored in the encrypted secret and injects that into a request header. The hash algorithm can be specified in the secret under the key `hash` and defaults to SHA256. This processor signs the verbatim request body by default, but can sign custom messages specified in the `msg` parameter in the `Proxy-Tokenizer` header (see about parameters bellow). This processor also respects the `dst` and `fmt` options. ```ruby diff --git a/processor.go b/processor.go index 83a0088..db7abde 100644 --- a/processor.go +++ b/processor.go @@ -1181,7 +1181,21 @@ type DstProcessor struct { AllowedDst []string `json:"allowed_dst,omitempty"` } -// Apply the specified val to the correct destination header in the request. +// Dsts of the form "query:" target a URL query parameter instead of a +// header. +const dstQueryPrefix = "query:" + +// Canonicalize a dst for comparison. Header names are case insensitive, but +// query parameter names are matched exactly. +func canonicalDst(dst string) string { + if strings.HasPrefix(dst, dstQueryPrefix) { + return dst + } + return textproto.CanonicalMIMEHeaderKey(dst) +} + +// Apply the specified val to the correct destination header or query +// parameter in the request. func (fp DstProcessor) ApplyDst(params map[string]string, r *http.Request, val string) error { dst, hasParam := params[ParamDst] @@ -1197,16 +1211,16 @@ func (fp DstProcessor) ApplyDst(params map[string]string, r *http.Request, val s dst = "Authorization" } - dst = textproto.CanonicalMIMEHeaderKey(dst) + dst = canonicalDst(dst) // check if param is allowed - if fp.Dst != "" && dst != textproto.CanonicalMIMEHeaderKey(fp.Dst) { + if fp.Dst != "" && dst != canonicalDst(fp.Dst) { return errors.New("bad dst") } if fp.AllowedDst != nil { var found bool for _, a := range fp.AllowedDst { - if dst == textproto.CanonicalMIMEHeaderKey(a) { + if dst == canonicalDst(a) { found = true break } @@ -1216,6 +1230,16 @@ func (fp DstProcessor) ApplyDst(params map[string]string, r *http.Request, val s } } + if param, ok := strings.CutPrefix(dst, dstQueryPrefix); ok { + if param == "" { + return errors.New("bad dst") + } + q := r.URL.Query() + q.Set(param, val) + r.URL.RawQuery = q.Encode() + return nil + } + r.Header.Set(dst, val) return nil diff --git a/processor_test.go b/processor_test.go index 4cd8cfb..1909600 100644 --- a/processor_test.go +++ b/processor_test.go @@ -119,6 +119,56 @@ func TestDstProcessor(t *testing.T) { assertResult("error", DstProcessor{Dst: "Foo", AllowedDst: []string{"Bar"}}, map[string]string{}) assertResult("error", DstProcessor{AllowedDst: []string{"Bar"}}, map[string]string{ParamDst: "Foo"}) assertResult("error", DstProcessor{Dst: "Bar"}, map[string]string{ParamDst: "Foo"}) + assertResult("Foo: 123", DstProcessor{AllowedDst: []string{"Foo", "query:key"}}, map[string]string{ParamDst: "Foo"}) +} + +func TestDstProcessorQuery(t *testing.T) { + assertResult := func(expected string, dp DstProcessor, params map[string]string, rawQuery string) { + t.Helper() + + u, err := url.Parse("https://api.example.com/path") + assert.NoError(t, err) + u.RawQuery = rawQuery + + r := http.Request{Header: make(http.Header), URL: u} + err = dp.ApplyDst(params, &r, "123") + if expected == "error" { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, expected, r.URL.RawQuery) + assert.Equal(t, 0, len(r.Header)) + } + } + + // sealed query dst applies without params + assertResult("key=123", DstProcessor{Dst: "query:key"}, map[string]string{}, "") + // existing query params are preserved + assertResult("foo=bar&key=123", DstProcessor{Dst: "query:key"}, map[string]string{}, "foo=bar") + // the requester may name the sealed query dst explicitly + assertResult("key=123", DstProcessor{Dst: "query:key"}, map[string]string{ParamDst: "query:key"}, "") + // allowlisted query dsts work, including as the default (first) entry + assertResult("key=123", DstProcessor{AllowedDst: []string{"query:key"}}, map[string]string{ParamDst: "query:key"}, "") + assertResult("key=123", DstProcessor{AllowedDst: []string{"query:key"}}, map[string]string{}, "") + assertResult("key=123", DstProcessor{AllowedDst: []string{"Foo", "query:key"}}, map[string]string{ParamDst: "query:key"}, "") + // an unsealed dst leaves the destination up to the requester, as with headers + assertResult("key=123", DstProcessor{}, map[string]string{ParamDst: "query:key"}, "") + // query param names are case sensitive + assertResult("error", DstProcessor{Dst: "query:key"}, map[string]string{ParamDst: "query:Key"}, "") + assertResult("error", DstProcessor{AllowedDst: []string{"query:key"}}, map[string]string{ParamDst: "query:other"}, "") + // header and query dsts are separate namespaces + assertResult("error", DstProcessor{Dst: "Authorization"}, map[string]string{ParamDst: "query:Authorization"}, "") + assertResult("error", DstProcessor{Dst: "query:Foo"}, map[string]string{ParamDst: "Foo"}, "") + // the param name is required + assertResult("error", DstProcessor{Dst: "query:"}, map[string]string{}, "") + assertResult("error", DstProcessor{}, map[string]string{ParamDst: "query:"}, "") + + // values are URL-encoded + u, err := url.Parse("https://api.example.com/path") + assert.NoError(t, err) + r := http.Request{Header: make(http.Header), URL: u} + assert.NoError(t, DstProcessor{Dst: "query:key"}.ApplyDst(map[string]string{}, &r, "a b&c")) + assert.Equal(t, "key=a+b%26c", r.URL.RawQuery) } func TestInjectBodyProcessorConfig(t *testing.T) { diff --git a/tokenizer_test.go b/tokenizer_test.go index 2b22575..554afa6 100644 --- a/tokenizer_test.go +++ b/tokenizer_test.go @@ -179,6 +179,38 @@ func TestTokenizer(t *testing.T) { assert.Equal(t, http.StatusProxyAuthRequired, resp.StatusCode) }) + t.Run("inject processor query dst", func(t *testing.T) { + queryServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := w.Write([]byte(r.URL.RawQuery)); err != nil { + logrus.WithError(err).Panicf("failed writing response") + } + })) + defer queryServer.Close() + UpstreamTrust.AddCert(queryServer.Certificate()) + + qu, err := url.Parse(queryServer.URL) + assert.NoError(t, err) + qu.Scheme = "http" + + auth := "trustno1" + token := "supersecret" + secret, err := (&Secret{AuthConfig: NewBearerAuthConfig(auth), ProcessorConfig: &InjectProcessorConfig{ + Token: token, + FmtProcessor: FmtProcessor{Fmt: "%s"}, + DstProcessor: DstProcessor{Dst: "query:key"}, + }}).Seal(sealKey) + assert.NoError(t, err) + + client, err := Client(tkzServer.URL, WithAuth(auth), WithSecret(secret, nil)) + assert.NoError(t, err) + resp, err := client.Get(qu.String() + "/lookup?foo=bar") + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + assert.Equal(t, "foo=bar&key="+token, string(body)) + }) + t.Run("inject hmac processor", func(t *testing.T) { auth := "secreter" key := []byte("trustno2") From 4f6093e0c7350390bc4d4d33355db289ff49e1e9 Mon Sep 17 00:00:00 2001 From: Matt Braun Date: Wed, 15 Jul 2026 09:16:57 -0500 Subject: [PATCH 2/2] Replace existing query params case-insensitively on inject url.Values.Set only replaces an exact-case duplicate, so a request carrying ?KEY=evil would survive alongside the injected ?key=, a parameter-pollution vector against upstreams that match parameter names loosely. Strip any case-insensitive match for the destination param before setting the injected value. --- README.md | 2 +- processor.go | 8 ++++++++ processor_test.go | 6 ++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index af41e0d..5aaad46 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ secret = { } ``` -This appends `key=my-api-token` to the request's query string, preserving any other query parameters. Unlike header names, query parameter names in `dst`/`allowed_dst` are matched case sensitively. Note that query strings are more likely than headers to end up in server access logs, so prefer sealing `dst` in the secret over allowing the client to select a query destination at request time. +This appends `key=my-api-token` to the request's query string, preserving any other query parameters. If the request already carries a parameter with the same name (compared case insensitively), it is replaced. Unlike header names, query parameter names in `dst`/`allowed_dst` are matched case sensitively. Note that query strings are more likely than headers to end up in server access logs. If `dst` is left unsealed, the client chooses the destination per request, meaning any client of the secret can opt it into a query string and its associated log exposure. Seal `dst` so that choice rests with the secret's creator. Aside from `inject_processor`, we also have `inject_hmac_processor`. This creates an HMAC signatures using the key stored in the encrypted secret and injects that into a request header. The hash algorithm can be specified in the secret under the key `hash` and defaults to SHA256. This processor signs the verbatim request body by default, but can sign custom messages specified in the `msg` parameter in the `Proxy-Tokenizer` header (see about parameters bellow). This processor also respects the `dst` and `fmt` options. diff --git a/processor.go b/processor.go index db7abde..2df8035 100644 --- a/processor.go +++ b/processor.go @@ -1235,6 +1235,14 @@ func (fp DstProcessor) ApplyDst(params map[string]string, r *http.Request, val s return errors.New("bad dst") } q := r.URL.Query() + // strip any existing occurrence of the param, matched case + // insensitively, so the request can't carry a competing value + // alongside the injected one + for k := range q { + if strings.EqualFold(k, param) { + delete(q, k) + } + } q.Set(param, val) r.URL.RawQuery = q.Encode() return nil diff --git a/processor_test.go b/processor_test.go index 1909600..2e85a7d 100644 --- a/processor_test.go +++ b/processor_test.go @@ -145,6 +145,12 @@ func TestDstProcessorQuery(t *testing.T) { assertResult("key=123", DstProcessor{Dst: "query:key"}, map[string]string{}, "") // existing query params are preserved assertResult("foo=bar&key=123", DstProcessor{Dst: "query:key"}, map[string]string{}, "foo=bar") + // a param already present in the request is replaced, matched case + // insensitively, so the request can't carry a competing value alongside + // the injected one + assertResult("key=123", DstProcessor{Dst: "query:key"}, map[string]string{}, "key=evil") + assertResult("key=123", DstProcessor{Dst: "query:key"}, map[string]string{}, "KEY=evil") + assertResult("foo=bar&key=123", DstProcessor{Dst: "query:key"}, map[string]string{}, "Key=evil&foo=bar&kEy=evil2") // the requester may name the sealed query dst explicitly assertResult("key=123", DstProcessor{Dst: "query:key"}, map[string]string{ParamDst: "query:key"}, "") // allowlisted query dsts work, including as the default (first) entry