Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<param>` 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
Expand Down
40 changes: 36 additions & 4 deletions processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:<param>" 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]

Expand All @@ -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
}
Expand All @@ -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
Expand Down
56 changes: 56 additions & 0 deletions processor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
32 changes: 32 additions & 0 deletions tokenizer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading