Skip to content

Commit 93ab018

Browse files
committed
Sign upstream requests with a verifiable digital signature.
1 parent d5280a3 commit 93ab018

File tree

8 files changed

+584
-23
lines changed

8 files changed

+584
-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{
@@ -76,6 +76,9 @@ type OAuthProxy struct {
7676

7777
mux map[string]*route
7878
regexRoutes []*route
79+
80+
requestSigner *RequestSigner
81+
publicCertsJSON []byte
7982
}
8083

8184
type route struct {
@@ -95,11 +98,12 @@ type StateParameter struct {
9598

9699
// UpstreamProxy stores information necessary for proxying the request back to the upstream.
97100
type UpstreamProxy struct {
98-
name string
99-
cookieName string
100-
handler http.Handler
101-
auth hmacauth.HmacAuth
102-
statsdClient *statsd.Client
101+
name string
102+
cookieName string
103+
handler http.Handler
104+
auth hmacauth.HmacAuth
105+
requestSigner *RequestSigner
106+
statsdClient *statsd.Client
103107
}
104108

105109
// deleteSSOCookieHeader deletes the session cookie from the request header string.
@@ -120,6 +124,9 @@ func (u *UpstreamProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
120124
if u.auth != nil {
121125
u.auth.SignRequest(r)
122126
}
127+
if u.requestSigner != nil {
128+
u.requestSigner.Sign(r)
129+
}
123130

124131
start := time.Now()
125132
u.handler.ServeHTTP(w, r)
@@ -213,13 +220,17 @@ func NewRewriteReverseProxy(route *RewriteRoute, config *UpstreamConfig) *httput
213220
}
214221

215222
// NewReverseProxyHandler creates a new http.Handler given a httputil.ReverseProxy
216-
func NewReverseProxyHandler(reverseProxy *httputil.ReverseProxy, opts *Options, config *UpstreamConfig) (http.Handler, []string) {
223+
func NewReverseProxyHandler(reverseProxy *httputil.ReverseProxy, opts *Options, config *UpstreamConfig, signer *RequestSigner) (http.Handler, []string) {
217224
upstreamProxy := &UpstreamProxy{
218-
name: config.Service,
219-
handler: reverseProxy,
220-
auth: config.HMACAuth,
221-
cookieName: opts.CookieName,
222-
statsdClient: opts.StatsdClient,
225+
name: config.Service,
226+
handler: reverseProxy,
227+
auth: config.HMACAuth,
228+
cookieName: opts.CookieName,
229+
statsdClient: opts.StatsdClient,
230+
requestSigner: signer,
231+
}
232+
if config.SkipRequestSigning {
233+
upstreamProxy.requestSigner = nil
223234
}
224235
if config.FlushInterval != 0 {
225236
return NewStreamingHandler(upstreamProxy, opts, config), []string{"handler:streaming"}
@@ -257,7 +268,7 @@ func generateHmacAuth(signatureKey string) (hmacauth.HmacAuth, error) {
257268
if err != nil {
258269
return nil, fmt.Errorf("unsupported signature hash algorithm: %s", algorithm)
259270
}
260-
auth := hmacauth.NewHmacAuth(hash, []byte(secret), SignatureHeader, SignatureHeaders)
271+
auth := hmacauth.NewHmacAuth(hash, []byte(secret), HMACSignatureHeader, SignatureHeaders)
261272
return auth, nil
262273
}
263274

@@ -285,6 +296,26 @@ func NewOAuthProxy(opts *Options, optFuncs ...func(*OAuthProxy) error) (*OAuthPr
285296
c.Run()
286297
}()
287298

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

311345
for _, optFunc := range optFuncs {
@@ -319,11 +353,13 @@ func NewOAuthProxy(opts *Options, optFuncs ...func(*OAuthProxy) error) (*OAuthPr
319353
switch route := upstreamConfig.Route.(type) {
320354
case *SimpleRoute:
321355
reverseProxy := NewReverseProxy(route.ToURL, upstreamConfig)
322-
handler, tags := NewReverseProxyHandler(reverseProxy, opts, upstreamConfig)
356+
handler, tags := NewReverseProxyHandler(
357+
reverseProxy, opts, upstreamConfig, requestSigner)
323358
p.Handle(route.FromURL.Host, handler, tags, upstreamConfig)
324359
case *RewriteRoute:
325360
reverseProxy := NewRewriteReverseProxy(route, upstreamConfig)
326-
handler, tags := NewReverseProxyHandler(reverseProxy, opts, upstreamConfig)
361+
handler, tags := NewReverseProxyHandler(
362+
reverseProxy, opts, upstreamConfig, requestSigner)
327363
p.HandleRegex(route.FromRegex, handler, tags, upstreamConfig)
328364
default:
329365
return nil, fmt.Errorf("unknown route type")
@@ -338,6 +374,7 @@ func (p *OAuthProxy) Handler() http.Handler {
338374
mux := http.NewServeMux()
339375
mux.HandleFunc("/favicon.ico", p.Favicon)
340376
mux.HandleFunc("/robots.txt", p.RobotsTxt)
377+
mux.HandleFunc("/oauth2/v1/certs", p.Certs)
341378
mux.HandleFunc("/oauth2/sign_out", p.SignOut)
342379
mux.HandleFunc("/oauth2/callback", p.OAuthCallback)
343380
mux.HandleFunc("/oauth2/auth", p.AuthenticateOnly)
@@ -537,6 +574,12 @@ func (p *OAuthProxy) RobotsTxt(rw http.ResponseWriter, _ *http.Request) {
537574
fmt.Fprintf(rw, "User-agent: *\nDisallow: /")
538575
}
539576

577+
// Certs publishes the public key necessary for upstream services to validate the digital signature
578+
// used to sign each request.
579+
func (p *OAuthProxy) Certs(rw http.ResponseWriter, _ *http.Request) {
580+
rw.Write(p.publicCertsJSON)
581+
}
582+
540583
// Favicon will proxy the request as usual if the user is already authenticated
541584
// but responds with a 404 otherwise, to avoid spurious and confusing
542585
// 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"
@@ -729,6 +770,72 @@ func TestAuthSkipRequests(t *testing.T) {
729770
testutil.Equal(t, "response", allowRW.Body.String())
730771
}
731772

773+
func generateTestSkipRequestSigningConfig(to string) []*UpstreamConfig {
774+
if !strings.Contains(to, "://") {
775+
to = fmt.Sprintf("%s://%s", "http", to)
776+
}
777+
parsed, err := url.Parse(to)
778+
if err != nil {
779+
panic(err)
780+
}
781+
templateVars := map[string]string{
782+
"root_domain": "dev",
783+
"cluster": "sso",
784+
}
785+
upstreamConfigs, err := loadServiceConfigs([]byte(fmt.Sprintf(`
786+
- service: foo
787+
default:
788+
from: foo.sso.dev
789+
to: %s
790+
options:
791+
skip_request_signing: true
792+
skip_auth_regex:
793+
- ^.*$
794+
`, parsed)), "sso", "http", templateVars)
795+
if err != nil {
796+
panic(err)
797+
}
798+
return upstreamConfigs
799+
}
800+
801+
func TestSkipSigningRequest(t *testing.T) {
802+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
803+
_, ok := r.Header["Sso-Signature"]
804+
testutil.Assert(t, !ok, "found unexpected SSO-Signature header in request")
805+
806+
_, ok = r.Header["kid"]
807+
testutil.Assert(t, !ok, "found unexpected signing key id header in request")
808+
809+
w.WriteHeader(200)
810+
w.Write([]byte("response"))
811+
}))
812+
defer upstream.Close()
813+
814+
signingKey, err := ioutil.ReadFile("testdata/private_key.pem")
815+
testutil.Assert(t, err == nil, "could not read private key from testdata: %s", err)
816+
817+
opts := NewOptions()
818+
opts.ClientID = "bazquux"
819+
opts.ClientSecret = "foobar"
820+
opts.CookieSecret = testEncodedCookieSecret
821+
opts.SkipAuthPreflight = true
822+
opts.RequestSigningKey = string(signingKey)
823+
opts.upstreamConfigs = generateTestSkipRequestSigningConfig(upstream.URL)
824+
opts.Validate()
825+
826+
upstreamURL, _ := url.Parse(upstream.URL)
827+
opts.provider = providers.NewTestProvider(upstreamURL, "")
828+
829+
proxy, _ := NewOAuthProxy(opts)
830+
831+
// Expect OK
832+
allowRW := httptest.NewRecorder()
833+
allowReq, _ := http.NewRequest("GET", "https://foo.sso.dev/endpoint", nil)
834+
proxy.Handler().ServeHTTP(allowRW, allowReq)
835+
testutil.Equal(t, http.StatusOK, allowRW.Code)
836+
testutil.Equal(t, "response", allowRW.Body.String())
837+
}
838+
732839
func generateMultiTestAuthSkipConfigs(toFoo, toBar string) []*UpstreamConfig {
733840
if !strings.Contains(toFoo, "://") {
734841
toFoo = fmt.Sprintf("%s://%s", "http", toFoo)
@@ -936,7 +1043,7 @@ func (st *SignatureTest) MakeRequestWithExpectedKey(method, body, key string) {
9361043
req.AddCookie(cookie)
9371044
// This is used by the upstream to validate the signature.
9381045
st.authenticator.auth = hmacauth.NewHmacAuth(
939-
crypto.SHA1, []byte(key), SignatureHeader, SignatureHeaders)
1046+
crypto.SHA1, []byte(key), HMACSignatureHeader, SignatureHeaders)
9401047
proxy.Handler().ServeHTTP(st.rw, req)
9411048
}
9421049

internal/proxy/options.go

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

90+
RequestSigningKey string `envconfig:"REQUEST_SIGNATURE_KEY"`
91+
9092
StatsdClient *statsd.Client
9193

9294
// 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)