added IsTrustedIssuer + tests
This commit is contained in:
parent
ed2297e2ad
commit
e753b5a1a6
|
@ -11,6 +11,8 @@ package keyfetch
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
@ -253,3 +255,166 @@ func clear() {
|
||||||
func normalizeIssuer(iss string) string {
|
func normalizeIssuer(iss string) string {
|
||||||
return strings.TrimRight(iss, "/")
|
return strings.TrimRight(iss, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
IsTrustedIssuer returns true when the `iss` (i.e. from a token) matches one
|
||||||
|
in the provided whitelist (also matches wildcard domains).
|
||||||
|
|
||||||
|
You may explicitly allow insecure http (i.e. for automated testing) by
|
||||||
|
including http:// Otherwise the scheme in each item of the whitelist should
|
||||||
|
include the "https://" prefix.
|
||||||
|
|
||||||
|
SECURITY CONSIDERATIONS (Please Read)
|
||||||
|
|
||||||
|
You'll notice that *http.Request is optional. It should only be used under these
|
||||||
|
three circumstances:
|
||||||
|
|
||||||
|
1) Something else guarantees http -> https redirection happens before the
|
||||||
|
connection gets here AND this server directly handles TLS/SSL.
|
||||||
|
|
||||||
|
2) If you're using a load balancer or web server, and this doesn't handle
|
||||||
|
TLS/SSL directly, that server is _explicitly_ configured to protect
|
||||||
|
against Domain Fronting attacks. As of 2019, most web servers and load
|
||||||
|
balancers do not protect against that by default.
|
||||||
|
|
||||||
|
3) If you only use it to make your automated integration testing more
|
||||||
|
and it isn't enabled in production.
|
||||||
|
|
||||||
|
Otherwise, DO NOT pass in *http.Request as you will introduce a 0-day
|
||||||
|
vulnerability allowing an attacker to spoof any token issuer of their choice.
|
||||||
|
The only reason I allowed this in a public library where non-experts would
|
||||||
|
encounter it is to make testing easier.
|
||||||
|
*/
|
||||||
|
func IsTrustedIssuer(iss string, whitelist Whitelist, rs ...*http.Request) bool {
|
||||||
|
// Normalize the http:// and https:// and parse
|
||||||
|
iss = strings.TrimRight(iss, "/") + "/"
|
||||||
|
if strings.HasPrefix(iss, "http://") {
|
||||||
|
// ignore
|
||||||
|
} else if strings.HasPrefix(iss, "//") {
|
||||||
|
return false // TODO
|
||||||
|
} else if !strings.HasPrefix(iss, "https://") {
|
||||||
|
iss = "https://" + iss
|
||||||
|
}
|
||||||
|
issURL, err := url.Parse(iss)
|
||||||
|
if nil != err {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that
|
||||||
|
// * schemes match (https: == https:)
|
||||||
|
// * paths match (/foo/ == /foo/, always with trailing slash added)
|
||||||
|
// * hostnames are compatible (a == b or "sub.foo.com".HasSufix(".foo.com"))
|
||||||
|
for i := range []*url.URL(whitelist) {
|
||||||
|
u := whitelist[i]
|
||||||
|
|
||||||
|
if issURL.Scheme != u.Scheme {
|
||||||
|
continue
|
||||||
|
} else if u.Path != strings.TrimRight(issURL.Path, "/")+"/" {
|
||||||
|
continue
|
||||||
|
} else if issURL.Host != u.Host {
|
||||||
|
if '.' == u.Host[0] && strings.HasSuffix(issURL.Host, u.Host) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// All failures have been handled
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if implicit issuer is available
|
||||||
|
if 0 == len(rs) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return hasImplicitTrust(issURL, rs[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasImplicitTrust relies on the security of DNS and TLS to determine if the
|
||||||
|
// headers of the request can be trusted as identifying the server itself as
|
||||||
|
// a valid issuer, without additional configuration.
|
||||||
|
//
|
||||||
|
// Helpful for testing, but in the wrong hands could easily lead to a zero-day.
|
||||||
|
func hasImplicitTrust(issURL *url.URL, r *http.Request) bool {
|
||||||
|
if nil == r {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity check that, if a load balancer exists, it isn't misconfigured
|
||||||
|
proto := r.Header.Get("X-Forwarded-Proto")
|
||||||
|
if "" != proto && proto != "https" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the host
|
||||||
|
// * If TLS, block Domain Fronting
|
||||||
|
// * Otherwise assume trusted proxy
|
||||||
|
// * Otherwise assume test environment
|
||||||
|
var host string
|
||||||
|
if nil != r.TLS {
|
||||||
|
// Note that if this were to be implemented for HTTP/2 it would need to
|
||||||
|
// check all names on the certificate, not just the one with which the
|
||||||
|
// original connection was established. However, not our problem here.
|
||||||
|
// See https://serverfault.com/a/908087/93930
|
||||||
|
if r.TLS.ServerName != r.Host {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
host = r.Host
|
||||||
|
} else {
|
||||||
|
host = r.Header.Get("X-Forwarded-Host")
|
||||||
|
if "" == host {
|
||||||
|
host = r.Host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same tests as above, adjusted since it can't handle wildcards and, since
|
||||||
|
// the path is variable, we make the assumption that a child can trust a
|
||||||
|
// parent, but that a parent cannot trust a child.
|
||||||
|
if r.Host != issURL.Host {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(strings.TrimRight(r.URL.Path, "/")+"/", issURL.Path) {
|
||||||
|
// Ex: Request URL Token Issuer
|
||||||
|
// !"https:example.com/johndoe/api/dothing".HasPrefix("https:example.com/")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whitelist
|
||||||
|
type Whitelist []*url.URL
|
||||||
|
|
||||||
|
// NewWhitelist turns an array of URLs (such as https://example.com/) into
|
||||||
|
// a parsed array of *url.URLs that can be used by the IsTrustedIssuer function
|
||||||
|
func NewWhitelist(issuers []string, insecures ...bool) (Whitelist, error) {
|
||||||
|
list := []*url.URL{}
|
||||||
|
insecure := false
|
||||||
|
if 0 != len(insecures) && insecures[0] {
|
||||||
|
insecure = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range issuers {
|
||||||
|
iss := issuers[i]
|
||||||
|
if strings.HasPrefix(iss, "http://") {
|
||||||
|
if !insecure {
|
||||||
|
return nil, errors.New("Oops! You have an insecure domain in your whitelist: " + iss)
|
||||||
|
}
|
||||||
|
} else if strings.HasPrefix(iss, "//") {
|
||||||
|
// TODO
|
||||||
|
return nil, errors.New("Rather than prefixing with // to support multiple protocols, add them seperately:" + iss)
|
||||||
|
} else if !strings.HasPrefix(iss, "https://") {
|
||||||
|
iss = "https://" + iss
|
||||||
|
}
|
||||||
|
// trailing slash as a boundary character, which may or may not denote a directory
|
||||||
|
iss = strings.TrimRight(iss, "/") + "/"
|
||||||
|
u, err := url.Parse(iss)
|
||||||
|
if nil != err {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(u.Host, "*.") {
|
||||||
|
u.Host = u.Host[1:]
|
||||||
|
}
|
||||||
|
list = append(list, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Whitelist(list), nil
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,134 @@
|
||||||
|
package keyfetch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInvalidIssuer(t *testing.T) {
|
||||||
|
_, err := NewWhitelist([]string{"somethingorother"})
|
||||||
|
if nil == err {
|
||||||
|
t.Log("invalid http urls can get through, but that's okay")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = NewWhitelist([]string{"//example.com/foo"})
|
||||||
|
if nil == err {
|
||||||
|
t.Fatal(errors.New("semi-bad url got through"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssuerMatches(t *testing.T) {
|
||||||
|
trusted := []string{
|
||||||
|
"https://example.com/",
|
||||||
|
"http://happy.xyz/abc",
|
||||||
|
"foobar.net/def/",
|
||||||
|
"https://*.wild.org",
|
||||||
|
"https://*.west.mali/verde",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := NewWhitelist(trusted)
|
||||||
|
if nil == err {
|
||||||
|
t.Fatal(errors.New("An insecure domain got through!"))
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := NewWhitelist(trusted, true)
|
||||||
|
if nil != err {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var iss string
|
||||||
|
iss = "https://example.com"
|
||||||
|
if !IsTrustedIssuer(iss, list) {
|
||||||
|
t.Fatal("A good domain didn't make it:", iss)
|
||||||
|
}
|
||||||
|
|
||||||
|
iss = "https://example.com/"
|
||||||
|
if !IsTrustedIssuer(iss, list) {
|
||||||
|
t.Fatal("A good domain didn't make it:", iss)
|
||||||
|
}
|
||||||
|
|
||||||
|
iss = "http://example.com"
|
||||||
|
if IsTrustedIssuer(iss, list) {
|
||||||
|
t.Fatal("A bad URL slipped past", iss)
|
||||||
|
}
|
||||||
|
|
||||||
|
iss = "https://example.com/foo"
|
||||||
|
if IsTrustedIssuer(iss, list) {
|
||||||
|
t.Fatal("A bad URL slipped past", iss)
|
||||||
|
}
|
||||||
|
|
||||||
|
iss = "http://happy.xyz/abc"
|
||||||
|
if !IsTrustedIssuer(iss, list) {
|
||||||
|
t.Fatal("A good URL didn't make it:", iss)
|
||||||
|
}
|
||||||
|
|
||||||
|
iss = "http://happy.xyz/abc/"
|
||||||
|
if !IsTrustedIssuer(iss, list) {
|
||||||
|
t.Fatal("A good URL didn't make it:", iss)
|
||||||
|
}
|
||||||
|
|
||||||
|
iss = "http://happy.xyz/abc/d"
|
||||||
|
if IsTrustedIssuer(iss, list) {
|
||||||
|
t.Fatal("A bad URL slipped past", iss)
|
||||||
|
}
|
||||||
|
|
||||||
|
iss = "http://happy.xyz/abcd"
|
||||||
|
if IsTrustedIssuer(iss, list) {
|
||||||
|
t.Fatal("A bad URL slipped past", iss)
|
||||||
|
}
|
||||||
|
|
||||||
|
iss = "https://foobar.net/def"
|
||||||
|
if !IsTrustedIssuer(iss, list) {
|
||||||
|
t.Fatal("A good URL didn't make it:", iss)
|
||||||
|
}
|
||||||
|
|
||||||
|
iss = "https://foobar.net/def/"
|
||||||
|
if !IsTrustedIssuer(iss, list) {
|
||||||
|
t.Fatal("A good URL didn't make it:", iss)
|
||||||
|
}
|
||||||
|
|
||||||
|
iss = "http://foobar.net/def/"
|
||||||
|
if IsTrustedIssuer(iss, list) {
|
||||||
|
t.Fatal("A bad URL slipped past", iss)
|
||||||
|
}
|
||||||
|
|
||||||
|
iss = "https://foobar.net/def/e"
|
||||||
|
if IsTrustedIssuer(iss, list) {
|
||||||
|
t.Fatal("A bad URL slipped past", iss)
|
||||||
|
}
|
||||||
|
|
||||||
|
iss = "https://foobar.net/defe"
|
||||||
|
if IsTrustedIssuer(iss, list) {
|
||||||
|
t.Fatal("A bad URL slipped past", iss)
|
||||||
|
}
|
||||||
|
|
||||||
|
iss = "https://wild.org"
|
||||||
|
if IsTrustedIssuer(iss, list) {
|
||||||
|
t.Fatal("A bad URL slipped past", iss)
|
||||||
|
}
|
||||||
|
|
||||||
|
iss = "https://foo.wild.org"
|
||||||
|
if !IsTrustedIssuer(iss, list) {
|
||||||
|
t.Fatal("A good URL didn't make it:", iss)
|
||||||
|
}
|
||||||
|
|
||||||
|
iss = "https://sub.foo.wild.org"
|
||||||
|
if !IsTrustedIssuer(iss, list) {
|
||||||
|
t.Fatal("A good URL didn't make it:", iss)
|
||||||
|
}
|
||||||
|
|
||||||
|
iss = "https://foo.wild.org/cherries"
|
||||||
|
if IsTrustedIssuer(iss, list) {
|
||||||
|
t.Fatal("A bad URL slipped past", iss)
|
||||||
|
}
|
||||||
|
|
||||||
|
iss = "https://sub.west.mali/verde/"
|
||||||
|
if !IsTrustedIssuer(iss, list) {
|
||||||
|
t.Fatal("A good URL didn't make it:", iss)
|
||||||
|
}
|
||||||
|
|
||||||
|
iss = "https://sub.west.mali"
|
||||||
|
if IsTrustedIssuer(iss, list) {
|
||||||
|
t.Fatal("A bad URL slipped past", iss)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue