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 27, 2018
1 parent 2a8d706 commit 443a712
Show file tree
Hide file tree
Showing 8 changed files with 584 additions and 23 deletions.
75 changes: 59 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 @@ -76,6 +76,9 @@ type OAuthProxy struct {

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

requestSigner *RequestSigner
publicCertsJSON []byte
}

type route struct {
Expand All @@ -95,11 +98,12 @@ 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
}

// deleteSSOCookieHeader deletes the session cookie from the request header string.
Expand All @@ -120,6 +124,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 @@ -213,13 +220,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 @@ -257,7 +268,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 @@ -285,6 +296,26 @@ 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.
certs := make(map[string]string)
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)
}
id, key := requestSigner.PublicKey()
certs[id] = key
} 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 @@ -306,6 +337,9 @@ func NewOAuthProxy(opts *Options, optFuncs ...func(*OAuthProxy) error) (*OAuthPr
skipAuthPreflight: opts.SkipAuthPreflight,
templates: getTemplates(),
PassAccessToken: opts.PassAccessToken,

requestSigner: requestSigner,
publicCertsJSON: certsAsStr,
}

for _, optFunc := range optFuncs {
Expand All @@ -319,11 +353,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 @@ -338,6 +374,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 @@ -537,6 +574,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
109 changes: 108 additions & 1 deletion internal/proxy/oauthproxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package proxy

import (
"crypto"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -422,6 +424,45 @@ 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")

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

expectedPublicKey, err := ioutil.ReadFile("testdata/public_key.pub")
testutil.Assert(t, err == nil, "could not read public key from testdata: %s", err)

var keyHash []byte
hasher := sha256.New()
_, _ = hasher.Write(expectedPublicKey)
keyHash = hasher.Sum(keyHash)

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, string(expectedPublicKey), certs[hex.EncodeToString(keyHash)])
}

func TestFavicon(t *testing.T) {
opts := NewOptions()
opts.ClientID = "bazquux"
Expand Down Expand Up @@ -729,6 +770,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 = providers.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 @@ -936,7 +1043,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 @@ -87,6 +87,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 443a712

Please sign in to comment.