docs+rules: teach httputil.Bind/BindEmpty (web v1.6.0); v1.3.4
Builtins README gains Bind/BindEmpty adapter rows + a path/query binding section (uuid/time via TextUnmarshaler; default: with min not omitempty,min). New info rule httputil.prefer-bind flags hand-rolled chi.URLParam / r.URL.Query() reads and points to Bind. Escape-hatch note rescoped to custom responses only.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// web/httputil v1.6.0 added Bind/BindEmpty, which fill a typed Req from path and
|
||||
// query struct tags and validate it once. Reading a parameter by hand — chi.URLParam
|
||||
// or r.URL.Query() inside a handler — skips that validation, which is the path by
|
||||
// which unclamped bounds and 500s-that-should-be-400s reach production. This rule
|
||||
// nudges toward Bind; it is advisory (info), since a genuinely custom response
|
||||
// (streaming, non-JSON) may still read parameters directly.
|
||||
func init() {
|
||||
registered = append(registered,
|
||||
Rule{
|
||||
ID: "httputil.prefer-bind",
|
||||
Severity: SeverityInfo,
|
||||
Module: "web",
|
||||
Check: checkPreferBind,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func checkPreferBind(c *Context) []Finding {
|
||||
const (
|
||||
msg = "reading a path/query parameter by hand skips validation — httputil.Bind / BindEmpty (v1.6.0) fill a typed Req from path:/query: tags and validate it once"
|
||||
hint = "Declare the parameter as a struct field (path:\"id\" / query:\"page\") and use httputil.Bind. Keep manual parsing only for genuinely custom responses (streaming, non-JSON)."
|
||||
)
|
||||
var hits []Finding
|
||||
seen := map[int]bool{}
|
||||
ast.Inspect(c.File, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
name := exprName(call.Fun)
|
||||
manual := strings.HasSuffix(name, ".URLParam") ||
|
||||
strings.HasSuffix(name, ".URLParamFromCtx") ||
|
||||
strings.HasSuffix(name, ".URL.Query")
|
||||
if !manual {
|
||||
return true
|
||||
}
|
||||
line := c.Fset.Position(call.Pos()).Line
|
||||
if seen[line] {
|
||||
return true
|
||||
}
|
||||
seen[line] = true
|
||||
hits = append(hits, Finding{Message: msg, Hint: hint, Line: line})
|
||||
return true
|
||||
})
|
||||
return hits
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package rules
|
||||
|
||||
import "testing"
|
||||
|
||||
const urlParamSnippet = `package handler
|
||||
|
||||
import "github.com/go-chi/chi/v5"
|
||||
|
||||
func (h *Handler) f(r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
_ = id
|
||||
}
|
||||
`
|
||||
|
||||
const urlQuerySnippet = `package handler
|
||||
|
||||
func (h *Handler) f(r *http.Request) {
|
||||
page := r.URL.Query().Get("page")
|
||||
_ = page
|
||||
}
|
||||
`
|
||||
|
||||
const boundHandlerSnippet = `package handler
|
||||
|
||||
import "code.nochebuena.dev/einherjar/web/httputil"
|
||||
|
||||
func (h *Handler) f() {
|
||||
_ = httputil.Bind(h.v, h.logger, h.get)
|
||||
}
|
||||
`
|
||||
|
||||
func TestPreferBindFiresOnURLParam(t *testing.T) {
|
||||
if got := findingsFor(Run(urlParamSnippet), "httputil.prefer-bind"); len(got) == 0 {
|
||||
t.Fatal("httputil.prefer-bind did not fire on chi.URLParam")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferBindFiresOnURLQuery(t *testing.T) {
|
||||
if got := findingsFor(Run(urlQuerySnippet), "httputil.prefer-bind"); len(got) == 0 {
|
||||
t.Fatal("httputil.prefer-bind did not fire on r.URL.Query()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferBindNoFalsePositiveOnBind(t *testing.T) {
|
||||
if got := findingsFor(Run(boundHandlerSnippet), "httputil.prefer-bind"); len(got) != 0 {
|
||||
t.Errorf("httputil.prefer-bind false-positived on a Bind handler: %+v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user