safeweb: add support for custom CSP (#13975)

To allow more flexibility with CSPs, add a fully customizable `CSP` type
that can be provided in `Config` and encodes itself into the correct
format. Preserve the `CSPAllowInlineStyles` option as is today, but
maybe that'll get deprecated later in favor of the new CSP field.

In particular, this allows for pages loading external JS, or inline JS
with nonces or hashes (see
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src#unsafe_inline_script)

Updates https://github.com/tailscale/corp/issues/8027

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
This commit is contained in:
Andrew Lytvynov 2024-10-31 14:13:29 -05:00 committed by GitHub
parent 6985369479
commit ddbc950f46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 92 additions and 24 deletions

View File

@ -74,25 +74,74 @@
crand "crypto/rand" crand "crypto/rand"
"fmt" "fmt"
"log" "log"
"maps"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"path" "path"
"slices"
"strings" "strings"
"github.com/gorilla/csrf" "github.com/gorilla/csrf"
) )
// The default Content-Security-Policy header. // CSP is the value of a Content-Security-Policy header. Keys are CSP
var defaultCSP = strings.Join([]string{ // directives (like "default-src") and values are source expressions (like
`default-src 'self'`, // origin is the only valid source for all content types // "'self'" or "https://tailscale.com"). A nil slice value is allowed for some
`script-src 'self'`, // disallow inline javascript // directives like "upgrade-insecure-requests" that don't expect a list of
`frame-ancestors 'none'`, // disallow framing of the page // source definitions.
`form-action 'self'`, // disallow form submissions to other origins type CSP map[string][]string
`base-uri 'self'`, // disallow base URIs from other origins
`block-all-mixed-content`, // disallow mixed content when serving over HTTPS // DefaultCSP is the recommended CSP to use when not loading resources from
`object-src 'self'`, // disallow embedding of resources from other origins // other domains and not embedding the current website. If you need to tweak
}, "; ") // the CSP, it is recommended to extend DefaultCSP instead of writing your own
// from scratch.
func DefaultCSP() CSP {
return CSP{
"default-src": {"self"}, // origin is the only valid source for all content types
"frame-ancestors": {"none"}, // disallow framing of the page
"form-action": {"self"}, // disallow form submissions to other origins
"base-uri": {"self"}, // disallow base URIs from other origins
// TODO(awly): consider upgrade-insecure-requests in SecureContext
// instead, as this is deprecated.
"block-all-mixed-content": nil, // disallow mixed content when serving over HTTPS
}
}
// Set sets the values for a given directive. Empty values are allowed, if the
// directive doesn't expect any (like "upgrade-insecure-requests").
func (csp CSP) Set(directive string, values ...string) {
csp[directive] = values
}
// Add adds a source expression to an existing directive.
func (csp CSP) Add(directive, value string) {
csp[directive] = append(csp[directive], value)
}
// Del deletes a directive and all its values.
func (csp CSP) Del(directive string) {
delete(csp, directive)
}
func (csp CSP) String() string {
keys := slices.Collect(maps.Keys(csp))
slices.Sort(keys)
var s strings.Builder
for _, k := range keys {
s.WriteString(k)
for _, v := range csp[k] {
// Special values like 'self', 'none', 'unsafe-inline', etc., must
// be quoted. Do it implicitly as a convenience here.
if !strings.Contains(v, ".") && len(v) > 1 && v[0] != '\'' && v[len(v)-1] != '\'' {
v = "'" + v + "'"
}
s.WriteString(" " + v)
}
s.WriteString("; ")
}
return strings.TrimSpace(s.String())
}
// The default Strict-Transport-Security header. This header tells the browser // The default Strict-Transport-Security header. This header tells the browser
// to exclusively use HTTPS for all requests to the origin for the next year. // to exclusively use HTTPS for all requests to the origin for the next year.
@ -130,6 +179,9 @@ type Config struct {
// startup. // startup.
CSRFSecret []byte CSRFSecret []byte
// CSP is the Content-Security-Policy header to return with BrowserMux
// responses.
CSP CSP
// CSPAllowInlineStyles specifies whether to include `style-src: // CSPAllowInlineStyles specifies whether to include `style-src:
// unsafe-inline` in the Content-Security-Policy header to permit the use of // unsafe-inline` in the Content-Security-Policy header to permit the use of
// inline CSS. // inline CSS.
@ -168,6 +220,10 @@ func (c *Config) setDefaults() error {
} }
} }
if c.CSP == nil {
c.CSP = DefaultCSP()
}
return nil return nil
} }
@ -199,16 +255,20 @@ func NewServer(config Config) (*Server, error) {
if config.CookiesSameSiteLax { if config.CookiesSameSiteLax {
sameSite = csrf.SameSiteLaxMode sameSite = csrf.SameSiteLaxMode
} }
if config.CSPAllowInlineStyles {
if _, ok := config.CSP["style-src"]; ok {
config.CSP.Add("style-src", "unsafe-inline")
} else {
config.CSP.Set("style-src", "self", "unsafe-inline")
}
}
s := &Server{ s := &Server{
Config: config, Config: config,
csp: defaultCSP, csp: config.CSP.String(),
// only set Secure flag on CSRF cookies if we are in a secure context // only set Secure flag on CSRF cookies if we are in a secure context
// as otherwise the browser will reject the cookie // as otherwise the browser will reject the cookie
csrfProtect: csrf.Protect(config.CSRFSecret, csrf.Secure(config.SecureContext), csrf.SameSite(sameSite)), csrfProtect: csrf.Protect(config.CSRFSecret, csrf.Secure(config.SecureContext), csrf.SameSite(sameSite)),
} }
if config.CSPAllowInlineStyles {
s.csp = defaultCSP + `; style-src 'self' 'unsafe-inline'`
}
s.h = cmp.Or(config.HTTPServer, &http.Server{}) s.h = cmp.Or(config.HTTPServer, &http.Server{})
if s.h.Handler != nil { if s.h.Handler != nil {
return nil, fmt.Errorf("use safeweb.Config.APIMux and safeweb.Config.BrowserMux instead of http.Server.Handler") return nil, fmt.Errorf("use safeweb.Config.APIMux and safeweb.Config.BrowserMux instead of http.Server.Handler")

View File

@ -241,18 +241,26 @@ func TestCSRFProtection(t *testing.T) {
func TestContentSecurityPolicyHeader(t *testing.T) { func TestContentSecurityPolicyHeader(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
csp CSP
apiRoute bool apiRoute bool
wantCSP bool wantCSP string
}{ }{
{ {
name: "default routes get CSP headers", name: "default CSP",
apiRoute: false, wantCSP: `base-uri 'self'; block-all-mixed-content; default-src 'self'; form-action 'self'; frame-ancestors 'none';`,
wantCSP: true, },
{
name: "custom CSP",
csp: CSP{
"default-src": {"'self'", "https://tailscale.com"},
"upgrade-insecure-requests": nil,
},
wantCSP: `default-src 'self' https://tailscale.com; upgrade-insecure-requests;`,
}, },
{ {
name: "`/api/*` routes do not get CSP headers", name: "`/api/*` routes do not get CSP headers",
apiRoute: true, apiRoute: true,
wantCSP: false, wantCSP: "",
}, },
} }
@ -265,9 +273,9 @@ func TestContentSecurityPolicyHeader(t *testing.T) {
var s *Server var s *Server
var err error var err error
if tt.apiRoute { if tt.apiRoute {
s, err = NewServer(Config{APIMux: h}) s, err = NewServer(Config{APIMux: h, CSP: tt.csp})
} else { } else {
s, err = NewServer(Config{BrowserMux: h}) s, err = NewServer(Config{BrowserMux: h, CSP: tt.csp})
} }
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -279,8 +287,8 @@ func TestContentSecurityPolicyHeader(t *testing.T) {
s.h.Handler.ServeHTTP(w, req) s.h.Handler.ServeHTTP(w, req)
resp := w.Result() resp := w.Result()
if (resp.Header.Get("Content-Security-Policy") == "") == tt.wantCSP { if got := resp.Header.Get("Content-Security-Policy"); got != tt.wantCSP {
t.Fatalf("content security policy want: %v; got: %v", tt.wantCSP, resp.Header.Get("Content-Security-Policy")) t.Fatalf("content security policy want: %q; got: %q", tt.wantCSP, got)
} }
}) })
} }
@ -397,7 +405,7 @@ func TestCSPAllowInlineStyles(t *testing.T) {
csp := resp.Header.Get("Content-Security-Policy") csp := resp.Header.Get("Content-Security-Policy")
allowsStyles := strings.Contains(csp, "style-src 'self' 'unsafe-inline'") allowsStyles := strings.Contains(csp, "style-src 'self' 'unsafe-inline'")
if allowsStyles != allow { if allowsStyles != allow {
t.Fatalf("CSP inline styles want: %v; got: %v", allow, allowsStyles) t.Fatalf("CSP inline styles want: %v, got: %v in %q", allow, allowsStyles, csp)
} }
}) })
} }