feat: add automatic CSRF protection for action calls
Generate a per-context CSRF token (128-bit, crypto/rand) and inject it
as a Datastar signal (via-csrf) alongside via-ctx. Validate with
constant-time comparison on /_action/{id} before executing, returning
403 on mismatch. Transparent to users since Datastar sends all signals
automatically.
Closes #9
This commit is contained in:
@@ -20,6 +20,7 @@ import (
|
|||||||
type Context struct {
|
type Context struct {
|
||||||
id string
|
id string
|
||||||
route string
|
route string
|
||||||
|
csrfToken string
|
||||||
app *V
|
app *V
|
||||||
view func() h.H
|
view func() h.H
|
||||||
routeParams map[string]string
|
routeParams map[string]string
|
||||||
@@ -477,6 +478,7 @@ func newContext(id string, route string, v *V) *Context {
|
|||||||
return &Context{
|
return &Context{
|
||||||
id: id,
|
id: id,
|
||||||
route: route,
|
route: route,
|
||||||
|
csrfToken: genCSRFToken(),
|
||||||
routeParams: make(map[string]string),
|
routeParams: make(map[string]string),
|
||||||
app: v,
|
app: v,
|
||||||
componentRegistry: make(map[string]*Context),
|
componentRegistry: make(map[string]*Context),
|
||||||
|
|||||||
15
via.go
15
via.go
@@ -10,6 +10,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -203,7 +204,7 @@ func (v *V) Page(route string, initContextFn func(c *Context)) {
|
|||||||
headElements := []h.H{h.Script(h.Type("module"), h.Src(v.datastarPath))}
|
headElements := []h.H{h.Script(h.Type("module"), h.Src(v.datastarPath))}
|
||||||
headElements = append(headElements, v.documentHeadIncludes...)
|
headElements = append(headElements, v.documentHeadIncludes...)
|
||||||
headElements = append(headElements,
|
headElements = append(headElements,
|
||||||
h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id))),
|
h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s','via-csrf':'%s'}", id, c.csrfToken))),
|
||||||
h.Meta(h.Data("init", "@get('/_sse')")),
|
h.Meta(h.Data("init", "@get('/_sse')")),
|
||||||
h.Meta(h.Data("init", fmt.Sprintf(`window.addEventListener('beforeunload', (evt) => {
|
h.Meta(h.Data("init", fmt.Sprintf(`window.addEventListener('beforeunload', (evt) => {
|
||||||
navigator.sendBeacon('/_session/close', '%s');});`, c.id))),
|
navigator.sendBeacon('/_session/close', '%s');});`, c.id))),
|
||||||
@@ -632,6 +633,12 @@ func New() *V {
|
|||||||
v.logErr(nil, "action '%s' failed: %v", actionID, err)
|
v.logErr(nil, "action '%s' failed: %v", actionID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
csrfToken, _ := sigs["via-csrf"].(string)
|
||||||
|
if subtle.ConstantTimeCompare([]byte(csrfToken), []byte(c.csrfToken)) != 1 {
|
||||||
|
v.logWarn(c, "action '%s' rejected: invalid CSRF token", actionID)
|
||||||
|
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
c.reqCtx = r.Context()
|
c.reqCtx = r.Context()
|
||||||
actionFn, err := c.getActionFn(actionID)
|
actionFn, err := c.getActionFn(actionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -675,6 +682,12 @@ func genRandID() string {
|
|||||||
return hex.EncodeToString(b)[:8]
|
return hex.EncodeToString(b)[:8]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func genCSRFToken() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
func extractParams(pattern, path string) map[string]string {
|
func extractParams(pattern, path string) map[string]string {
|
||||||
p := strings.Split(strings.Trim(pattern, "/"), "/")
|
p := strings.Split(strings.Trim(pattern, "/"), "/")
|
||||||
u := strings.Split(strings.Trim(path, "/"), "/")
|
u := strings.Split(strings.Trim(path, "/"), "/")
|
||||||
|
|||||||
Reference in New Issue
Block a user