Skip to content

Commit

Permalink
Feature/csp builder (#86)
Browse files Browse the repository at this point in the history
* add basic csp builder setup, most special-case handlers and tests

* split logic into additional files, add tests

* rename csp_builder to be same as package name, add docs in README

* fix issues reported by golangci-lint
  • Loading branch information
robot-5 authored Sep 6, 2022
1 parent 0ce3852 commit a0273b7
Show file tree
Hide file tree
Showing 6 changed files with 776 additions and 17 deletions.
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,27 @@ The STS header will only be sent on verified HTTPS connections (and when `IsDeve
### Content Security Policy
If you need dynamic support for CSP while using Websockets, check out this other middleware [awakenetworks/csp](https://github.com/awakenetworks/csp).

Otherwise you can use the CSP Builder to create a CSP in a safer way:

~~~ go
import (
"github.com/unrolled/secure"
"github.com/unrolled/secure/cspbuilder"
)

cspBuilder := cspbuilder.Builder{
Directives: map[string][]string{
cspbuilder.DefaultSrc: {"self"},
cspbuilder.ScriptSrc: {"self", "www.google-analytics.com"},
cspbuilder.ImgSrc: {"*"},
},
}

opt := secure.Options{
ContentSecurityPolicy: cspBuilder.MustBuild(),
}
~~~

## Integration examples

### [chi](https://github.com/pressly/chi)
Expand Down
110 changes: 110 additions & 0 deletions cspbuilder/builder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package cspbuilder

import (
"strings"
)

const (
// Fetch Directives
ChildSrc = "child-src"
ConnectSrc = "connect-src"
DefaultSrc = "default-src"
FontSrc = "font-src"
FrameSrc = "frame-src"
ImgSrc = "img-src"
ManifestSrc = "manifest-src"
MediaSrc = "media-src"
ObjectSrc = "object-src"
PrefetchSrc = "prefetch-src"
ScriptSrc = "script-src"
ScriptSrcAttr = "script-src-attr"
ScriptSrcElem = "script-src-elem"
StyleSrc = "style-src"
StyleSrcAttr = "style-src-attr"
StyleSrcElem = "style-src-elem"
WorkerSrc = "worker-src"

// Document Directives
BaseURI = "base-uri"
Sandbox = "sandbox"

// Navigation directives
FormAction = "form-action"
FrameAncestors = "frame-ancestors"
NavigateTo = "navigate-to"

// Reporting directives
ReportURI = "report-uri"
ReportTo = "report-to"

// Other directives
RequireTrustedTypesFor = "require-trusted-types-for"
TrustedTypes = "trusted-types"
UpgradeInsecureRequests = "upgrade-insecure-requests"
)

type Builder struct {
Directives map[string]([]string)
}

// MustBuild is like Build but panics if an error occurs.
func (builder *Builder) MustBuild() string {
policy, err := builder.Build()
if err != nil {
panic(err)
}
return policy
}

// Build creates a content security policy string from the specified directives.
// If any directive contains invalid values, an error is returned instead.
func (builder *Builder) Build() (string, error) {
var sb strings.Builder

for directive := range builder.Directives {
if sb.Len() > 0 {
sb.WriteString("; ")
}

switch directive {
case Sandbox:
err := buildDirectiveSandbox(&sb, builder.Directives[directive])
if err != nil {
return "", err
}
case FrameAncestors:
err := buildDirectiveFrameAncestors(&sb, builder.Directives[directive])
if err != nil {
return "", err
}
case ReportTo:
err := buildDirectiveReportTo(&sb, builder.Directives[directive])
if err != nil {
return "", err
}
case RequireTrustedTypesFor:
err := buildDirectiveRequireTrustedTypesFor(&sb, builder.Directives[directive])
if err != nil {
return "", err
}
case TrustedTypes:
err := buildDirectiveTrustedTypes(&sb, builder.Directives[directive])
if err != nil {
return "", err
}
case UpgradeInsecureRequests:
err := buildDirectiveUpgradeInsecureRequests(&sb, builder.Directives[directive])
if err != nil {
return "", err
}
default:
// no special handling of directive values needed
err := buildDirectiveDefault(&sb, directive, builder.Directives[directive])
if err != nil {
return "", err
}
}
}

return sb.String(), nil
}
141 changes: 141 additions & 0 deletions cspbuilder/builder_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package cspbuilder

import (
"reflect"
"sort"
"strings"
"testing"
)

func TestContentSecurityPolicyBuilder_Build_SingleDirective(t *testing.T) {
tests := []struct {
name string
directiveName string
directiveValues []string
want string
wantErr bool
}{
{
name: "empty default-src",
directiveName: DefaultSrc,
directiveValues: nil,
want: "",
wantErr: true,
},
{
name: "single default-src",
directiveName: DefaultSrc,
directiveValues: []string{"'self'"},
want: "default-src 'self'",
},
{
name: "multiple default-src",
directiveName: DefaultSrc,
directiveValues: []string{"'self'", "example.com", "*.example.com"},
want: "default-src 'self' example.com *.example.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
builder := &Builder{
Directives: map[string][]string{
tt.directiveName: tt.directiveValues,
},
}
got, err := builder.Build()
if (err != nil) != tt.wantErr {
t.Errorf("ContentSecurityPolicyBuilder.Build() error = %v, wantErr %v", err, tt.wantErr)
return
}

if got != tt.want {
t.Errorf("ContentSecurityPolicyBuilder.Build() = '%v', want '%v'", got, tt.want)
}
})
}
}

func TestContentSecurityPolicyBuilder_Build_MultipleDirectives(t *testing.T) {
tests := []struct {
name string
directives map[string]([]string)
builder Builder
wantParts []string
wantErr bool
}{
{
name: "multiple valid directives",
directives: map[string]([]string){
"default-src": {"'self'", "example.com", "*.example.com"},
"sandbox": {"allow-scripts"},
"frame-ancestors": {"'self'", "http://*.example.com"},
"report-to": {"group1"},
"require-trusted-types-for": {"'script'"},
"trusted-types": {"policy-1", "policy-#=_/@.%", "'allow-duplicates'"},
"upgrade-insecure-requests": nil,
},

wantParts: []string{
"default-src 'self' example.com *.example.com",
"sandbox allow-scripts",
"frame-ancestors 'self' http://*.example.com",
"report-to group1",
"require-trusted-types-for 'script'",
"trusted-types policy-1 policy-#=_/@.% 'allow-duplicates'",
"upgrade-insecure-requests",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
builder := &Builder{
Directives: tt.directives,
}
got, err := builder.Build()
if (err != nil) != tt.wantErr {
t.Errorf("ContentSecurityPolicyBuilder.Build() error = %v, wantErr %v", err, tt.wantErr)
return
}

{
startsWithDirective := false
for directive := range tt.directives {
if strings.HasPrefix(got, directive) {
startsWithDirective = true
break
}
}
if !startsWithDirective {
t.Errorf("ContentSecurityPolicyBuilder.Build() = '%v', does not start with directive name", got)
}
}

if strings.HasSuffix(got, " ") {
t.Errorf("ContentSecurityPolicyBuilder.Build() = '%v', ends on whitespace", got)
}
if strings.HasSuffix(got, ";") {
t.Errorf("ContentSecurityPolicyBuilder.Build() = '%v', ends on semi-colon", got)
}

// order of directives in created string is not guaranteed
// check output contains all expected parts
{
gotParts := strings.Split(got, "; ")
if len(gotParts) != len(tt.wantParts) {
t.Errorf("Got %d parts, want %d", len(gotParts), len(tt.wantParts))
}

sort.Slice(gotParts, func(i, j int) bool {
return gotParts[i] < gotParts[j]
})
sort.Slice(tt.wantParts, func(i, j int) bool {
return tt.wantParts[i] < tt.wantParts[j]
})

if !reflect.DeepEqual(gotParts, tt.wantParts) {
t.Errorf("ContentSecurityPolicyBuilder.Build() = '%v', expected following parts %v", got, gotParts)
}
}
})
}
}
Loading

0 comments on commit a0273b7

Please sign in to comment.