Skip to content

Instantly share code, notes, and snippets.

@egtann
Last active May 10, 2024 04:44
Show Gist options
  • Save egtann/0f686c0fadbd3c52121dc910b849ed3e to your computer and use it in GitHub Desktop.
Save egtann/0f686c0fadbd3c52121dc910b849ed3e to your computer and use it in GitHub Desktop.
Using a strict CSP with HTMX and Templ
// Example component with styling and script tags. The script+style will work even when
// retrieved after page load via HTMX because of our X-Nonce header/middleware.
templ MyComponent() {
<style nonce={ nonceFrom(ctx) }>
.red {
color: red;
}
</style>
<script nonce={ nonceFrom(ctx) }>
console.log("here")
</script>
<div class="red">Hello World</div>
}
templ Page() {
<!DOCTYPE html>
@csrf()
@MyComponent()
}
// nonceFrom is a helper to make it more convenient to use.
func nonceFrom(ctx context.Context) string {
nonce, _ := ctx.Value(app.NonceKey).(string)
return nonce
}
// csrf injects meta tags and scripts. The meta tag is a hack to work around
// the fact we can't embed go variables into a script tag, and we also can't
// sign "script" (non-templ) templates.
templ csrf() {
<meta name="_csrf" content={ csrfTokenFrom(ctx) }/>
<meta name="_nonce" content={ nonceFrom(ctx) }/>
<script nonce={ nonceFrom(ctx) }>
const csrf = document.querySelector(`meta[name="_csrf"]`)
const nonce = document.querySelector(`meta[name="_nonce"]`)
window.addEventListener("htmx:configRequest", (event) => {
event.detail.headers["X-CSRF-Token"] = csrf.content
event.detail.headers["X-Nonce"] = nonce.content
})
</script>
}
// setCSP secures the user against XSS attacks. Since we use inline styles and
// scripts, this applies a cryptographically random 16-byte nonce to the
// context for the browser to verify inline scripts and source tags.
func setCSP(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
byt := make([]byte, 16)
_, err := rand.Read(byt)
if err != nil {
err = fmt.Errorf("read: %w", err)
http.Error(w, err.Error(),
http.StatusInternalServerError)
return
}
nonce := base64.URLEncoding.EncodeToString(byt)
ctx := context.WithValue(r.Context(), app.NonceKey, nonce)
csp := []string{
"default-src 'self'",
fmt.Sprintf("script-src 'self' 'nonce-%s'", nonce),
fmt.Sprintf("style-src 'self' 'nonce-%s'", nonce),
}
h := w.Header()
h.Set("Content-Security-Policy", strings.Join(csp, "; "))
next.ServeHTTP(w, r.WithContext(ctx))
}
return http.HandlerFunc(fn)
}
// setNonce overrides the nonce in the context to match the one provided by the
// client. This enables us to re-use the same nonce on subsequent htmx ajax
// requests as long as we're on the same page.
func setNonce(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
if nonce := r.Header.Get("X-Nonce"); nonce != "" {
ctx := context.WithValue(r.Context(), app.NonceKey, nonce)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
@egtann
Copy link
Author

egtann commented Dec 26, 2023

It's not perfect because:

  1. Any nonce added by HTMX requests is embedded in the HTML itself (browsers remove it on page load--we lose that benefit in HTMX)
  2. An attacker can manipulate the nonce the server returns on htmx calls by controlling the X-Nonce header, but it's only valid for that page load.

We could improve this by stripping the nonce from the DOM ourselves on our meta tags and whenever HTMX content is loaded in, which might help both of these issues above.

The safest/best way to do this is to not use the above at all and instead have separate JS/CSS files in the old-fashioned way and secured either with a SHA or a nonce.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment