Skip to content

Commit e5f71c5

Browse files
committed
Sign upstream requests with a verifiable digital signature.
1 parent cba4491 commit e5f71c5

File tree

8 files changed

+580
-23
lines changed

8 files changed

+580
-23
lines changed

internal/proxy/oauthproxy.go

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ import (
2424
"github.com/datadog/datadog-go/statsd"
2525
)
2626

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

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

7575
mux map[string]*route
7676
regexRoutes []*route
77+
78+
requestSigner *RequestSigner
79+
publicCertsJSON []byte
7780
}
7881

7982
type route struct {
@@ -93,11 +96,12 @@ type StateParameter struct {
9396

9497
// UpstreamProxy stores information necessary for proxying the request back to the upstream.
9598
type UpstreamProxy struct {
96-
name string
97-
cookieName string
98-
handler http.Handler
99-
auth hmacauth.HmacAuth
100-
statsdClient *statsd.Client
99+
name string
100+
cookieName string
101+
handler http.Handler
102+
auth hmacauth.HmacAuth
103+
requestSigner *RequestSigner
104+
statsdClient *statsd.Client
101105
}
102106

103107
// deleteSSOCookieHeader deletes the session cookie from the request header string.
@@ -118,6 +122,9 @@ func (u *UpstreamProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
118122
if u.auth != nil {
119123
u.auth.SignRequest(r)
120124
}
125+
if u.requestSigner != nil {
126+
u.requestSigner.Sign(r)
127+
}
121128

122129
start := time.Now()
123130
u.handler.ServeHTTP(w, r)
@@ -211,13 +218,17 @@ func NewRewriteReverseProxy(route *RewriteRoute, config *UpstreamConfig) *httput
211218
}
212219

213220
// NewReverseProxyHandler creates a new http.Handler given a httputil.ReverseProxy
214-
func NewReverseProxyHandler(reverseProxy *httputil.ReverseProxy, opts *Options, config *UpstreamConfig) (http.Handler, []string) {
221+
func NewReverseProxyHandler(reverseProxy *httputil.ReverseProxy, opts *Options, config *UpstreamConfig, signer *RequestSigner) (http.Handler, []string) {
215222
upstreamProxy := &UpstreamProxy{
216-
name: config.Service,
217-
handler: reverseProxy,
218-
auth: config.HMACAuth,
219-
cookieName: opts.CookieName,
220-
statsdClient: opts.StatsdClient,
223+
name: config.Service,
224+
handler: reverseProxy,
225+
auth: config.HMACAuth,
226+
cookieName: opts.CookieName,
227+
statsdClient: opts.StatsdClient,
228+
requestSigner: signer,
229+
}
230+
if config.SkipRequestSigning {
231+
upstreamProxy.requestSigner = nil
221232
}
222233
if config.FlushInterval != 0 {
223234
return NewStreamingHandler(upstreamProxy, opts, config), []string{"handler:streaming"}
@@ -255,7 +266,7 @@ func generateHmacAuth(signatureKey string) (hmacauth.HmacAuth, error) {
255266
if err != nil {
256267
return nil, fmt.Errorf("unsupported signature hash algorithm: %s", algorithm)
257268
}
258-
auth := hmacauth.NewHmacAuth(hash, []byte(secret), SignatureHeader, SignatureHeaders)
269+
auth := hmacauth.NewHmacAuth(hash, []byte(secret), HMACSignatureHeader, SignatureHeaders)
259270
return auth, nil
260271
}
261272

@@ -283,6 +294,26 @@ func NewOAuthProxy(opts *Options, optFuncs ...func(*OAuthProxy) error) (*OAuthPr
283294
c.Run()
284295
}()
285296

297+
// Configure the RequestSigner (used to sign requests with `Sso-Signature` header).
298+
// Also build the `certs` static JSON-string which will be served from a public endpoint.
299+
// The key published at this endpoint allows upstreams to decrypt the `Sso-Signature`
300+
// header, and validate the integrity and authenticity of a request.
301+
certs := make(map[string]string)
302+
var requestSigner *RequestSigner
303+
if len(opts.RequestSigningKey) > 0 {
304+
if requestSigner, err = NewRequestSigner(opts.RequestSigningKey); err != nil {
305+
return nil, fmt.Errorf("could not build RequestSigner: %s", err)
306+
}
307+
id, key := requestSigner.PublicKey()
308+
certs[id] = key
309+
} else {
310+
logger.Warn("Running OAuthProxy without signing key. Requests will not be signed.")
311+
}
312+
certsAsStr, err := json.MarshalIndent(certs, "", " ")
313+
if err != nil {
314+
return nil, fmt.Errorf("could not marshal public certs as JSON: %s", err)
315+
}
316+
286317
p := &OAuthProxy{
287318
CookieCipher: cipher,
288319
CookieDomain: opts.CookieDomain,
@@ -303,6 +334,9 @@ func NewOAuthProxy(opts *Options, optFuncs ...func(*OAuthProxy) error) (*OAuthPr
303334
redirectURL: &url.URL{Path: "/oauth2/callback"},
304335
skipAuthPreflight: opts.SkipAuthPreflight,
305336
templates: getTemplates(),
337+
338+
requestSigner: requestSigner,
339+
publicCertsJSON: certsAsStr,
306340
}
307341

308342
for _, optFunc := range optFuncs {
@@ -316,11 +350,13 @@ func NewOAuthProxy(opts *Options, optFuncs ...func(*OAuthProxy) error) (*OAuthPr
316350
switch route := upstreamConfig.Route.(type) {
317351
case *SimpleRoute:
318352
reverseProxy := NewReverseProxy(route.ToURL, upstreamConfig)
319-
handler, tags := NewReverseProxyHandler(reverseProxy, opts, upstreamConfig)
353+
handler, tags := NewReverseProxyHandler(
354+
reverseProxy, opts, upstreamConfig, requestSigner)
320355
p.Handle(route.FromURL.Host, handler, tags, upstreamConfig)
321356
case *RewriteRoute:
322357
reverseProxy := NewRewriteReverseProxy(route, upstreamConfig)
323-
handler, tags := NewReverseProxyHandler(reverseProxy, opts, upstreamConfig)
358+
handler, tags := NewReverseProxyHandler(
359+
reverseProxy, opts, upstreamConfig, requestSigner)
324360
p.HandleRegex(route.FromRegex, handler, tags, upstreamConfig)
325361
default:
326362
return nil, fmt.Errorf("unknown route type")
@@ -335,6 +371,7 @@ func (p *OAuthProxy) Handler() http.Handler {
335371
mux := http.NewServeMux()
336372
mux.HandleFunc("/favicon.ico", p.Favicon)
337373
mux.HandleFunc("/robots.txt", p.RobotsTxt)
374+
mux.HandleFunc("/oauth2/v1/certs", p.Certs)
338375
mux.HandleFunc("/oauth2/sign_out", p.SignOut)
339376
mux.HandleFunc("/oauth2/callback", p.OAuthCallback)
340377
mux.HandleFunc("/oauth2/auth", p.AuthenticateOnly)
@@ -533,6 +570,12 @@ func (p *OAuthProxy) RobotsTxt(rw http.ResponseWriter, _ *http.Request) {
533570
fmt.Fprintf(rw, "User-agent: *\nDisallow: /")
534571
}
535572

573+
// Certs publishes the public key necessary for upstream services to validate the digital signature
574+
// used to sign each request.
575+
func (p *OAuthProxy) Certs(rw http.ResponseWriter, _ *http.Request) {
576+
rw.Write(p.publicCertsJSON)
577+
}
578+
536579
// Favicon will proxy the request as usual if the user is already authenticated
537580
// but responds with a 404 otherwise, to avoid spurious and confusing
538581
// authentication attempts when a browser automatically requests the favicon on

internal/proxy/oauthproxy_test.go

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package proxy
22

33
import (
44
"crypto"
5+
"crypto/sha256"
6+
"encoding/hex"
57
"encoding/json"
68
"errors"
79
"fmt"
@@ -422,6 +424,45 @@ func TestRobotsTxt(t *testing.T) {
422424
testutil.Equal(t, "User-agent: *\nDisallow: /", rw.Body.String())
423425
}
424426

427+
func TestCerts(t *testing.T) {
428+
opts := NewOptions()
429+
opts.ClientID = "bazquux"
430+
opts.ClientSecret = "foobar"
431+
opts.CookieSecret = testEncodedCookieSecret
432+
opts.ProviderURLString = "https://auth.sso.dev"
433+
opts.upstreamConfigs = generateTestUpstreamConfigs("foo-internal.sso.dev")
434+
435+
requestSigningKey, err := ioutil.ReadFile("testdata/private_key.pem")
436+
testutil.Assert(t, err == nil, "could not read private key from testdata: %s", err)
437+
opts.RequestSigningKey = string(requestSigningKey)
438+
opts.Validate()
439+
440+
expectedPublicKey, err := ioutil.ReadFile("testdata/public_key.pub")
441+
testutil.Assert(t, err == nil, "could not read public key from testdata: %s", err)
442+
443+
var keyHash []byte
444+
hasher := sha256.New()
445+
_, _ = hasher.Write(expectedPublicKey)
446+
keyHash = hasher.Sum(keyHash)
447+
448+
proxy, err := NewOAuthProxy(opts)
449+
if err != nil {
450+
t.Errorf("unexpected error %s", err)
451+
return
452+
}
453+
rw := httptest.NewRecorder()
454+
req, _ := http.NewRequest("GET", "https://foo.sso.dev/oauth2/v1/certs", nil)
455+
proxy.Handler().ServeHTTP(rw, req)
456+
testutil.Equal(t, 200, rw.Code)
457+
458+
var certs map[string]string
459+
if err := json.Unmarshal([]byte(rw.Body.String()), &certs); err != nil {
460+
t.Errorf("failed to unmarshal certs from json response: %s", err)
461+
return
462+
}
463+
testutil.Equal(t, string(expectedPublicKey), certs[hex.EncodeToString(keyHash)])
464+
}
465+
425466
func TestFavicon(t *testing.T) {
426467
opts := NewOptions()
427468
opts.ClientID = "bazquux"
@@ -768,6 +809,72 @@ func TestAuthSkipRequests(t *testing.T) {
768809
testutil.Equal(t, "response", allowRW.Body.String())
769810
}
770811

812+
func generateTestSkipRequestSigningConfig(to string) []*UpstreamConfig {
813+
if !strings.Contains(to, "://") {
814+
to = fmt.Sprintf("%s://%s", "http", to)
815+
}
816+
parsed, err := url.Parse(to)
817+
if err != nil {
818+
panic(err)
819+
}
820+
templateVars := map[string]string{
821+
"root_domain": "dev",
822+
"cluster": "sso",
823+
}
824+
upstreamConfigs, err := loadServiceConfigs([]byte(fmt.Sprintf(`
825+
- service: foo
826+
default:
827+
from: foo.sso.dev
828+
to: %s
829+
options:
830+
skip_request_signing: true
831+
skip_auth_regex:
832+
- ^.*$
833+
`, parsed)), "sso", "http", templateVars)
834+
if err != nil {
835+
panic(err)
836+
}
837+
return upstreamConfigs
838+
}
839+
840+
func TestSkipSigningRequest(t *testing.T) {
841+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
842+
_, ok := r.Header["Sso-Signature"]
843+
testutil.Assert(t, !ok, "found unexpected SSO-Signature header in request")
844+
845+
_, ok = r.Header["kid"]
846+
testutil.Assert(t, !ok, "found unexpected signing key id header in request")
847+
848+
w.WriteHeader(200)
849+
w.Write([]byte("response"))
850+
}))
851+
defer upstream.Close()
852+
853+
signingKey, err := ioutil.ReadFile("testdata/private_key.pem")
854+
testutil.Assert(t, err == nil, "could not read private key from testdata: %s", err)
855+
856+
opts := NewOptions()
857+
opts.ClientID = "bazquux"
858+
opts.ClientSecret = "foobar"
859+
opts.CookieSecret = testEncodedCookieSecret
860+
opts.SkipAuthPreflight = true
861+
opts.RequestSigningKey = string(signingKey)
862+
opts.upstreamConfigs = generateTestSkipRequestSigningConfig(upstream.URL)
863+
opts.Validate()
864+
865+
upstreamURL, _ := url.Parse(upstream.URL)
866+
opts.provider = NewTestProvider(upstreamURL, "")
867+
868+
proxy, _ := NewOAuthProxy(opts)
869+
870+
// Expect OK
871+
allowRW := httptest.NewRecorder()
872+
allowReq, _ := http.NewRequest("GET", "https://foo.sso.dev/endpoint", nil)
873+
proxy.Handler().ServeHTTP(allowRW, allowReq)
874+
testutil.Equal(t, http.StatusOK, allowRW.Code)
875+
testutil.Equal(t, "response", allowRW.Body.String())
876+
}
877+
771878
func generateMultiTestAuthSkipConfigs(toFoo, toBar string) []*UpstreamConfig {
772879
if !strings.Contains(toFoo, "://") {
773880
toFoo = fmt.Sprintf("%s://%s", "http", toFoo)
@@ -975,7 +1082,7 @@ func (st *SignatureTest) MakeRequestWithExpectedKey(method, body, key string) {
9751082
req.AddCookie(cookie)
9761083
// This is used by the upstream to validate the signature.
9771084
st.authenticator.auth = hmacauth.NewHmacAuth(
978-
crypto.SHA1, []byte(key), SignatureHeader, SignatureHeaders)
1085+
crypto.SHA1, []byte(key), HMACSignatureHeader, SignatureHeaders)
9791086
proxy.Handler().ServeHTTP(st.rw, req)
9801087
}
9811088

internal/proxy/options.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ type Options struct {
8484
StatsdHost string `envconfig:"STATSD_HOST"`
8585
StatsdPort int `envconfig:"STATSD_PORT"`
8686

87+
RequestSigningKey string `envconfig:"REQUEST_SIGNATURE_KEY"`
88+
8789
StatsdClient *statsd.Client
8890

8991
// This is an override for supplying template vars at test time

internal/proxy/proxy_config.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ type UpstreamConfig struct {
5757
Timeout time.Duration
5858
FlushInterval time.Duration
5959
HeaderOverrides map[string]string
60+
SkipRequestSigning bool
6061
}
6162

6263
// RouteConfig maps to the yaml config fields,
@@ -77,12 +78,13 @@ type RouteConfig struct {
7778
// * timeout - duration before timing out request.
7879
// * flush_interval - interval at which the proxy should flush data to the browser
7980
type OptionsConfig struct {
80-
HeaderOverrides map[string]string `yaml:"header_overrides"`
81-
SkipAuthRegex []string `yaml:"skip_auth_regex"`
82-
AllowedGroups []string `yaml:"allowed_groups"`
83-
TLSSkipVerify bool `yaml:"tls_skip_verify"`
84-
Timeout time.Duration `yaml:"timeout"`
85-
FlushInterval time.Duration `yaml:"flush_interval"`
81+
HeaderOverrides map[string]string `yaml:"header_overrides"`
82+
SkipAuthRegex []string `yaml:"skip_auth_regex"`
83+
AllowedGroups []string `yaml:"allowed_groups"`
84+
TLSSkipVerify bool `yaml:"tls_skip_verify"`
85+
Timeout time.Duration `yaml:"timeout"`
86+
FlushInterval time.Duration `yaml:"flush_interval"`
87+
SkipRequestSigning bool `yaml:"skip_request_signing"`
8688
}
8789

8890
// ErrParsingConfig is an error specific to config parsing.
@@ -365,6 +367,7 @@ func parseOptionsConfig(proxy *UpstreamConfig) error {
365367
proxy.FlushInterval = proxy.RouteConfig.Options.FlushInterval
366368
proxy.HeaderOverrides = proxy.RouteConfig.Options.HeaderOverrides
367369
proxy.TLSSkipVerify = proxy.RouteConfig.Options.TLSSkipVerify
370+
proxy.SkipRequestSigning = proxy.RouteConfig.Options.SkipRequestSigning
368371

369372
proxy.RouteConfig.Options = nil
370373

0 commit comments

Comments
 (0)