From b12109152edd14ba76bba17b94422e9fa829edbe Mon Sep 17 00:00:00 2001 From: Aaron Hurt Date: Tue, 23 Jan 2018 17:11:57 -0600 Subject: [PATCH 1/4] add support for HSTS response headers --- config/config.go | 7 ++++ config/load.go | 3 ++ fabio.properties | 32 +++++++++++++++++++ proxy/http_headers.go | 39 +++++++++++++++++++++++ proxy/http_headers_test.go | 65 ++++++++++++++++++++++++++++++++++++++ proxy/http_proxy.go | 5 +++ 6 files changed, 151 insertions(+) diff --git a/config/config.go b/config/config.go index 41f452bc0..58279ae58 100644 --- a/config/config.go +++ b/config/config.go @@ -64,6 +64,13 @@ type Proxy struct { TLSHeaderValue string GZIPContentTypes *regexp.Regexp RequestID string + STSHeader STSHeader +} + +type STSHeader struct { + MaxAge int + Subdomains bool + Preload bool } type Runtime struct { diff --git a/config/load.go b/config/load.go index fc45ef8a7..944c625fb 100644 --- a/config/load.go +++ b/config/load.go @@ -129,6 +129,9 @@ func load(cmdline, environ, envprefix []string, props *properties.Properties) (c f.StringVar(&cfg.Proxy.TLSHeader, "proxy.header.tls", defaultConfig.Proxy.TLSHeader, "header for TLS connections") f.StringVar(&cfg.Proxy.TLSHeaderValue, "proxy.header.tls.value", defaultConfig.Proxy.TLSHeaderValue, "value for TLS connection header") f.StringVar(&cfg.Proxy.RequestID, "proxy.header.requestid", defaultConfig.Proxy.RequestID, "header for reqest id") + f.IntVar(&cfg.Proxy.STSHeader.MaxAge, "proxy.header.sts.maxage", defaultConfig.Proxy.STSHeader.MaxAge, "enable and set the max-age value for HSTS") + f.BoolVar(&cfg.Proxy.STSHeader.Subdomains, "proxy.header.sts.subdomains", defaultConfig.Proxy.STSHeader.Subdomains, "direct HSTS to include subdomains") + f.BoolVar(&cfg.Proxy.STSHeader.Preload, "proxy.header.sts.preload", defaultConfig.Proxy.STSHeader.Preload, "direct HSTS to pass the preload directive") f.StringVar(&gzipContentTypesValue, "proxy.gzip.contenttype", defaultValues.GZIPContentTypesValue, "regexp of content types to compress") f.StringVar(&listenerValue, "proxy.addr", defaultValues.ListenerValue, "listener config") f.StringVar(&certSourcesValue, "proxy.cs", defaultValues.CertSourcesValue, "certificate sources") diff --git a/fabio.properties b/fabio.properties index eede53da3..8f10865a0 100644 --- a/fabio.properties +++ b/fabio.properties @@ -398,6 +398,38 @@ # proxy.header.requestid = +# proxy.header.sts.maxage enables and configures the max-age of HSTS for TLS requests. +# When set greater than zero this enables the Strict-Transport-Security header +# and sets the max-age value in the header. +# +# The default is +# +# proxy.header.sts.maxage = 0 + + +# proxy.header.sts.subdomains instructs HSTS to include subdomains. +# When set to true, the 'includeSubDomains' option will be added to +# the Strict-Transport-Security header. +# +# The default is +# +# proxy.header.sts.subdomains = false + + +# proxy.header.sts.preload instructs HSTS to include the preload directive. +# When set to true, the 'preload' option will be added to the +# Strict-Transport-Security header. +# +# Sending the preload directive from your site can have PERMANENT CONSEQUENCES +# and prevent users from accessing your site and any of its subdomains if you +# find you need to switch back to HTTP. Please read the details at +# hstspreload.appspot.com/#removal before sending the header with "preload". +# +# The default is +# +# proxy.header.sts.preload = false + + # proxy.gzip.contenttype configures which responses should be compressed. # # By default, responses sent to the client are not compressed even if the diff --git a/proxy/http_headers.go b/proxy/http_headers.go index 8c5bd84fc..251b797ed 100644 --- a/proxy/http_headers.go +++ b/proxy/http_headers.go @@ -10,6 +10,22 @@ import ( "github.com/fabiolb/fabio/config" ) +// addResponseHeaders adds/updates headers in the response +func addResponseHeaders(w http.ResponseWriter, r *http.Request, cfg config.Proxy) error { + if r.TLS != nil && cfg.STSHeader.MaxAge > 0 { + sts := "max-age=" + i32toa(int32(cfg.STSHeader.MaxAge)) + if cfg.STSHeader.Subdomains { + sts += "; includeSubdomains" + } + if cfg.STSHeader.Preload { + sts += "; preload" + } + w.Header().Set("Strict-Transport-Security", sts) + } + + return nil +} + // addHeaders adds/updates headers in request // // * add/update `Forwarded` header @@ -130,6 +146,29 @@ func uint16base16(n uint16) string { return string(b) } +// i32toa is a faster implentation of strconv.Itoa() without importing another library +// https://stackoverflow.com/a/39444005 +func i32toa(n int32) string { + buf := [11]byte{} + pos := len(buf) + i := int64(n) + signed := i < 0 + if signed { + i = -i + } + for { + pos-- + buf[pos], i = '0'+byte(i%10), i/10 + if i == 0 { + if signed { + pos-- + buf[pos] = '-' + } + return string(buf[pos:]) + } + } +} + // scheme derives the request scheme used on the initial // request first from headers and then from the connection // using the following heuristic: diff --git a/proxy/http_headers_test.go b/proxy/http_headers_test.go index 0de1c616b..baa610eae 100644 --- a/proxy/http_headers_test.go +++ b/proxy/http_headers_test.go @@ -4,6 +4,7 @@ import ( "crypto/tls" "fmt" "net/http" + "net/http/httptest" "testing" "github.com/fabiolb/fabio/config" @@ -386,6 +387,70 @@ func TestAddHeaders(t *testing.T) { } } +func TestAddResponseHeaders(t *testing.T) { + tests := []struct { + desc string + r *http.Request + cfg config.Proxy + hdrs http.Header + err string + }{ + {"set Strict-Transport-Security for TLS, if MaxAge greater than 0", + &http.Request{RemoteAddr: "1.2.3.4:5555", TLS: &tls.ConnectionState{}}, + config.Proxy{STSHeader: config.STSHeader{MaxAge: 31536000}}, + http.Header{ + "Strict-Transport-Security": []string{"max-age=31536000"}, + }, + "", + }, + + {"set Strict-Transport-Security with options for TLS, if MaxAge greater than 0 with options", + &http.Request{RemoteAddr: "1.2.3.4:5555", TLS: &tls.ConnectionState{}}, + config.Proxy{STSHeader: config.STSHeader{MaxAge: 31536000, Preload: true, Subdomains: true}}, + http.Header{ + "Strict-Transport-Security": []string{"max-age=31536000; includeSubdomains; preload"}, + }, + "", + }, + + {"skip Strict-Transport-Security for non-TLS, if MaxAge greater than 0", + &http.Request{RemoteAddr: "1.2.3.4:5555"}, + config.Proxy{STSHeader: config.STSHeader{MaxAge: 31536000}}, + http.Header{}, + "", + }, + } + + for i, tt := range tests { + tt := tt // capture loop var + + t.Run(tt.desc, func(t *testing.T) { + if tt.r.Header == nil { + tt.r.Header = http.Header{} + } + + w := httptest.NewRecorder() + err := addResponseHeaders(w, tt.r, tt.cfg) + + if err != nil { + if got, want := err.Error(), tt.err; got != want { + t.Fatalf("%d: %s\ngot %q\nwant %q", i, tt.desc, got, want) + } + return + } + + if tt.err != "" { + t.Fatalf("%d: got nil want %q", i, tt.err) + return + } + + resp := w.Result() + got, want := resp.Header, tt.hdrs + verify.Values(t, "", got, want) + }) + } +} + func TestLocalPort(t *testing.T) { tests := []struct { r *http.Request diff --git a/proxy/http_proxy.go b/proxy/http_proxy.go index f9aa84962..95511026f 100644 --- a/proxy/http_proxy.go +++ b/proxy/http_proxy.go @@ -130,6 +130,11 @@ func (p *HTTPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if err := addResponseHeaders(w, r, p.Config); err != nil { + http.Error(w, "cannot add response headers", http.StatusInternalServerError) + return + } + upgrade, accept := r.Header.Get("Upgrade"), r.Header.Get("Accept") tr := p.Transport From c12b57a1faae09657e095ca8590a13a05019e6bd Mon Sep 17 00:00:00 2001 From: Aaron Hurt Date: Wed, 24 Jan 2018 10:57:59 -0600 Subject: [PATCH 2/4] cleanup test case description --- proxy/http_headers_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proxy/http_headers_test.go b/proxy/http_headers_test.go index baa610eae..5794b0fb7 100644 --- a/proxy/http_headers_test.go +++ b/proxy/http_headers_test.go @@ -404,7 +404,7 @@ func TestAddResponseHeaders(t *testing.T) { "", }, - {"set Strict-Transport-Security with options for TLS, if MaxAge greater than 0 with options", + {"set Strict-Transport-Security for TLS, if MaxAge greater than 0 with options", &http.Request{RemoteAddr: "1.2.3.4:5555", TLS: &tls.ConnectionState{}}, config.Proxy{STSHeader: config.STSHeader{MaxAge: 31536000, Preload: true, Subdomains: true}}, http.Header{ From bb7e2b0e88bd1d2a041dd562e707f790e66d6c99 Mon Sep 17 00:00:00 2001 From: Aaron Hurt Date: Fri, 26 Jan 2018 12:02:06 -0600 Subject: [PATCH 3/4] adding markdown docs and config load test --- config/load_test.go | 21 +++++++++++++++++++ docs/content/ref/proxy.header.sts.maxage.md | 11 ++++++++++ docs/content/ref/proxy.header.sts.preload.md | 17 +++++++++++++++ .../ref/proxy.header.sts.subdomains.md | 11 ++++++++++ fabio.properties | 2 +- 5 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 docs/content/ref/proxy.header.sts.maxage.md create mode 100644 docs/content/ref/proxy.header.sts.preload.md create mode 100644 docs/content/ref/proxy.header.sts.subdomains.md diff --git a/config/load_test.go b/config/load_test.go index f74610911..77e60e312 100644 --- a/config/load_test.go +++ b/config/load_test.go @@ -390,6 +390,27 @@ func TestLoad(t *testing.T) { return cfg }, }, + { + args: []string{"-proxy.header.sts.maxage", "31536000"}, + cfg: func(cfg *Config) *Config { + cfg.Proxy.STSHeader.MaxAge = 31536000 + return cfg + }, + }, + { + args: []string{"-proxy.header.sts.subdomains", "true"}, + cfg: func(cfg *Config) *Config { + cfg.Proxy.STSHeader.Subdomains = true + return cfg + }, + }, + { + args: []string{"-proxy.header.sts.preload", "true"}, + cfg: func(cfg *Config) *Config { + cfg.Proxy.STSHeader.Preload = true + return cfg + }, + }, { args: []string{"-proxy.gzip.contenttype", `^text/.*$`}, cfg: func(cfg *Config) *Config { diff --git a/docs/content/ref/proxy.header.sts.maxage.md b/docs/content/ref/proxy.header.sts.maxage.md new file mode 100644 index 000000000..6846edcc9 --- /dev/null +++ b/docs/content/ref/proxy.header.sts.maxage.md @@ -0,0 +1,11 @@ +--- +title: "proxy.header.sts.maxage" +--- + +`proxy.header.sts.maxage` enables and configures the max-age of HSTS for TLS requests. +When set greater than zero this enables the Strict-Transport-Security header +and sets the max-age value in the header. + +The default is + + proxy.header.sts.maxage = 0 diff --git a/docs/content/ref/proxy.header.sts.preload.md b/docs/content/ref/proxy.header.sts.preload.md new file mode 100644 index 000000000..274060047 --- /dev/null +++ b/docs/content/ref/proxy.header.sts.preload.md @@ -0,0 +1,17 @@ +--- +title: "proxy.header.sts.preload" +--- + +`proxy.header.sts.preload` instructs HSTS to include the preload directive. +When set to true, the 'preload' option will be added to the +Strict-Transport-Security header. + +Sending the preload directive from your site can have PERMANENT CONSEQUENCES +and prevent users from accessing your site and any of its subdomains if you +find you need to switch back to HTTP. Please read the details at +[https://hstspreload.org/#removal](https://hstspreload.org/#removal) +before sending the header with "preload". + +The default is + + proxy.header.sts.preload = false diff --git a/docs/content/ref/proxy.header.sts.subdomains.md b/docs/content/ref/proxy.header.sts.subdomains.md new file mode 100644 index 000000000..fde3da4f1 --- /dev/null +++ b/docs/content/ref/proxy.header.sts.subdomains.md @@ -0,0 +1,11 @@ +--- +title: "proxy.header.sts.subdomains" +--- + +`proxy.header.sts.subdomains` instructs HSTS to include subdomains. +When set to true, the 'includeSubDomains' option will be added to +the Strict-Transport-Security header. + +The default is + + proxy.header.sts.subdomains = false diff --git a/fabio.properties b/fabio.properties index 8f10865a0..def4ccd78 100644 --- a/fabio.properties +++ b/fabio.properties @@ -423,7 +423,7 @@ # Sending the preload directive from your site can have PERMANENT CONSEQUENCES # and prevent users from accessing your site and any of its subdomains if you # find you need to switch back to HTTP. Please read the details at -# hstspreload.appspot.com/#removal before sending the header with "preload". +# https://hstspreload.org/#removal before sending the header with "preload". # # The default is # From 77e152cb4f6595f88fa83f53200d400bd9bfc947 Mon Sep 17 00:00:00 2001 From: Aaron Hurt Date: Fri, 26 Jan 2018 14:13:24 -0600 Subject: [PATCH 4/4] add integration test --- proxy/http_integration_test.go | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/proxy/http_integration_test.go b/proxy/http_integration_test.go index 30b173b70..c430c4ae0 100644 --- a/proxy/http_integration_test.go +++ b/proxy/http_integration_test.go @@ -80,6 +80,41 @@ func TestProxyRequestIDHeader(t *testing.T) { } } +func TestProxySTSHeader(t *testing.T) { + server := httptest.NewServer(okHandler) + defer server.Close() + + proxy := httptest.NewTLSServer(&HTTPProxy{ + Config: config.Proxy{ + STSHeader: config.STSHeader{ + MaxAge: 31536000, + Subdomains: true, + Preload: true, + }, + }, + Transport: &http.Transport{TLSClientConfig: tlsInsecureConfig()}, + Lookup: func(r *http.Request) *route.Target { + return &route.Target{URL: mustParse(server.URL)} + }, + }) + defer proxy.Close() + + client := http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsInsecureConfig(), + }, + } + resp, err := client.Get(proxy.URL) + if err != nil { + panic(err) + } + + if got, want := resp.Header.Get("Strict-Transport-Security"), + "max-age=31536000; includeSubdomains; preload"; got != want { + t.Errorf("got %v want %v", got, want) + } +} + func TestProxyNoRouteHTML(t *testing.T) { want := "503" noroute.SetHTML(want)