Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds HeaderSHA256 predicate #1905

Merged
merged 1 commit into from
Dec 8, 2022
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
72 changes: 72 additions & 0 deletions docs/reference/predicates.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,78 @@ JWTPayloadAllKVRegexp("iss", "^https://")
JWTPayloadAnyKVRegexp("iss", "^https://")
```

### HeaderSHA256

Matches if SHA-256 hash of the header value (known as [pre-shared key](https://en.wikipedia.org/wiki/Pre-shared_key) or secret)
equals to any of the configured hash values.
Several hash values could be used to match multiple secrets e.g. during secret rotation.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same could be achieved using two routes with two different HeaderSHA256 but it is more convenient to use single predicate with multiple secrets to avoid route duplication, e.g. in zalando.org/skipper-predicate ingress annotation.


Hash values only hide secrets from parties that have access to the source of Skipper routes.
Authentication strength depends on the strength of the secret value so e.g.
`HeaderSHA256("X-Secret", "2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b")`
is not stronger than just `Header("X-Secret", "secret")`.
Comment on lines +387 to +390
Copy link
Member Author

@AlexanderYastrebov AlexanderYastrebov Nov 22, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discuss: add salt?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added guide on how to generate secure header value.
I think salt is not required if secret value is random, its length is at least 32 bytes (SHA-256 output size) and there is no need to hide the possible reuse of the same secret value in several routes.

In general proper salt value has its own requirements so the idea is to get rid of it if possible to minimize number of parameters that users need to specify and understand.


The secret value must be kept secret, must be used by a single client and must be rotated periodically.
See below how to generate random secret value using [OpenSSL](https://www.openssl.org/docs/man1.1.1/man1/rand.html).

Parameters:

* header name (string)
* one or more hex-encoded SHA-256 hashes of the matching header values (string)

Secure secret value example:
```sh
#
# 1. Generate cryptographically secure pseudo random secret header value:
# - length of at least 32 bytes (the size of the SHA-256 output)
# - encode as -base64 or -hex to get ASCII text value
#
SECRET=$(openssl rand -base64 32)
echo $SECRET
3YchPsliGjBXvyl/ncLWEI8/loKGrj/VNM4garxWEmA=

#
# 2. Get SHA-256 hash of the secret header value to use as HeaderSHA256 argument:
# - use echo -n to not output the trailing newline
#
echo -n $SECRET | sha256sum
a6131ba920df753c8109500cc11818f7192336d06532f6fa13009c2e4f6e1841 -
```
```
// 3. Configure route to match hash of the secret value
HeaderSHA256(
"X-Secret",
"a6131ba920df753c8109500cc11818f7192336d06532f6fa13009c2e4f6e1841"
) -> inlineContent("ok\n") -> <shunt>
```
```sh
# 4. Test secret value
curl -H "X-Secret: $SECRET" http://localhost:9090
```

Secret rotation example:
```
// To rotate secret:
// * add new secret - both old and new secrets match during rotation
// * update client to use new secret
// * remove old secret
HeaderSHA256(
"X-Secret",
"cba06b5736faf67e54b07b561eae94395e774c517a7d910a54369e1263ccfbd4", // SHA256("old")
"11507a0e2f5e69d5dfa40a62a1bd7b6ee57e6bcd85c67c9b8431b36fff21c437" // SHA256("new")
) -> inlineContent("ok\n") -> <shunt>
```

[Basic access authentication](https://en.wikipedia.org/wiki/Basic_access_authentication) example:
```
anon: * -> setResponseHeader("WWW-Authenticate", `Basic realm="foo", charset="UTF-8"`) -> status(401) -> <shunt>;
auth: HeaderSHA256(
"Authorization",
"caae07e42ed8d231a58edcde95782b0feb67186172c18c89894ce4c2174df137", // SHA256("Basic " + BASE64("test:123£"))
"157da8472590f0ce0a7c651bd79aecb5cc582944fcf76cbabada915d333deee8" // SHA256("Basic " + BASE64("Aladdin:open sesame"))
) -> inlineContent("ok\n") -> <shunt>;
```

## Interval

An interval implements custom predicates to match routes only during some period of time.
Expand Down
74 changes: 74 additions & 0 deletions predicates/auth/headersha256.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package auth

import (
"crypto/sha256"
"encoding/hex"
"net/http"

"github.com/zalando/skipper/predicates"
"github.com/zalando/skipper/routing"
)

type headerSha256Spec struct{}

type headerSha256Predicate struct {
name string
hashes [][sha256.Size]byte
}

// NewHeaderSHA256 creates a predicate specification, whose instances match SHA-256 hash of the header value.
// The HeaderSHA256 predicate requires the header name and one or more hex-encoded SHA-256 hash values of the matching header.
func NewHeaderSHA256() routing.PredicateSpec {
return &headerSha256Spec{}
}

func (*headerSha256Spec) Name() string {
return predicates.HeaderSHA256Name
}

// Create a predicate instance matching SHA256 hash of the header value
func (*headerSha256Spec) Create(args []interface{}) (routing.Predicate, error) {
if len(args) < 2 {
return nil, predicates.ErrInvalidPredicateParameters
}

name, ok := args[0].(string)
if !ok {
return nil, predicates.ErrInvalidPredicateParameters
}

var hashes [][sha256.Size]byte
for _, arg := range args[1:] {
hexHash, ok := arg.(string)
if !ok {
return nil, predicates.ErrInvalidPredicateParameters
}
hash, err := hex.DecodeString(hexHash)
if err != nil {
return nil, err
}
if len(hash) != sha256.Size {
return nil, predicates.ErrInvalidPredicateParameters
}
hashes = append(hashes, *(*[sha256.Size]byte)(hash)) // https://github.com/golang/go/issues/46505
}

return &headerSha256Predicate{name, hashes}, nil
}

func (p *headerSha256Predicate) Match(r *http.Request) bool {
value := r.Header.Get(p.name)
if value == "" {
return false
}

valueHash := sha256.Sum256([]byte(value))

for _, hash := range p.hashes {
if valueHash == hash {
return true
}
}

return false
}
103 changes: 103 additions & 0 deletions predicates/auth/headersha256_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package auth

import (
"net/http"
"testing"
)

func TestHeaderSHA256Args(t *testing.T) {
s := NewHeaderSHA256()
for _, tc := range []struct {
args []interface{}
}{
{
args: []interface{}{},
},
{
args: []interface{}{"X-Secret", "xyz"},
},
{
args: []interface{}{"X-Secret", "00112233"},
},
{
args: []interface{}{"X-Secret", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", "AA"},
},
} {
if _, err := s.Create(tc.args); err == nil {
t.Errorf("expected error for arguments: %v", tc.args)
}
}
}

func TestHeaderSHA256Match(t *testing.T) {
s := NewHeaderSHA256()
p, err := s.Create([]interface{}{
"X-Secret",
"2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b", // "secret"
"5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8", // "password"
})
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
header http.Header
match bool
}{
{
header: http.Header{
"X-Test": []string{"foo"},
},
match: false,
},
{
header: http.Header{
"X-Secret": []string{"foo"},
},
match: false,
},
{
header: http.Header{
"X-Secret": []string{"SECRET"},
},
match: false,
},
{
header: http.Header{
"X-Secret": []string{"PASSWORD"},
},
match: false,
},
{
header: http.Header{
"X-Secret": []string{"secret"},
},
match: true,
},
{
header: http.Header{
"X-Secret": []string{"password"},
},
match: true,
},
} {
if p.Match(&http.Request{Header: tc.header}) != tc.match {
t.Errorf("expected match: %v", tc.match)
}
}
}

func BenchmarkHeaderSHA256Match(b *testing.B) {
s := NewHeaderSHA256()
p, err := s.Create([]interface{}{"X-Secret", "2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b"})
if err != nil {
b.Fatal(err)
}
r := &http.Request{Header: http.Header{"X-Secret": []string{"secret"}}}
if !p.Match(r) {
b.Fatal("match expected")
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
p.Match(r)
}
}
1 change: 1 addition & 0 deletions predicates/predicates.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const (
JWTPayloadAllKVName = "JWTPayloadAllKV"
JWTPayloadAnyKVRegexpName = "JWTPayloadAnyKVRegexp"
JWTPayloadAllKVRegexpName = "JWTPayloadAllKVRegexp"
HeaderSHA256Name = "HeaderSHA256"
AfterName = "After"
BeforeName = "Before"
BetweenName = "Between"
Expand Down
1 change: 1 addition & 0 deletions skipper.go
Original file line number Diff line number Diff line change
Expand Up @@ -1733,6 +1733,7 @@ func run(o Options, sig chan os.Signal, idleConnsCH chan struct{}) error {
pauth.NewJWTPayloadAnyKV(),
pauth.NewJWTPayloadAllKVRegexp(),
pauth.NewJWTPayloadAnyKVRegexp(),
pauth.NewHeaderSHA256(),
methods.New(),
tee.New(),
forwarded.NewForwardedHost(),
Expand Down