From 2bd05989a120a5a5c2f2ccf3e8f9ec62814ed7ba Mon Sep 17 00:00:00 2001 From: joerdav Date: Mon, 20 May 2024 16:36:40 +0100 Subject: [PATCH 1/3] feat: add WithNonce for CSP compatability --- .../{index.md => 01-injection-attacks.md} | 9 +- .../10-security/02-content-security-policy.md | 113 ++++++++++++++++++ docs/docs/10-security/03-code-signing.md | 4 + examples/content-security-policy/main.go | 63 ++++++++++ .../content-security-policy/templates.templ | 9 ++ .../templates_templ.go | 44 +++++++ generator/test-script-usage/expected.html | 6 +- generator/test-script-usage/render_test.go | 5 +- runtime.go | 27 ++++- 9 files changed, 266 insertions(+), 14 deletions(-) rename docs/docs/10-security/{index.md => 01-injection-attacks.md} (94%) create mode 100644 docs/docs/10-security/02-content-security-policy.md create mode 100644 docs/docs/10-security/03-code-signing.md create mode 100644 examples/content-security-policy/main.go create mode 100644 examples/content-security-policy/templates.templ create mode 100644 examples/content-security-policy/templates_templ.go diff --git a/docs/docs/10-security/index.md b/docs/docs/10-security/01-injection-attacks.md similarity index 94% rename from docs/docs/10-security/index.md rename to docs/docs/10-security/01-injection-attacks.md index c7775e876..9a1f832cf 100644 --- a/docs/docs/10-security/index.md +++ b/docs/docs/10-security/01-injection-attacks.md @@ -1,6 +1,4 @@ -# Security - -## Injection attacks +# Injection attacks templ is designed to prevent user-provided data from being used to inject vulnerabilities. @@ -87,8 +85,3 @@ css className() { color: { red }; } ``` - -## Code signing - -Binaries are created by https://github.com/a-h and signed with https://adrianhesketh.com/a-h.gpg - diff --git a/docs/docs/10-security/02-content-security-policy.md b/docs/docs/10-security/02-content-security-policy.md new file mode 100644 index 000000000..5f2ccef44 --- /dev/null +++ b/docs/docs/10-security/02-content-security-policy.md @@ -0,0 +1,113 @@ +# Content security policy + +## Nonces + +In templ [script templates](/syntax-and-usage/script-templates#script-templates) are rendered as inline ` + +``` + +## Nonce Middleware + +Generate and apply nonces in a middleware to remove repeated code in handlers: + +```go title="main.go" +package main + +import ( + "fmt" + "log" + "net/http" + "time" +) + +func nonceMiddleware(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nonce := generateSecurelyRandomString() + ctx := templ.WithNonce(context.Background(), nonce) + w.Header().Add("Content-Security-Policy", fmt.Sprintf("script-src 'nonce-%s'", nonce)) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func main() { + mux := http.NewServeMux() + + // Handle template. + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if err := template().Render(ctx, os.Stdout); err != nil { + http.Error(w, "failed to render", 500) + } + }) + + // Start the server. + fmt.Println("listening on :8080") + // Apply middlewares. + mux = nonceMiddleware(mux) + if err := http.ListenAndServe(":8080", mux); err != nil { + log.Printf("error listening: %v", err) + } +} +``` diff --git a/docs/docs/10-security/03-code-signing.md b/docs/docs/10-security/03-code-signing.md new file mode 100644 index 000000000..aa3046f4d --- /dev/null +++ b/docs/docs/10-security/03-code-signing.md @@ -0,0 +1,4 @@ +# Code signing + +Binaries are created by https://github.com/a-h and signed with https://adrianhesketh.com/a-h.gpg + diff --git a/examples/content-security-policy/main.go b/examples/content-security-policy/main.go new file mode 100644 index 000000000..1e36694e2 --- /dev/null +++ b/examples/content-security-policy/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "crypto/rand" + "fmt" + "math/big" + "net/http" + + "github.com/a-h/templ" +) + +func main() { + mux := http.NewServeMux() + + mux.HandleFunc("/basic", func(w http.ResponseWriter, r *http.Request) { + nonce, err := generateRandomString(28) + if err != nil { + // ... + } + ctx := templ.WithNonce(r.Context(), nonce) + w.Header().Add("Content-Security-Policy", fmt.Sprintf("script-src 'nonce-%s'", nonce)) + err = template().Render(ctx, w) + if err != nil { + // ... + } + }) + + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := template().Render(r.Context(), w) + if err != nil { + // ... + } + }) + mux.Handle("/middleware", scriptNonceMiddleware(h)) + + http.ListenAndServe("127.0.0.1:7000", mux) +} + +func scriptNonceMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nonce, err := generateRandomString(28) + if err != nil { + // ... + } + ctx := templ.WithNonce(r.Context(), nonce) + w.Header().Add("Content-Security-Policy", fmt.Sprintf("script-src 'nonce-%s'", nonce)) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func generateRandomString(n int) (string, error) { + const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-" + ret := make([]byte, n) + for i := 0; i < n; i++ { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) + if err != nil { + return "", err + } + ret[i] = letters[num.Int64()] + } + + return string(ret), nil +} diff --git a/examples/content-security-policy/templates.templ b/examples/content-security-policy/templates.templ new file mode 100644 index 000000000..c88808adc --- /dev/null +++ b/examples/content-security-policy/templates.templ @@ -0,0 +1,9 @@ +package main + +script sayHello() { + alert("Hello") +} + +templ template() { + @sayHello() +} diff --git a/examples/content-security-policy/templates_templ.go b/examples/content-security-policy/templates_templ.go new file mode 100644 index 000000000..4d463f6cb --- /dev/null +++ b/examples/content-security-policy/templates_templ.go @@ -0,0 +1,44 @@ +// Code generated by templ - DO NOT EDIT. + +package main + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import "context" +import "io" +import "bytes" + +func sayHello() templ.ComponentScript { + return templ.ComponentScript{ + Name: `__templ_sayHello_6bd3`, + Function: `function __templ_sayHello_6bd3(){alert("Hello") +}`, + Call: templ.SafeScript(`__templ_sayHello_6bd3`), + CallInline: templ.SafeScriptInline(`__templ_sayHello_6bd3`), + } +} + +func template() templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = sayHello().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} diff --git a/generator/test-script-usage/expected.html b/generator/test-script-usage/expected.html index bffa3c630..9f15abc46 100644 --- a/generator/test-script-usage/expected.html +++ b/generator/test-script-usage/expected.html @@ -1,4 +1,4 @@ - - diff --git a/generator/test-script-usage/render_test.go b/generator/test-script-usage/render_test.go index 9aea4ba67..48b2da861 100644 --- a/generator/test-script-usage/render_test.go +++ b/generator/test-script-usage/render_test.go @@ -1,9 +1,11 @@ package testscriptusage import ( + "context" _ "embed" "testing" + "github.com/a-h/templ" "github.com/a-h/templ/generator/htmldiff" ) @@ -13,7 +15,8 @@ var expected string func Test(t *testing.T) { component := ThreeButtons() - diff, err := htmldiff.Diff(component, expected) + ctx := templ.WithNonce(context.Background(), "nonce1") + diff, err := htmldiff.DiffCtx(ctx, component, expected) if err != nil { t.Fatal(err) } diff --git a/runtime.go b/runtime.go index 3a86939a5..c430a0f2b 100644 --- a/runtime.go +++ b/runtime.go @@ -41,6 +41,12 @@ func (cf ComponentFunc) Render(ctx context.Context, w io.Writer) error { return cf(ctx, w) } +func WithNonce(ctx context.Context, nonce string) context.Context { + ctx, v := getContext(ctx) + v.nonce = nonce + return ctx +} + func WithChildren(ctx context.Context, children Component) context.Context { ctx, v := getContext(ctx) v.children = &children @@ -578,6 +584,7 @@ const contextKey = contextKeyType(0) type contextValue struct { ss map[string]struct{} children *Component + nonce string } func (v *contextValue) addScript(s string) { @@ -663,13 +670,29 @@ type ComponentScript struct { var _ Component = ComponentScript{} +func writeScriptHeader(ctx context.Context, w io.Writer) error { + _, ctxv := getContext(ctx) + if _, err := io.WriteString(w, ` - -``` - -## Nonce Middleware - -Generate and apply nonces in a middleware to remove repeated code in handlers: - ```go title="main.go" package main @@ -83,11 +47,12 @@ import ( "time" ) -func nonceMiddleware(handler http.Handler) http.Handler { +func withNonce(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - nonce := generateSecurelyRandomString() - ctx := templ.WithNonce(context.Background(), nonce) + nonce := securelyGenerateRandomString() w.Header().Add("Content-Security-Policy", fmt.Sprintf("script-src 'nonce-%s'", nonce)) + // Use the context to pass the nonce to the handler. + ctx := templ.WithNonce(r.Context(), nonce) next.ServeHTTP(w, r.WithContext(ctx)) }) } @@ -96,18 +61,26 @@ func main() { mux := http.NewServeMux() // Handle template. - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - if err := template().Render(ctx, os.Stdout); err != nil { - http.Error(w, "failed to render", 500) - } - }) + mux.HandleFunc("/", templ.Handler(template())) + + // Apply middleware. + withNonceMux := withNonce(mux) // Start the server. fmt.Println("listening on :8080") - // Apply middlewares. - mux = nonceMiddleware(mux) - if err := http.ListenAndServe(":8080", mux); err != nil { + if err := http.ListenAndServe(":8080", withNonceMux); err != nil { log.Printf("error listening: %v", err) } } ``` + +```html title="Output" + + +``` diff --git a/docs/docs/10-security/03-code-signing.md b/docs/docs/10-security/03-code-signing.md index aa3046f4d..473850196 100644 --- a/docs/docs/10-security/03-code-signing.md +++ b/docs/docs/10-security/03-code-signing.md @@ -1,4 +1,7 @@ # Code signing -Binaries are created by https://github.com/a-h and signed with https://adrianhesketh.com/a-h.gpg +Binaries are created by the Github Actions workflow at https://github.com/a-h/templ/blob/main/.github/workflows/release.yml +Binaries are signed by cosign. The public key is stored in the repository at https://github.com/a-h/templ/blob/main/cosign.pub + +Instructions for key verification at https://docs.sigstore.dev/verifying/verify/ diff --git a/generator/test-script-usage-nonce/expected.html b/generator/test-script-usage-nonce/expected.html new file mode 100644 index 000000000..9f15abc46 --- /dev/null +++ b/generator/test-script-usage-nonce/expected.html @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/generator/test-script-usage-nonce/render_test.go b/generator/test-script-usage-nonce/render_test.go new file mode 100644 index 000000000..48b2da861 --- /dev/null +++ b/generator/test-script-usage-nonce/render_test.go @@ -0,0 +1,26 @@ +package testscriptusage + +import ( + "context" + _ "embed" + "testing" + + "github.com/a-h/templ" + "github.com/a-h/templ/generator/htmldiff" +) + +//go:embed expected.html +var expected string + +func Test(t *testing.T) { + component := ThreeButtons() + + ctx := templ.WithNonce(context.Background(), "nonce1") + diff, err := htmldiff.DiffCtx(ctx, component, expected) + if err != nil { + t.Fatal(err) + } + if diff != "" { + t.Error(diff) + } +} diff --git a/generator/test-script-usage-nonce/template.templ b/generator/test-script-usage-nonce/template.templ new file mode 100644 index 000000000..9364a7525 --- /dev/null +++ b/generator/test-script-usage-nonce/template.templ @@ -0,0 +1,44 @@ +package testscriptusage + +script withParameters(a string, b string, c int) { + console.log(a, b, c); +} + +script withoutParameters() { + alert("hello"); +} + +script onClick() { + alert("clicked"); +} + +templ Button(text string) { + +} + +script withComment() { + //' +} + +templ ThreeButtons() { + @Button("A") + @Button("B") + + + + @Conditional(true) +} + +script conditionalScript() { + alert("conditional"); +} + +templ Conditional(show bool) { + +} diff --git a/generator/test-script-usage-nonce/template_templ.go b/generator/test-script-usage-nonce/template_templ.go new file mode 100644 index 000000000..e30405e4b --- /dev/null +++ b/generator/test-script-usage-nonce/template_templ.go @@ -0,0 +1,219 @@ +// Code generated by templ - DO NOT EDIT. + +package testscriptusage + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import "context" +import "io" +import "bytes" + +func withParameters(a string, b string, c int) templ.ComponentScript { + return templ.ComponentScript{ + Name: `__templ_withParameters_1056`, + Function: `function __templ_withParameters_1056(a, b, c){console.log(a, b, c); +}`, + Call: templ.SafeScript(`__templ_withParameters_1056`, a, b, c), + CallInline: templ.SafeScriptInline(`__templ_withParameters_1056`, a, b, c), + } +} + +func withoutParameters() templ.ComponentScript { + return templ.ComponentScript{ + Name: `__templ_withoutParameters_6bbf`, + Function: `function __templ_withoutParameters_6bbf(){alert("hello"); +}`, + Call: templ.SafeScript(`__templ_withoutParameters_6bbf`), + CallInline: templ.SafeScriptInline(`__templ_withoutParameters_6bbf`), + } +} + +func onClick() templ.ComponentScript { + return templ.ComponentScript{ + Name: `__templ_onClick_657d`, + Function: `function __templ_onClick_657d(){alert("clicked"); +}`, + Call: templ.SafeScript(`__templ_onClick_657d`), + CallInline: templ.SafeScriptInline(`__templ_onClick_657d`), + } +} + +func Button(text string) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, withParameters("test", text, 123), withoutParameters()) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} + +func withComment() templ.ComponentScript { + return templ.ComponentScript{ + Name: `__templ_withComment_9cf8`, + Function: `function __templ_withComment_9cf8(){//' +}`, + Call: templ.SafeScript(`__templ_withComment_9cf8`), + CallInline: templ.SafeScriptInline(`__templ_withComment_9cf8`), + } +} + +func ThreeButtons() templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var5 := templ.GetChildren(ctx) + if templ_7745c5c3_Var5 == nil { + templ_7745c5c3_Var5 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = Button("A").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Button("B").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, onClick()) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Conditional(true).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} + +func conditionalScript() templ.ComponentScript { + return templ.ComponentScript{ + Name: `__templ_conditionalScript_de41`, + Function: `function __templ_conditionalScript_de41(){alert("conditional"); +}`, + Call: templ.SafeScript(`__templ_conditionalScript_de41`), + CallInline: templ.SafeScriptInline(`__templ_conditionalScript_de41`), + } +} + +func Conditional(show bool) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var7 := templ.GetChildren(ctx) + if templ_7745c5c3_Var7 == nil { + templ_7745c5c3_Var7 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, conditionalScript()) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} diff --git a/generator/test-script-usage/expected.html b/generator/test-script-usage/expected.html index 9f15abc46..bffa3c630 100644 --- a/generator/test-script-usage/expected.html +++ b/generator/test-script-usage/expected.html @@ -1,4 +1,4 @@ - - diff --git a/generator/test-script-usage/render_test.go b/generator/test-script-usage/render_test.go index 48b2da861..9aea4ba67 100644 --- a/generator/test-script-usage/render_test.go +++ b/generator/test-script-usage/render_test.go @@ -1,11 +1,9 @@ package testscriptusage import ( - "context" _ "embed" "testing" - "github.com/a-h/templ" "github.com/a-h/templ/generator/htmldiff" ) @@ -15,8 +13,7 @@ var expected string func Test(t *testing.T) { component := ThreeButtons() - ctx := templ.WithNonce(context.Background(), "nonce1") - diff, err := htmldiff.DiffCtx(ctx, component, expected) + diff, err := htmldiff.Diff(component, expected) if err != nil { t.Fatal(err) } diff --git a/runtime.go b/runtime.go index c430a0f2b..b918a3f7f 100644 --- a/runtime.go +++ b/runtime.go @@ -41,12 +41,20 @@ func (cf ComponentFunc) Render(ctx context.Context, w io.Writer) error { return cf(ctx, w) } +// WithNonce sets a CSP nonce on the context and returns it. func WithNonce(ctx context.Context, nonce string) context.Context { ctx, v := getContext(ctx) v.nonce = nonce return ctx } +// GetNonce returns the CSP nonce value set with WithNonce, or an +// empty string if none has been set. +func GetNonce(ctx context.Context) (nonce string) { + _, v := getContext(ctx) + return v.nonce +} + func WithChildren(ctx context.Context, children Component) context.Context { ctx, v := getContext(ctx) v.children = &children @@ -670,20 +678,13 @@ type ComponentScript struct { var _ Component = ComponentScript{} -func writeScriptHeader(ctx context.Context, w io.Writer) error { - _, ctxv := getContext(ctx) - if _, err := io.WriteString(w, `