diff --git a/http/csp.go b/http/csp.go new file mode 100644 index 0000000..12d9e72 --- /dev/null +++ b/http/csp.go @@ -0,0 +1,93 @@ +package http + +import ( + "encoding/base64" + "fmt" + "sort" + "strings" +) + +type ( + // CSP is a non-concurrency-safe map to hold a Content-Security-Policy + // and manipulate it afterwards to eventually render it into its + // header-representation + CSP map[string][]CSPSourceValue + // CSPHashAlgo defines the available hash algorithms + CSPHashAlgo string + // CSPSourceValue represents an value in the map for a given directive + CSPSourceValue string +) + +// Collection of pre-defined values. For documentation see +// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/Sources +const ( + CSPHashSHA256 CSPHashAlgo = "sha256" + CSPHashSHA384 CSPHashAlgo = "sha384" + CSPHashSHA512 CSPHashAlgo = "sha512" + + CSPSrcNone CSPSourceValue = "'none'" + CSPSrcReportSample CSPSourceValue = "'report-sample'" + CSPSrcSelf CSPSourceValue = "'self'" + CSPSrcStrictDynamic CSPSourceValue = "'strict-dynamic'" + CSPSrcUnsafeEval CSPSourceValue = "'unsafe-eval'" + CSPSrcUnsafeHashes CSPSourceValue = "'unsafe-hashes'" + CSPSrcUnsafeInline CSPSourceValue = "'unsafe-inline'" + CSPSrcWASMUnsafeEval CSPSourceValue = "'wasm-unsafe-eval'" + + CSPSrcSchemeData CSPSourceValue = "data:" + CSPSrcSchemeMediastream CSPSourceValue = "mediastream:" + CSPSrcSchemeBlob CSPSourceValue = "blob:" + CSPSrcSchemeFilesystem CSPSourceValue = "filesystem:" +) + +// CSPSrcHash takes an algo (sha256, sha384 or sha512) and the sum +// value and converts it into the right representation for the header +func CSPSrcHash(algo CSPHashAlgo, sum []byte) CSPSourceValue { + return CSPSourceValue(fmt.Sprintf("'%s-%s'", algo, base64.StdEncoding.EncodeToString(sum))) +} + +// CSPSrcNonce takes a base64 encoded nonce value and converts it +// into the right representation for the header +func CSPSrcNonce(b64Value string) CSPSourceValue { + return CSPSourceValue(fmt.Sprintf("'nonce-%s'", b64Value)) +} + +// Add adds a single CSPSourceValue to the given directive +func (c CSP) Add(directive string, value CSPSourceValue) { + c[directive] = append(c[directive], value) +} + +// Clone copies the CSP into a new one for manipulation +func (c CSP) Clone() CSP { + n := make(CSP) + for dir, vals := range c { + n[dir] = append([]CSPSourceValue{}, vals...) + } + return n +} + +// Set replaces all present values for the given directive +func (c CSP) Set(directive string, value CSPSourceValue) { + c[directive] = []CSPSourceValue{value} +} + +// ToHeaderValue encodes the policy into the format expected in the +// `Content-Security-Policy` header. +func (c CSP) ToHeaderValue() string { + var keys []string + for k := range c { + keys = append(keys, k) + } + sort.Strings(keys) + + var parts []string + for _, k := range keys { + lst := []string{k} + for _, v := range c[k] { + lst = append(lst, string(v)) + } + parts = append(parts, strings.Join(lst, " ")) + } + + return strings.Join(parts, ";") +} diff --git a/http/csp_test.go b/http/csp_test.go new file mode 100644 index 0000000..f0e9916 --- /dev/null +++ b/http/csp_test.go @@ -0,0 +1,33 @@ +package http + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCSPClone(t *testing.T) { + c1 := CSP{"default-src": []CSPSourceValue{CSPSrcNone}} + c2 := c1.Clone() + + c2.Add("default-src", CSPSrcSelf) // Makes no sense in real world! + + assert.NotEqual(t, c1["default-src"], c2["default-src"], "expect c1 not to have changed") +} + +func TestCSPToHeaderValue(t *testing.T) { + c := CSP{} + c.Set("default-src", CSPSrcNone) + + c.Set("connect-src", CSPSrcSelf) + c.Set("font-src", CSPSrcSelf) + c.Set("img-src", CSPSrcSelf) + c.Add("img-src", CSPSrcSchemeData) + c.Set("script-src", CSPSrcSelf) + c.Add("script-src", CSPSrcUnsafeInline) + c.Set("style-src", CSPSrcSelf) + + assert.Equal(t, + "connect-src 'self';default-src 'none';font-src 'self';img-src 'self' data:;script-src 'self' 'unsafe-inline';style-src 'self'", + c.ToHeaderValue()) +}