Security headers
Set Content-Security-Policy, HSTS, nosniff and Referrer-Policy in one middleware, and know what each prevents.
w.Header().Set("Content-Security-Policy", "default-src 'self'")A security header is an instruction to the browser, sent with the response. The browser does the enforcing; your Go code only has to send the header. They cost nothing at runtime and they close whole classes of attack, so set them in one middleware that wraps every route.
What each one prevents
- Content-Security-Policy lists where the page may load code from.
default-src 'self'means scripts, styles and images must come from this origin. If an attacker manages to inject a script tag of their own, the browser refuses to run it, because an inline script is not on the list. - Strict-Transport-Security tells the browser to use HTTPS for this host for the next
max-ageseconds, even when a link says http.max-age=31536000; includeSubDomainsis one year. Send it only over HTTPS. - X-Content-Type-Options: nosniff stops the browser guessing a response's type from its bytes. Without it, a user upload served as
text/plaincan be treated as JavaScript and run. - Referrer-Policy: no-referrer keeps the current URL, which may carry a token, out of requests your page makes to other sites.
- frame-ancestors 'none', a CSP directive, stops another site putting your page in a frame and collecting clicks meant for it.
X-Frame-Options: DENYis the older header that does the same job for browsers that predate CSP.
Order matters
Headers sit in a map and are sent with the first write to the response. Set them before the handler writes a body. After that the map is ignored, and nothing tells you so.
Example
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
// secure sets the headers before the wrapped handler can write anything.
func secure(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", "default-src 'self'; frame-ancestors 'none'")
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
func main() {
h := secure(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
// A fixed list, so the printed order never changes between runs.
for _, name := range []string{"Content-Security-Policy", "X-Content-Type-Options"} {
fmt.Printf("%s: %s\n", name, rec.Header().Get(name))
}
}
// Output:
// Content-Security-Policy: default-src 'self'; frame-ancestors 'none'
// X-Content-Type-Options: nosniff
When to use it
- Block an injected script tag from running on a server-rendered page.
- Stop browsers ever trying plain HTTP against your domain again.
- Serve user uploads that cannot execute as HTML or JavaScript.
- Keep a URL containing a reset token out of third-party referrer logs.
More examples
A per-request nonce so your own scripts still run
A strict policy blocks inline scripts, including the ones you wrote. A nonce is the way back in: a random value sent in the header and repeated on the tag. Reach for it on server-rendered pages that need a small amount of inline JavaScript. The alternative is to move all of it into files under /static and keep script-src 'self'.
package main
import (
"crypto/rand"
"encoding/base64"
"html/template"
"log"
"net/http"
)
var page = template.Must(template.New("page").Parse(
`<script nonce="{{.Nonce}}">console.log("allowed")</script>`))
func handler(w http.ResponseWriter, r *http.Request) {
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
http.Error(w, "server error", http.StatusInternalServerError)
return
}
nonce := base64.StdEncoding.EncodeToString(raw)
// Only a script tag carrying this exact nonce may run. Injected markup
// cannot guess it, because it is new on every request.
w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'nonce-"+nonce+"'")
if err := page.Execute(w, map[string]string{"Nonce": nonce}); err != nil {
log.Println("render:", err)
}
}HSTS only when the request really came over TLS
Sending HSTS on a plain HTTP response achieves nothing, and sending it from a local development server over http can lock your browser out of http://localhost for a year. This check keeps it to the requests that arrived encrypted.
package main
import "net/http"
func hsts(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// r.TLS is non-nil only when this process terminated TLS itself.
// Behind a proxy, the proxy's X-Forwarded-Proto is the signal.
secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
if secure {
w.Header().Set("Strict-Transport-Security",
"max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}A download route that cannot be run as code
Use this on anything a user uploaded. A file named photo.png whose bytes are HTML becomes a page running on your origin, with access to your cookies, unless these three headers are present. Serving uploads from a separate hostname is stronger still.
package main
import (
"log"
"net/http"
)
func main() {
files := http.FileServer(http.Dir("uploads"))
mux := http.NewServeMux()
mux.Handle("/uploads/", http.StripPrefix("/uploads/",
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// nosniff: the browser must believe the declared type.
// attachment: save it, do not render it in the page.
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Disposition", "attachment")
w.Header().Set("Content-Security-Policy", "sandbox")
files.ServeHTTP(w, r)
})))
log.Fatal(http.ListenAndServe(":8080", mux))
}
Discussion