Last active
December 29, 2024 12:25
-
-
Save egtann/0f686c0fadbd3c52121dc910b849ed3e to your computer and use it in GitHub Desktop.
Using a strict CSP with HTMX and Templ
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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() | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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> | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
It's not perfect because:
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.