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

Add support for HSTS response headers and provide method for adding additional response headers #425

Merged
merged 4 commits into from
Jan 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
7 changes: 7 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions config/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
21 changes: 21 additions & 0 deletions config/load_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions docs/content/ref/proxy.header.sts.maxage.md
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions docs/content/ref/proxy.header.sts.preload.md
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions docs/content/ref/proxy.header.sts.subdomains.md
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions fabio.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
# https://hstspreload.org/#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
Expand Down
39 changes: 39 additions & 0 deletions proxy/http_headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
65 changes: 65 additions & 0 deletions proxy/http_headers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"crypto/tls"
"fmt"
"net/http"
"net/http/httptest"
"testing"

"github.com/fabiolb/fabio/config"
Expand Down Expand Up @@ -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 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
Expand Down
35 changes: 35 additions & 0 deletions proxy/http_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := "<html>503</html>"
noroute.SetHTML(want)
Expand Down
5 changes: 5 additions & 0 deletions proxy/http_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down