Skip to content

Commit

Permalink
Allow/encourage dst/fmt to be specified in secret (#9)
Browse files Browse the repository at this point in the history
* allow/encourage dst/fmt to be specified in secret

Also, allow the secret to allowlist these if they are to be put
in request-time params.

The dst/fmt will usually be knowable at the time the secret is
created and they usually wont need to vary betweeen requests.
Allowing them to be specified at request-time gives room for
attackers to discover ways to get the target service to reflect
back the plaintext secret in a response.

* remove some dead code
  • Loading branch information
btoews committed Jul 13, 2023
1 parent 1bcbec4 commit fe18ba0
Show file tree
Hide file tree
Showing 4 changed files with 273 additions and 95 deletions.
61 changes: 43 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,36 +40,32 @@ Host: api.stripe.com
Authorization: Bearer my-stripe-api-token
```

Notice that the client's request is to _http_://api.stripe.com. In order for the proxy to be able to inject credentials into requests we need to speak plain HTTP to the proxy server, not HTTPS. The proxy transparently switches to HTTPS for connections to upstream services. We could use HTTPS for communication between the client and the proxy server, but flycast already uses WireGuard and the redundant encryption would only complicate things.
Notice that the client's request is to _http_://api.stripe.com. In order for the proxy to be able to inject credentials into requests we need to speak plain HTTP to the proxy server, not HTTPS. The proxy transparently switches to HTTPS for connections to upstream services. This assumes communication between the client and tokenizer happens over a secure transport (a VPN).

## Processors

The processor dictates how the encrypted secret gets turned into a credential and added to the request. The example above uses `inject_processor`, which simply injects the verbatim secret into a request header. By default, this injects the secret into the `Authorization` header without further processing.

The client can include parameters to change this behavior though:
The processor dictates how the encrypted secret gets turned into a credential and added to the request. The example above uses `inject_processor`, which simply injects the verbatim secret into a request header. By default, this injects the secret into the `Authorization: Bearer` header without further processing. The `inject_processor` can optionally specify a destination and/or printf-style format string to be applied to the injection of the credential:

```ruby
processor_params = {
dst: "My-Custom-Header",
fmt: "FooBar %s"
secret = {
inject_processor: {
token: "my-stripe-api-token",
dst: "X-Stripe-Token",
fmt: "token=%s",
},
bearer_auth: {
digest: Digest::SHA256.base64digest('trustno1')
}
}

conn.headers[:proxy_tokenizer] = "#{Base64.encode64(sealed_secret)}; #{processor_params.to_json}"

conn.get("http://api.stripe.com")
```

The request will get rewritten to look like this:
This will result in the header getting injected like this:

```http
GET / HTTP/1.1
Host: api.stripe.com
My-Custom-Header: FooBar my-stripe-api-key
X-Stripe-Token: token=my-stripe-api-key
```

The parameters are supplied as JSON in the `Proxy-Tokenizer` header after the encrypted secret. The `dst` parameter instructs the processor to put the secret in the `My-Custom-Header` header and the `fmt` parameter is a printf-style format string that is applied to the secret.

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. It also respects the `dst` and `fmt` parameters.
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
secret = {
Expand All @@ -83,6 +79,35 @@ secret = {
}
```

## Request-time parameters

If the destination/formatting might vary between requests, `inject_processor` and `inject_hmac_processor` can specify an allowlist of `dst`/`fmt` parameters that the client can specify at request time. These parameters are supplied as JSON in the `Proxy-Tokenizer` header after the encrypted secret.

```ruby
secret = {
inject_processor: {
token: "my-stripe-api-token"
allowed_dst: ["X-Stripe-Token", "Authorization"],
allowed_fmt: ["Bearer %s", "token=%s"],
},
bearer_auth: {
digest: Digest::SHA256.base64digest('trustno1')
}
}

seal_key = ENV["TOKENIZER_PUBLIC_KEY"]
sealed_secret = RbNaCl::Boxes::Sealed.new(seal_key).box(secret.to_json)

processor_params = {
dst: "X-Stripe-Token",
fmt: "token=%s"
}

conn.headers[:proxy_tokenizer] = "#{Base64.encode64(sealed_secret)}; #{processor_params.to_json}"

conn.get("http://api.stripe.com")
```

## Host allowlist

If a client is fully compromised, the attacker could send encrypted secrets via tokenizer to a service that simply echoes back the request. This way, the attacker could learn the plaintext value of the secret. To mitigate against this, secrets can specify which hosts they may be used against.
Expand Down
115 changes: 92 additions & 23 deletions processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import (
"fmt"
"io"
"net/http"
"net/textproto"
"strings"

"golang.org/x/exp/slices"
)

const (
Expand Down Expand Up @@ -45,6 +48,8 @@ type ProcessorConfig interface {

type InjectProcessorConfig struct {
Token string `json:"token"`
FmtProcessor
DstProcessor
}

var _ ProcessorConfig = new(InjectProcessorConfig)
Expand All @@ -54,22 +59,21 @@ func (c *InjectProcessorConfig) Processor(params map[string]string) (RequestProc
return nil, errors.New("missing token")
}

val, err := applyParamFmt(params[ParamFmt], false, c.Token)
val, err := c.ApplyFmt(params, false, c.Token)
if err != nil {
return nil, err
}

return func(r *http.Request) error {
applyParamDst(r, params[ParamDst], val)
return nil
return c.ApplyDst(params, r, val)
}, nil
}

func (c *InjectProcessorConfig) Updated() bool { return false }

type InjectHMACProcessorConfig struct {
Key []byte `json:"key"`
Hash string `json:"hash"`
FmtProcessor
DstProcessor
}

var _ ProcessorConfig = new(InjectHMACProcessorConfig)
Expand Down Expand Up @@ -105,14 +109,12 @@ func (c *InjectHMACProcessorConfig) Processor(params map[string]string) (Request
return err
}

val, err := applyParamFmt(params[ParamFmt], true, hm.Sum(nil))
val, err := c.ApplyFmt(params, true, hm.Sum(nil))
if err != nil {
return err
}

applyParamDst(r, params[ParamDst], val)

return nil
return c.ApplyDst(params, r, val)
}, nil
}

Expand All @@ -137,33 +139,46 @@ func (c *OAuthProcessorConfig) Processor(params map[string]string) (RequestProce
return nil, errors.New("missing token")
}

val, err := applyParamFmt(params[ParamFmt], false, token)
if err != nil {
return nil, err
}

return func(r *http.Request) error {
applyParamDst(r, params[ParamDst], val)
r.Header.Set("Authorization", "Bearer "+token)
return nil
}, nil
}

func applyParamDst(r *http.Request, dst string, value string) {
if dst == "" {
dst = "Authorization"
}
r.Header.Set(dst, value)
// A helper type to be embedded in RequestProcessors wanting to use the `fmt` config/param.
type FmtProcessor struct {
Fmt string `json:"fmt,omitempty"`
AllowedFmt []string `json:"allowed_fmt,omitempty"`
}

func applyParamFmt(format string, isBinary bool, arg any) (string, error) {
// Apply the format string from the secret config or parameters. isBinary
// dictates whether %s or %x should be allowed. arg is passed to the sprintf
// call.
func (fp FmtProcessor) ApplyFmt(params map[string]string, isBinary bool, arg any) (string, error) {
format, hasParam := params[ParamFmt]

// apply default for param
switch {
case format != "":
case hasParam:
// ok
case fp.Fmt != "":
format = fp.Fmt
case len(fp.AllowedFmt) != 0:
format = fp.AllowedFmt[0]
case isBinary:
format = "Bearer %x"
default:
format = "Bearer %s"
}

// check if param is allowed
if fp.Fmt != "" && format != fp.Fmt {
return "", errors.New("bad fmt")
}
if fp.AllowedFmt != nil && !slices.Contains(fp.AllowedFmt, format) {
return "", errors.New("bad fmt")
}

i := strings.IndexRune(format, '%')

switch i {
Expand All @@ -176,8 +191,16 @@ func applyParamFmt(format string, isBinary bool, arg any) (string, error) {
}

// only permit simple (%s, %x, %X) format directives
// TODO: implement ruby's %m (base64)
switch format[i+1] {
case 'x', 'X', 's':
case 'x', 'X':
if !isBinary {
return "", errors.New("bad fmt")
}
case 's':
if isBinary {
return "", errors.New("bad fmt")
}
default:
return "", errors.New("bad fmt")
}
Expand All @@ -189,3 +212,49 @@ func applyParamFmt(format string, isBinary bool, arg any) (string, error) {

return fmt.Sprintf(format, arg), nil
}

// A helper type to be embedded in RequestProcessors wanting to use the `dst` config/param.
type DstProcessor struct {
Dst string `json:"dst,omitempty"`
AllowedDst []string `json:"allowed_dst,omitempty"`
}

// Apply the specified val to the correct destination header in the request.
func (fp DstProcessor) ApplyDst(params map[string]string, r *http.Request, val string) error {
dst, hasParam := params[ParamDst]

// apply default for param
switch {
case hasParam:
// ok
case fp.Dst != "":
dst = fp.Dst
case len(fp.AllowedDst) != 0:
dst = fp.AllowedDst[0]
default:
dst = "Authorization"
}

dst = textproto.CanonicalMIMEHeaderKey(dst)

// check if param is allowed
if fp.Dst != "" && dst != textproto.CanonicalMIMEHeaderKey(fp.Dst) {
return errors.New("bad dst")
}
if fp.AllowedDst != nil {
var found bool
for _, a := range fp.AllowedDst {
if dst == textproto.CanonicalMIMEHeaderKey(a) {
found = true
break
}
}
if !found {
return errors.New("bad dst")
}
}

r.Header.Set(dst, val)

return nil
}
83 changes: 72 additions & 11 deletions processor_test.go
Original file line number Diff line number Diff line change
@@ -1,44 +1,105 @@
package tokenizer

import (
"bytes"
"net/http"
"strings"
"testing"

"github.com/alecthomas/assert/v2"
)

func TestApplyFmt(t *testing.T) {
val, err := applyParamFmt("", true, []byte{1, 2, 3})
func TestFmtProcessor(t *testing.T) {
p := FmtProcessor{}

val, err := p.ApplyFmt(map[string]string{}, true, []byte{1, 2, 3})
assert.NoError(t, err)
assert.Equal(t, "Bearer 010203", val)

val, err = applyParamFmt("", false, "123")
val, err = p.ApplyFmt(map[string]string{}, false, "123")
assert.NoError(t, err)
assert.Equal(t, "Bearer 123", val)

val, err = applyParamFmt("%x", true, []byte{1, 2, 3})
val, err = p.ApplyFmt(map[string]string{ParamFmt: "%x"}, true, []byte{1, 2, 3})
assert.NoError(t, err)
assert.Equal(t, "010203", val)

val, err = applyParamFmt("%X", true, []byte{1, 2, 3})
val, err = p.ApplyFmt(map[string]string{ParamFmt: "%X"}, true, []byte{1, 2, 3})
assert.NoError(t, err)
assert.Equal(t, "010203", val)

val, err = applyParamFmt("%s", false, "123")
val, err = p.ApplyFmt(map[string]string{ParamFmt: "%s"}, false, "123")
assert.NoError(t, err)
assert.Equal(t, "123", val)

_, err = applyParamFmt("%d", false, "123")
_, err = p.ApplyFmt(map[string]string{ParamFmt: "%d"}, false, "123")
assert.Error(t, err)

_, err = p.ApplyFmt(map[string]string{ParamFmt: "%.3s"}, false, "123")
assert.Error(t, err)

_, err = applyParamFmt("%.3s", false, "123")
_, err = p.ApplyFmt(map[string]string{ParamFmt: "%s%s"}, false, "123")
assert.Error(t, err)

_, err = applyParamFmt("%s%s", false, "123")
_, err = p.ApplyFmt(map[string]string{ParamFmt: "asdf%"}, false, "123")
assert.Error(t, err)

_, err = applyParamFmt("asdf%", false, "123")
_, err = p.ApplyFmt(map[string]string{ParamFmt: "asdf"}, false, "123")
assert.Error(t, err)

val, err = FmtProcessor{AllowedFmt: []string{"%s"}}.ApplyFmt(map[string]string{ParamFmt: "%s"}, false, "123")
assert.NoError(t, err)
assert.Equal(t, "123", val)

_, err = FmtProcessor{AllowedFmt: []string{"x %s"}}.ApplyFmt(map[string]string{ParamFmt: "%s"}, false, "123")
assert.Error(t, err)

_, err = applyParamFmt("asdf", false, "123")
val, err = FmtProcessor{Fmt: "%s"}.ApplyFmt(map[string]string{ParamFmt: "%s"}, false, "123")
assert.NoError(t, err)
assert.Equal(t, "123", val)

_, err = FmtProcessor{Fmt: "x %s"}.ApplyFmt(map[string]string{ParamFmt: "%s"}, false, "123")
assert.Error(t, err)

val, err = FmtProcessor{Fmt: "%s", AllowedFmt: []string{"%s"}}.ApplyFmt(map[string]string{ParamFmt: "%s"}, false, "123")
assert.NoError(t, err)
assert.Equal(t, "123", val)

val, err = FmtProcessor{Fmt: "%s", AllowedFmt: []string{"%s"}}.ApplyFmt(map[string]string{}, false, "123")
assert.NoError(t, err)
assert.Equal(t, "123", val)

_, err = FmtProcessor{Fmt: "%s", AllowedFmt: []string{"x %s"}}.ApplyFmt(map[string]string{}, false, "123")
assert.Error(t, err)
}

func TestDstProcessor(t *testing.T) {
assertResult := func(expected string, dp DstProcessor, params map[string]string) {
t.Helper()

r := http.Request{Header: make(http.Header)}
err := dp.ApplyDst(params, &r, "123")
if expected == "error" {
assert.Error(t, err)
} else {
assert.NoError(t, err)
buf := new(bytes.Buffer)
assert.NoError(t, r.Header.Write(buf))
assert.Equal(t, expected, strings.TrimSpace(buf.String()))
}
}

assertResult("Authorization: 123", DstProcessor{}, map[string]string{})
assertResult("Authorization: 123", DstProcessor{}, map[string]string{ParamDst: "Authorization"})
assertResult("Authorization: 123", DstProcessor{}, map[string]string{ParamDst: "AuThOriZaTiOn"})
assertResult("Foo: 123", DstProcessor{}, map[string]string{ParamDst: "Foo"})
assertResult("Foo: 123", DstProcessor{Dst: "Foo", AllowedDst: []string{"Foo"}}, map[string]string{ParamDst: "Foo"})
assertResult("Foo: 123", DstProcessor{Dst: "Foo", AllowedDst: []string{"fOo"}}, map[string]string{ParamDst: "foO"})
assertResult("Foo: 123", DstProcessor{AllowedDst: []string{"fOo"}}, map[string]string{ParamDst: "foO"})
assertResult("Foo: 123", DstProcessor{Dst: "Foo"}, map[string]string{ParamDst: "foO"})
assertResult("Foo: 123", DstProcessor{Dst: "Foo", AllowedDst: []string{"fOo"}}, map[string]string{})
assertResult("error", DstProcessor{Dst: "Foo", AllowedDst: []string{"Bar"}}, map[string]string{ParamDst: "Foo"})
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"})
}
Loading

0 comments on commit fe18ba0

Please sign in to comment.