diff --git a/README.md b/README.md
index 6d2bfb9..5aaad46 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. 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.
```ruby
diff --git a/processor.go b/processor.go
index 83a0088..2df8035 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,24 @@ 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()
+ // 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
+ }
+
r.Header.Set(dst, val)
return nil
diff --git a/processor_test.go b/processor_test.go
index 4cd8cfb..2e85a7d 100644
--- a/processor_test.go
+++ b/processor_test.go
@@ -119,6 +119,62 @@ 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")
+ // 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
+ 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")