Skip to content

Commit

Permalink
HTTP Headers support (#41)
Browse files Browse the repository at this point in the history
This pull request let users configure the HTTP headers as recommended by
the 2020 Cure53 security audit on Node Exporter.

It is not an arbitrary way to inject any HTTP headers as some backends
(e.g. Prometheus) already add headers (CORS), and we do not want to let
users change those.

Signed-off-by: Julien Pivotto <[email protected]>
  • Loading branch information
roidelapluie authored Oct 6, 2021
1 parent 3f030d1 commit 19e732c
Show file tree
Hide file tree
Showing 36 changed files with 191 additions and 43 deletions.
24 changes: 24 additions & 0 deletions docs/web-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,30 @@ http_server_config:
# Enable HTTP/2 support. Note that HTTP/2 is only supported with TLS.
# This can not be changed on the fly.
[ http2: <boolean> | default = true ]
# List of headers that can be added to HTTP responses.
[ headers:
# Set the Content-Security-Policy header to HTTP responses.
# Unset if blank.
[ Content-Security-Policy: <string> ]
# Set the X-Frame-Options header to HTTP responses.
# Unset if blank. Accepted values are deny and sameorigin.
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options
[ X-Frame-Options: <string> ]
# Set the X-Content-Type-Options header to HTTP responses.
# Unset if blank. Accepted value is nosniff.
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options
[ X-Content-Type-Options: <string> ]
# Set the X-XSS-Protection header to all responses.
# Unset if blank. Accepted value is nosniff.
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection
[ X-XSS-Protection: <string> ]
# Set the Strict-Transport-Security header to HTTP responses.
# Unset if blank.
# Please make sure that you use this with care as this header might force
# browsers to load Prometheus and the other applications hosted on the same
# domain and subdomains over HTTPS.
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security
[ Strict-Transport-Security: <string> ] ]
# Usernames and hashed passwords that have full access to the web
# server via basic authentication. If empty, no basic authentication is
Expand Down
43 changes: 41 additions & 2 deletions web/users.go → web/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,25 @@ package web

import (
"encoding/hex"
"fmt"
"net/http"
"sync"

"github.com/go-kit/log"
"golang.org/x/crypto/bcrypt"
)

// extraHTTPHeaders is a map of HTTP headers that can be added to HTTP
// responses.
// This is private on purpose to ensure consistency in the Prometheus ecosystem.
var extraHTTPHeaders = map[string][]string{
"Strict-Transport-Security": nil,
"X-Content-Type-Options": {"nosniff"},
"X-Frame-Options": {"deny", "sameorigin"},
"X-XSS-Protection": nil,
"Content-Security-Policy": nil,
}

func validateUsers(configPath string) error {
c, err := getConfig(configPath)
if err != nil {
Expand All @@ -40,7 +52,29 @@ func validateUsers(configPath string) error {
return nil
}

type userAuthRoundtrip struct {
// validateHeaderConfig checks that the provided header configuration is correct.
// It does not check the validity of all the values, only the ones which are
// well-defined enumerations.
func validateHeaderConfig(headers map[string]string) error {
HeadersLoop:
for k, v := range headers {
values, ok := extraHTTPHeaders[k]
if !ok {
return fmt.Errorf("HTTP header %q can not be configured", k)
}
for _, allowedValue := range values {
if v == allowedValue {
continue HeadersLoop
}
}
if len(values) > 0 {
return fmt.Errorf("invalid value for %s. Expected one of: %q, but got: %q", k, values, v)
}
}
return nil
}

type webHandler struct {
tlsConfigPath string
handler http.Handler
logger log.Logger
Expand All @@ -50,14 +84,19 @@ type userAuthRoundtrip struct {
bcryptMtx sync.Mutex
}

func (u *userAuthRoundtrip) ServeHTTP(w http.ResponseWriter, r *http.Request) {
func (u *webHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c, err := getConfig(u.tlsConfigPath)
if err != nil {
u.logger.Log("msg", "Unable to parse configuration", "err", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}

// Configure http headers.
for k, v := range c.HTTPConfig.Header {
w.Header().Set(k, v)
}

if len(c.Users) == 0 {
u.handler.ServeHTTP(w, r)
return
Expand Down
48 changes: 46 additions & 2 deletions web/users_test.go → web/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func TestBasicAuthCache(t *testing.T) {
})

go func() {
ListenAndServe(server, "testdata/tls_config_users_noTLS.good.yml", testlogger)
ListenAndServe(server, "testdata/web_config_users_noTLS.good.yml", testlogger)
close(done)
}()

Expand Down Expand Up @@ -103,7 +103,7 @@ func TestBasicAuthWithFakepassword(t *testing.T) {
})

go func() {
ListenAndServe(server, "testdata/tls_config_users_noTLS.good.yml", testlogger)
ListenAndServe(server, "testdata/web_config_users_noTLS.good.yml", testlogger)
close(done)
}()

Expand All @@ -128,3 +128,47 @@ func TestBasicAuthWithFakepassword(t *testing.T) {
// Login with the response cached.
login()
}

// TestHTTPHeaders validates that HTTP headers are added correctly.
func TestHTTPHeaders(t *testing.T) {
server := &http.Server{
Addr: port,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello World!"))
}),
}

done := make(chan struct{})
t.Cleanup(func() {
if err := server.Shutdown(context.Background()); err != nil {
t.Fatal(err)
}
<-done
})

go func() {
ListenAndServe(server, "testdata/web_config_headers.good.yml", testlogger)
close(done)
}()

client := &http.Client{}
req, err := http.NewRequest("GET", "http://localhost"+port, nil)
if err != nil {
t.Fatal(err)
}
r, err := client.Do(req)
if err != nil {
t.Fatal(err)
}

for k, v := range map[string]string{
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"X-Frame-Options": "deny",
"X-Content-Type-Options": "nosniff",
"X-XSS-Protection": "1",
} {
if got := r.Header.Get(k); got != v {
t.Fatalf("unexpected %s header value, expected %q, got %q", k, v, got)
}
}
}
File renamed without changes.
7 changes: 7 additions & 0 deletions web/testdata/web_config_headers.good.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
http_server_config:
headers:
X-Frame-Options: deny
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-XSS-Protection: 1
Content-Security-Policy: "default-src 'self' *.test.example.net"
3 changes: 3 additions & 0 deletions web/testdata/web_config_headers_content_type_options.bad.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
http_server_config:
headers:
X-Content-Type-Options: sniff
3 changes: 3 additions & 0 deletions web/testdata/web_config_headers_extra_header.bad.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
http_server_config:
headers:
Content-Type: foo
3 changes: 3 additions & 0 deletions web/testdata/web_config_headers_frame_options.bad.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
http_server_config:
headers:
X-Frame-Options: foo
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
8 changes: 6 additions & 2 deletions web/tls_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ func (t *TLSStruct) SetDirectory(dir string) {
}

type HTTPStruct struct {
HTTP2 bool `yaml:"http2"`
HTTP2 bool `yaml:"http2"`
Header map[string]string `yaml:"headers,omitempty"`
}

func getConfig(configPath string) (*Config, error) {
Expand All @@ -76,6 +77,9 @@ func getConfig(configPath string) (*Config, error) {
HTTPConfig: HTTPStruct{HTTP2: true},
}
err = yaml.UnmarshalStrict(content, c)
if err == nil {
err = validateHeaderConfig(c.HTTPConfig.Header)
}
c.TLSConfig.SetDirectory(filepath.Dir(configPath))
return c, err
}
Expand Down Expand Up @@ -207,7 +211,7 @@ func Serve(l net.Listener, server *http.Server, tlsConfigPath string, logger log
return err
}

server.Handler = &userAuthRoundtrip{
server.Handler = &webHandler{
tlsConfigPath: tlsConfigPath,
logger: logger,
handler: handler,
Expand Down
Loading

0 comments on commit 19e732c

Please sign in to comment.