diff --git a/.version b/.version index 8d31eb250..7e3525dda 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -0.2.699 \ No newline at end of file +0.2.699 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..6ba5e0410 --- /dev/null +++ b/docs/docs/10-security/02-content-security-policy.md @@ -0,0 +1,86 @@ +# Content security policy + +## Nonces + +In templ [script templates](/syntax-and-usage/script-templates#script-templates) are rendered as inline ` + +``` 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..473850196 --- /dev/null +++ b/docs/docs/10-security/03-code-signing.md @@ -0,0 +1,7 @@ +# Code signing + +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/examples/content-security-policy/main.go b/examples/content-security-policy/main.go new file mode 100644 index 000000000..72c33a410 --- /dev/null +++ b/examples/content-security-policy/main.go @@ -0,0 +1,67 @@ +package main + +import ( + "crypto/rand" + "fmt" + "math/big" + "net/http" + "os" + + "log/slog" + + "github.com/a-h/templ" +) + +func main() { + log := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + + // Create HTTP routes. + mux := http.NewServeMux() + mux.Handle("/", templ.Handler(template())) + + // Wrap the router with CSP middleware to apply the CSP nonce to templ scripts. + withCSPMiddleware := NewCSPMiddleware(log, mux) + + log.Info("Listening...", slog.String("addr", "127.0.0.1:7001")) + if err := http.ListenAndServe("127.0.0.1:7001", withCSPMiddleware); err != nil { + log.Error("failed to start server", slog.Any("error", err)) + } +} + +func NewCSPMiddleware(log *slog.Logger, next http.Handler) *CSPMiddleware { + return &CSPMiddleware{ + Log: log, + Next: next, + Size: 28, + } +} + +type CSPMiddleware struct { + Log *slog.Logger + Next http.Handler + Size int +} + +func (m *CSPMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { + nonce, err := m.generateNonce() + if err != nil { + m.Log.Error("failed to generate nonce", slog.Any("error", err)) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } + ctx := templ.WithNonce(r.Context(), nonce) + w.Header().Add("Content-Security-Policy", fmt.Sprintf("script-src 'nonce-%s'", nonce)) + m.Next.ServeHTTP(w, r.WithContext(ctx)) +} + +func (m *CSPMiddleware) generateNonce() (string, error) { + const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-" + ret := make([]byte, m.Size) + for i := 0; i < m.Size; 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-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/runtime.go b/runtime.go index ac6952eed..9216edcf2 100644 --- a/runtime.go +++ b/runtime.go @@ -41,6 +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 @@ -578,6 +592,7 @@ const contextKey = contextKeyType(0) type contextValue struct { ss map[string]struct{} children *Component + nonce string } func (v *contextValue) setHasOnceBeenRendered(id string) { @@ -678,13 +693,22 @@ type ComponentScript struct { var _ Component = ComponentScript{} +func writeScriptHeader(ctx context.Context, w io.Writer) (err error) { + var nonceAttr string + if nonce := GetNonce(ctx); nonce != "" { + nonceAttr = " nonce=\"" + EscapeString(nonce) + "\"" + } + _, err = fmt.Fprintf(w, `