From e4a31670c00dbfee0b323274aed0854b65761c6b Mon Sep 17 00:00:00 2001 From: Alexander Yastrebov Date: Wed, 7 Dec 2022 18:15:56 +0100 Subject: [PATCH] Adds `HeaderSHA256` predicate `HeaderSHA256` predicate matches SHA-256 hash of the configured header value. ``` go test ./predicates/auth/ -run NONE -bench BenchmarkHeaderSHA256Match -benchmem goos: linux goarch: amd64 pkg: github.com/zalando/skipper/predicates/auth cpu: Intel(R) Core(TM) i5-8350U CPU @ 1.70GHz BenchmarkHeaderSHA256Match-8 4675351 237.2 ns/op 0 B/op 0 allocs/op ``` Signed-off-by: Alexander Yastrebov --- docs/reference/predicates.md | 72 +++++++++++++++++++ predicates/auth/headersha256.go | 74 +++++++++++++++++++ predicates/auth/headersha256_test.go | 103 +++++++++++++++++++++++++++ predicates/predicates.go | 1 + skipper.go | 1 + 5 files changed, 251 insertions(+) create mode 100644 predicates/auth/headersha256.go create mode 100644 predicates/auth/headersha256_test.go diff --git a/docs/reference/predicates.md b/docs/reference/predicates.md index 76b277065b..a3a3de2144 100644 --- a/docs/reference/predicates.md +++ b/docs/reference/predicates.md @@ -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. + +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")`. + +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") -> +``` +```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") -> +``` + +[Basic access authentication](https://en.wikipedia.org/wiki/Basic_access_authentication) example: +``` +anon: * -> setResponseHeader("WWW-Authenticate", `Basic realm="foo", charset="UTF-8"`) -> status(401) -> ; +auth: HeaderSHA256( + "Authorization", + "caae07e42ed8d231a58edcde95782b0feb67186172c18c89894ce4c2174df137", // SHA256("Basic " + BASE64("test:123£")) + "157da8472590f0ce0a7c651bd79aecb5cc582944fcf76cbabada915d333deee8" // SHA256("Basic " + BASE64("Aladdin:open sesame")) +) -> inlineContent("ok\n") -> ; +``` + ## Interval An interval implements custom predicates to match routes only during some period of time. diff --git a/predicates/auth/headersha256.go b/predicates/auth/headersha256.go new file mode 100644 index 0000000000..3103c4c894 --- /dev/null +++ b/predicates/auth/headersha256.go @@ -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 +} diff --git a/predicates/auth/headersha256_test.go b/predicates/auth/headersha256_test.go new file mode 100644 index 0000000000..2a22de7c7c --- /dev/null +++ b/predicates/auth/headersha256_test.go @@ -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) + } +} diff --git a/predicates/predicates.go b/predicates/predicates.go index 5655023297..5596895952 100644 --- a/predicates/predicates.go +++ b/predicates/predicates.go @@ -33,6 +33,7 @@ const ( JWTPayloadAllKVName = "JWTPayloadAllKV" JWTPayloadAnyKVRegexpName = "JWTPayloadAnyKVRegexp" JWTPayloadAllKVRegexpName = "JWTPayloadAllKVRegexp" + HeaderSHA256Name = "HeaderSHA256" AfterName = "After" BeforeName = "Before" BetweenName = "Between" diff --git a/skipper.go b/skipper.go index 247f9d2aac..29a12d5ed7 100644 --- a/skipper.go +++ b/skipper.go @@ -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(),