Skip to content

Commit

Permalink
Sign upstream requests with a verifiable digital signature.
Browse files Browse the repository at this point in the history
  • Loading branch information
katzdm committed Nov 19, 2018
1 parent cba4491 commit 993e32d
Show file tree
Hide file tree
Showing 8 changed files with 610 additions and 23 deletions.
81 changes: 65 additions & 16 deletions internal/proxy/oauthproxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import (
"github.com/datadog/datadog-go/statsd"
)

// SignatureHeader is the header name where the signed request header is stored.
const SignatureHeader = "Gap-Signature"
// HMACSignatureHeader is the header name where the signed request header is stored.
const HMACSignatureHeader = "Gap-Signature"

// SignatureHeaders are the headers that are valid in the request.
var SignatureHeaders = []string{
Expand Down Expand Up @@ -74,6 +74,9 @@ type OAuthProxy struct {

mux map[string]*route
regexRoutes []*route

requestSigner *RequestSigner
publicCertsJSON []byte
}

type route struct {
Expand All @@ -93,11 +96,19 @@ type StateParameter struct {

// UpstreamProxy stores information necessary for proxying the request back to the upstream.
type UpstreamProxy struct {
name string
cookieName string
handler http.Handler
auth hmacauth.HmacAuth
statsdClient *statsd.Client
name string
cookieName string
handler http.Handler
auth hmacauth.HmacAuth
requestSigner *RequestSigner
statsdClient *statsd.Client
}

// Certificates stores certs that will be publicly exported via the `/v1/certs` endpoint. These may
// be utilized by upstreams to check the integrity of a request, by validating the `Sso-Signature`
// header value.
type Certificates struct {
PublicKey string `json:"signing_key"`
}

// deleteSSOCookieHeader deletes the session cookie from the request header string.
Expand All @@ -118,6 +129,9 @@ func (u *UpstreamProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if u.auth != nil {
u.auth.SignRequest(r)
}
if u.requestSigner != nil {
u.requestSigner.Sign(r)
}

start := time.Now()
u.handler.ServeHTTP(w, r)
Expand Down Expand Up @@ -211,13 +225,17 @@ func NewRewriteReverseProxy(route *RewriteRoute, config *UpstreamConfig) *httput
}

// NewReverseProxyHandler creates a new http.Handler given a httputil.ReverseProxy
func NewReverseProxyHandler(reverseProxy *httputil.ReverseProxy, opts *Options, config *UpstreamConfig) (http.Handler, []string) {
func NewReverseProxyHandler(reverseProxy *httputil.ReverseProxy, opts *Options, config *UpstreamConfig, signer *RequestSigner) (http.Handler, []string) {
upstreamProxy := &UpstreamProxy{
name: config.Service,
handler: reverseProxy,
auth: config.HMACAuth,
cookieName: opts.CookieName,
statsdClient: opts.StatsdClient,
name: config.Service,
handler: reverseProxy,
auth: config.HMACAuth,
cookieName: opts.CookieName,
statsdClient: opts.StatsdClient,
requestSigner: signer,
}
if config.SkipRequestSigning {
upstreamProxy.requestSigner = nil
}
if config.FlushInterval != 0 {
return NewStreamingHandler(upstreamProxy, opts, config), []string{"handler:streaming"}
Expand Down Expand Up @@ -255,7 +273,7 @@ func generateHmacAuth(signatureKey string) (hmacauth.HmacAuth, error) {
if err != nil {
return nil, fmt.Errorf("unsupported signature hash algorithm: %s", algorithm)
}
auth := hmacauth.NewHmacAuth(hash, []byte(secret), SignatureHeader, SignatureHeaders)
auth := hmacauth.NewHmacAuth(hash, []byte(secret), HMACSignatureHeader, SignatureHeaders)
return auth, nil
}

Expand Down Expand Up @@ -283,6 +301,25 @@ func NewOAuthProxy(opts *Options, optFuncs ...func(*OAuthProxy) error) (*OAuthPr
c.Run()
}()

// Configure the RequestSigner (used to sign requests with `Sso-Signature` header).
// Also build the `certs` static JSON-string which will be served from a public endpoint.
// The key published at this endpoint allows upstreams to decrypt the `Sso-Signature`
// header, and validate the integrity and authenticity of a request.
var certs = Certificates{}
var requestSigner *RequestSigner
if len(opts.RequestSigningKey) > 0 {
if requestSigner, err = NewRequestSigner(opts.RequestSigningKey); err != nil {
return nil, fmt.Errorf("could not build RequestSigner: %s", err)
}
certs.PublicKey = requestSigner.PublicKey()
} else {
logger.Warn("Running OAuthProxy without signing key. Requests will not be signed.")
}
certsAsStr, err := json.MarshalIndent(certs, "", " ")
if err != nil {
return nil, fmt.Errorf("could not marshal public certs as JSON: %s", err)
}

p := &OAuthProxy{
CookieCipher: cipher,
CookieDomain: opts.CookieDomain,
Expand All @@ -303,6 +340,9 @@ func NewOAuthProxy(opts *Options, optFuncs ...func(*OAuthProxy) error) (*OAuthPr
redirectURL: &url.URL{Path: "/oauth2/callback"},
skipAuthPreflight: opts.SkipAuthPreflight,
templates: getTemplates(),

requestSigner: requestSigner,
publicCertsJSON: certsAsStr,
}

for _, optFunc := range optFuncs {
Expand All @@ -316,11 +356,13 @@ func NewOAuthProxy(opts *Options, optFuncs ...func(*OAuthProxy) error) (*OAuthPr
switch route := upstreamConfig.Route.(type) {
case *SimpleRoute:
reverseProxy := NewReverseProxy(route.ToURL, upstreamConfig)
handler, tags := NewReverseProxyHandler(reverseProxy, opts, upstreamConfig)
handler, tags := NewReverseProxyHandler(
reverseProxy, opts, upstreamConfig, requestSigner)
p.Handle(route.FromURL.Host, handler, tags, upstreamConfig)
case *RewriteRoute:
reverseProxy := NewRewriteReverseProxy(route, upstreamConfig)
handler, tags := NewReverseProxyHandler(reverseProxy, opts, upstreamConfig)
handler, tags := NewReverseProxyHandler(
reverseProxy, opts, upstreamConfig, requestSigner)
p.HandleRegex(route.FromRegex, handler, tags, upstreamConfig)
default:
return nil, fmt.Errorf("unknown route type")
Expand All @@ -335,6 +377,7 @@ func (p *OAuthProxy) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/favicon.ico", p.Favicon)
mux.HandleFunc("/robots.txt", p.RobotsTxt)
mux.HandleFunc("/oauth2/v1/certs", p.Certs)
mux.HandleFunc("/oauth2/sign_out", p.SignOut)
mux.HandleFunc("/oauth2/callback", p.OAuthCallback)
mux.HandleFunc("/oauth2/auth", p.AuthenticateOnly)
Expand Down Expand Up @@ -533,6 +576,12 @@ func (p *OAuthProxy) RobotsTxt(rw http.ResponseWriter, _ *http.Request) {
fmt.Fprintf(rw, "User-agent: *\nDisallow: /")
}

// Certs publishes the public key necessary for upstream services to validate the digital signature
// used to sign each request.
func (p *OAuthProxy) Certs(rw http.ResponseWriter, _ *http.Request) {
rw.Write(p.publicCertsJSON)
}

// Favicon will proxy the request as usual if the user is already authenticated
// but responds with a 404 otherwise, to avoid spurious and confusing
// authentication attempts when a browser automatically requests the favicon on
Expand Down
135 changes: 134 additions & 1 deletion internal/proxy/oauthproxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,73 @@ func TestRobotsTxt(t *testing.T) {
testutil.Equal(t, "User-agent: *\nDisallow: /", rw.Body.String())
}

func TestCerts(t *testing.T) {
opts := NewOptions()
opts.ClientID = "bazquux"
opts.ClientSecret = "foobar"
opts.CookieSecret = testEncodedCookieSecret
opts.ProviderURLString = "https://auth.sso.dev"
opts.upstreamConfigs = generateTestUpstreamConfigs("foo-internal.sso.dev")

// Dummy key; generated only for this test.
opts.RequestSigningKey = string(`-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCy38IQCH8QyeNF
s1zA0XuIyqnTcSfYZg0nPfB+K//pFy7tIOAwmR6th8NykrxFhEQDHKNCmLXt4j8V
FDHQZtGjUBHRmAXZW8NOQ0EI1vc/Dpt09sU40JQlXZZeL+9/7iAxEfSE3TQr1k7P
Xwxpjm9rsLSn7FoLnvXco0mc6+d2jjxf4cMgJIaQLKOd783KUQzLVEvBQJ05JnpI
2xMjS0q33ltMTMGF3QZQN9i4bZKgnItomKxTJbfxftO11FTNLB7og94sWmlThAY5
/UMjZaWYJ1g89+WUJ+KpVYyJsHPBBkaQG+NYazcLDyIowpzJ1WVkInysshpTqwT+
UPV4at+jAgMBAAECggEAX8lxK5LRMJVcLlwRZHQJekRE0yS6WKi1jHkfywEW5qRy
jatYQs4MXpLgN/+Z8IQWw6/XQXdznTLV4xzQXDBjPNhI4ntNTotUOBnNvsUW296f
ou/uxzDy1FuchU2YLGLBPGXIEko+gOcfhu74P6J1yi5zX6UyxxxVvtR2PCEb7yDw
m2881chwMblZ5Z8uyF++ajkK3/rqLk64w29+K4ZTDbTcCp5NtBYx2qSEU7yp12rc
qscUGqxG00Abx+osI3cUn0kOq7356LeR1rfA15yZwOb+s28QYp2WPlVB2hOiYXQv
+ttEOpt0x1QJhBAsFgwY173sD5w2MryRQb1RCwBvqQKBgQDeTdbRzxzAl83h/mAq
5I+pNEz57veAFVO+iby7TbZ/0w6q+QeT+bHF+TjGHiSlbtg3nd9NPrex2UjiN7ej
+DrxhsSLsP1ZfwDNv6f1Ii1HluJclUFSUNU/LntBjqqCJ959lniNp1y5+ZQ/j2Rf
+ZraVsHRB0itilFeAl5+n7CfxwKBgQDN/K+E1TCbp1inU60Lc9zeb8fqTEP6Mp36
qQ0Dp+KMLPJ0xQSXFq9ILr4hTJlBqfmTkfmQUcQuwercZ3LNQPbsuIg96bPW73R1
toXjokd6jUn5sJXCOE0RDumcJrL1VRf9RN1AmM4CgCc/adUMjws3pBc5R4An7UyU
ouRQhN+5RQKBgFOVTrzqM3RSX22mWAAomb9T09FxQQueeTM91IFUMdcTwwMTyP6h
Nm8qSmdrM/ojmBYpPKlteGHdQaMUse5rybXAJywiqs84ilPRyNPJOt8c4xVOZRYP
IG62Ck/W1VNErEnqBn+0OpAOP+g6ANJ5JfkL/6mZJIFjbT58g4z2e9FHAoGBAM3f
uBkd7lgTuLJ8Gh6xLVYQCJHuqZ49ytFE9qHpwK5zGdyFMSJE5OlS9mpXoXEUjkHk
iraoUlidLbwdlIr6XBCaGmku07SFXTNtOoIZpjEhV4c762HTXYsoCWos733uD2zt
z+iJEJVFOnTRtMK5kO+KjD+Oa9L8BCcmauTi+Ku1AoGAZBUzi95THA60hPXI0hm/
o0J5mfLkFPfhpUmDAMaEpv3bM4byA+IGXSZVc1IZO6cGoaeUHD2Yl1m9a5tv5rF+
FS9Ht+IgATvGojah+xxQy+kf6tRB9Hn4scyq+64AesXlDbWDEagomQ0hyV/JKSS6
LQatvnCmBd9omRT2uwYUo+o=
-----END PRIVATE KEY-----`)
opts.Validate()

expectedPublicKey := string(`-----BEGIN RSA PUBLIC KEY-----
MIIBCgKCAQEAst/CEAh/EMnjRbNcwNF7iMqp03En2GYNJz3wfiv/6Rcu7SDgMJke
rYfDcpK8RYREAxyjQpi17eI/FRQx0GbRo1AR0ZgF2VvDTkNBCNb3Pw6bdPbFONCU
JV2WXi/vf+4gMRH0hN00K9ZOz18MaY5va7C0p+xaC5713KNJnOvndo48X+HDICSG
kCyjne/NylEMy1RLwUCdOSZ6SNsTI0tKt95bTEzBhd0GUDfYuG2SoJyLaJisUyW3
8X7TtdRUzSwe6IPeLFppU4QGOf1DI2WlmCdYPPfllCfiqVWMibBzwQZGkBvjWGs3
Cw8iKMKcydVlZCJ8rLIaU6sE/lD1eGrfowIDAQAB
-----END RSA PUBLIC KEY-----
`)

proxy, err := NewOAuthProxy(opts)
if err != nil {
t.Errorf("unexpected error %s", err)
return
}
rw := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "https://foo.sso.dev/oauth2/v1/certs", nil)
proxy.Handler().ServeHTTP(rw, req)
testutil.Equal(t, 200, rw.Code)

var certs map[string]string
if err := json.Unmarshal([]byte(rw.Body.String()), &certs); err != nil {
t.Errorf("failed to unmarshal certs from json response: %s", err)
return
}
testutil.Equal(t, expectedPublicKey, certs["signing_key"])
}

func TestFavicon(t *testing.T) {
opts := NewOptions()
opts.ClientID = "bazquux"
Expand Down Expand Up @@ -768,6 +835,72 @@ func TestAuthSkipRequests(t *testing.T) {
testutil.Equal(t, "response", allowRW.Body.String())
}

func generateTestSkipRequestSigningConfig(to string) []*UpstreamConfig {
if !strings.Contains(to, "://") {
to = fmt.Sprintf("%s://%s", "http", to)
}
parsed, err := url.Parse(to)
if err != nil {
panic(err)
}
templateVars := map[string]string{
"root_domain": "dev",
"cluster": "sso",
}
upstreamConfigs, err := loadServiceConfigs([]byte(fmt.Sprintf(`
- service: foo
default:
from: foo.sso.dev
to: %s
options:
skip_request_signing: true
skip_auth_regex:
- ^.*$
`, parsed)), "sso", "http", templateVars)
if err != nil {
panic(err)
}
return upstreamConfigs
}

func TestSkipSigningRequest(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, ok := r.Header["Sso-Signature"]
testutil.Assert(t, !ok, "found unexpected SSO-Signature header in request")

_, ok = r.Header["kid"]
testutil.Assert(t, !ok, "found unexpected signing key id header in request")

w.WriteHeader(200)
w.Write([]byte("response"))
}))
defer upstream.Close()

signingKey, err := ioutil.ReadFile("testdata/private_key.pem")
testutil.Assert(t, err == nil, "could not read private key from testdata: %s", err)

opts := NewOptions()
opts.ClientID = "bazquux"
opts.ClientSecret = "foobar"
opts.CookieSecret = testEncodedCookieSecret
opts.SkipAuthPreflight = true
opts.RequestSigningKey = string(signingKey)
opts.upstreamConfigs = generateTestSkipRequestSigningConfig(upstream.URL)
opts.Validate()

upstreamURL, _ := url.Parse(upstream.URL)
opts.provider = NewTestProvider(upstreamURL, "")

proxy, _ := NewOAuthProxy(opts)

// Expect OK
allowRW := httptest.NewRecorder()
allowReq, _ := http.NewRequest("GET", "https://foo.sso.dev/endpoint", nil)
proxy.Handler().ServeHTTP(allowRW, allowReq)
testutil.Equal(t, http.StatusOK, allowRW.Code)
testutil.Equal(t, "response", allowRW.Body.String())
}

func generateMultiTestAuthSkipConfigs(toFoo, toBar string) []*UpstreamConfig {
if !strings.Contains(toFoo, "://") {
toFoo = fmt.Sprintf("%s://%s", "http", toFoo)
Expand Down Expand Up @@ -975,7 +1108,7 @@ func (st *SignatureTest) MakeRequestWithExpectedKey(method, body, key string) {
req.AddCookie(cookie)
// This is used by the upstream to validate the signature.
st.authenticator.auth = hmacauth.NewHmacAuth(
crypto.SHA1, []byte(key), SignatureHeader, SignatureHeaders)
crypto.SHA1, []byte(key), HMACSignatureHeader, SignatureHeaders)
proxy.Handler().ServeHTTP(st.rw, req)
}

Expand Down
2 changes: 2 additions & 0 deletions internal/proxy/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ type Options struct {
StatsdHost string `envconfig:"STATSD_HOST"`
StatsdPort int `envconfig:"STATSD_PORT"`

RequestSigningKey string `envconfig:"REQUEST_SIGNATURE_KEY"`

StatsdClient *statsd.Client

// This is an override for supplying template vars at test time
Expand Down
15 changes: 9 additions & 6 deletions internal/proxy/proxy_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ type UpstreamConfig struct {
Timeout time.Duration
FlushInterval time.Duration
HeaderOverrides map[string]string
SkipRequestSigning bool
}

// RouteConfig maps to the yaml config fields,
Expand All @@ -77,12 +78,13 @@ type RouteConfig struct {
// * timeout - duration before timing out request.
// * flush_interval - interval at which the proxy should flush data to the browser
type OptionsConfig struct {
HeaderOverrides map[string]string `yaml:"header_overrides"`
SkipAuthRegex []string `yaml:"skip_auth_regex"`
AllowedGroups []string `yaml:"allowed_groups"`
TLSSkipVerify bool `yaml:"tls_skip_verify"`
Timeout time.Duration `yaml:"timeout"`
FlushInterval time.Duration `yaml:"flush_interval"`
HeaderOverrides map[string]string `yaml:"header_overrides"`
SkipAuthRegex []string `yaml:"skip_auth_regex"`
AllowedGroups []string `yaml:"allowed_groups"`
TLSSkipVerify bool `yaml:"tls_skip_verify"`
Timeout time.Duration `yaml:"timeout"`
FlushInterval time.Duration `yaml:"flush_interval"`
SkipRequestSigning bool `yaml:"skip_request_signing"`
}

// ErrParsingConfig is an error specific to config parsing.
Expand Down Expand Up @@ -365,6 +367,7 @@ func parseOptionsConfig(proxy *UpstreamConfig) error {
proxy.FlushInterval = proxy.RouteConfig.Options.FlushInterval
proxy.HeaderOverrides = proxy.RouteConfig.Options.HeaderOverrides
proxy.TLSSkipVerify = proxy.RouteConfig.Options.TLSSkipVerify
proxy.SkipRequestSigning = proxy.RouteConfig.Options.SkipRequestSigning

proxy.RouteConfig.Options = nil

Expand Down
Loading

0 comments on commit 993e32d

Please sign in to comment.