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