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

sso-proxy: sign upstream requests with a verifiable digital signature #106

Merged
merged 1 commit into from
Nov 27, 2018
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
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,
jphines marked this conversation as resolved.
Show resolved Hide resolved
}
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