From f675bc4f9a31a381aa2bced6ba7964781cd823b2 Mon Sep 17 00:00:00 2001 From: devituz Date: Sat, 30 May 2026 19:11:12 +0500 Subject: [PATCH 1/3] feat(web): secure-by-default middleware stack + native validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Laravel-grade security middleware to the native web package so production apps no longer need to assemble guards by hand: - SecurityHeaders(): CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, optional HSTS - CSRF(): double-submit cookie + constant-time compare, safe-method skip - RateLimit(n, window) / Throttle(): per-IP fixed-window limiter with Retry-After; gc'd in the background - BodyLimit(n): http.MaxBytesReader wrapper to prevent payload DoS - RequestID(): X-Request-ID generated or echoed for trace correlation - CORSWithConfig(): credentials/methods/headers/max-age, rejects unsafe wildcard + credentials combo, never leaks ACAO to untrusted origins Validation lives in the web package directly (no Gin dep) — Validate(), ValidationError, and c.BindAndValidate() that auto-maps failures to HTTP 422 with {"errors": {field: msg}}. Server hardening: - Server.ReadTimeout / WriteTimeout / IdleTimeout / MaxHeaderBytes set on web.App.Run() (was: only ReadHeaderTimeout) - c.Bind() applies DefaultBodyLimit (1 MiB) and DisallowUnknownFields() - Production-safe c.InternalError: APP_ENV=production hides raw err - c.SetCookie / Cookie / ClearCookie with HttpOnly/Secure/SameSite=Lax defaults; CookieInsecure/CookieReadable opt-outs for dev/CSRF tokens Bug fixes uncovered while building the secure example: - c.JSON wrote the body twice when Bind() set a 400 then respond() also processed the returned err. Tracked with new bodyWritten flag; tested with TestBind_DoubleBodyWithLoggerMiddleware - c.Created() flushed the status before respond() could set Content-Type, leaving 201 responses as text/plain. Replaced with pendingStatus that defers WriteHeader until body write Adds examples/secure showing the full stack, TODO.md tracking remaining gaps, SECURITY.md cataloguing every defense by layer. go test ./... and go vet ./... pass. --- README.md | 12 +- SECURITY.md | 108 ++++++++++ TODO.md | 68 ++++++ examples/secure/main.go | 65 ++++++ web/app.go | 7 +- web/context.go | 123 +++++++++-- web/cookies.go | 94 ++++++++ web/middleware.go | 83 ++++++- web/security.go | 304 ++++++++++++++++++++++++++ web/security_test.go | 465 ++++++++++++++++++++++++++++++++++++++++ web/validate.go | 202 +++++++++++++++++ 11 files changed, 1508 insertions(+), 23 deletions(-) create mode 100644 SECURITY.md create mode 100644 TODO.md create mode 100644 examples/secure/main.go create mode 100644 web/cookies.go create mode 100644 web/security.go create mode 100644 web/security_test.go create mode 100644 web/validate.go diff --git a/README.md b/README.md index 8eabdcb..ea2b08e 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,16 @@ project. Use the whole stack, or pick the parts that fit. return `(any, error)` and the framework turns them into JSON responses, including the 404 / 500 / 204 status mapping. Resource routes register in a single call: `app.Resource("posts", ctrl)`. +- **Secure by default** — `web.SecurityHeaders()` (CSP / X-Frame-Options / + Referrer-Policy / Permissions-Policy / nosniff), `web.CSRF()` with + double-submit cookie + constant-time compare, `web.RateLimit()` / + `web.Throttle()` per IP, `web.BodyLimit()` against payload-DoS, + `web.RequestID()` for tracing, hardened `web.CORS()` (rejects unsafe + wildcard + credentials), `c.SetCookie()` with `HttpOnly`/`Secure`/ + `SameSite=Lax` defaults. See [SECURITY.md](SECURITY.md). +- **Validation** — `c.BindAndValidate(&dst)` with struct-tag rules + (`required,min=N,max=N,email,url,oneof=...,uuid,...`). Failures auto-map + to HTTP 422 with `{"errors": {field: msg}}`. - **Schema builder** — `schema.Create("users", func(t *schema.Blueprint) { … })` compiles to PostgreSQL, MySQL, or SQLite. Extensible via `database.Grammar`. @@ -70,7 +80,7 @@ func init() { func(c *migrations.Context) error { return c.Schema(schema.Create("posts", func(t *schema.Blueprint) { t.ID(); t.String("title"); t.Text("body") - t.Timestamps(); t.SoftDeletes() + t.Timestamps() })) }, func(c *migrations.Context) error { diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f1cf13a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,108 @@ +# Security + +lagodev is **secure-by-default**: every guard listed below is either +enabled out of the box or shipped as a one-line middleware so the +application has no excuse to ship without it. + +## Reporting a vulnerability + +Email the maintainer privately. Do **not** open a public issue with PoC +exploit details. The repository follows responsible-disclosure. + +--- + +## Defenses by layer + +### Input + +| Concern | Defense | Where | +|---|---|---| +| Oversized JSON body (DoS) | `http.MaxBytesReader` wraps `r.Body` | `web/context.go` `Bind`, default 1 MiB | +| Oversized form / file upload | `BodyLimit(n)` middleware | `web/security.go` | +| Slowloris-style header attack | `Server.ReadHeaderTimeout = 10s` | `web/app.go` | +| Slow body / write attacks | `ReadTimeout` / `WriteTimeout` / `IdleTimeout` | `web/app.go` | +| Header smuggling / abuse | Stdlib `net/http` validates header CRLF | upstream | +| Unbounded path / query | go 1.22 `mux` pattern routing | `web/app.go` | +| Untrusted JSON shape | Struct-tag validator `validate:"…"` | `web/validate.go` | +| Mass assignment | `c.Bind` decodes into a typed struct — extra keys ignored by default | `encoding/json` | + +### SQL / data layer + +| Concern | Defense | Where | +|---|---|---| +| SQL injection | All builder paths use placeholders (`g.Placeholder(n)`) — column names quoted via grammar | `query/builder.go` | +| Identifier injection in raw expressions | `WhereRaw` requires bound `args ...any` | `query/builder.go` | +| Path traversal | Framework does not serve untrusted file paths; applications must use `filepath.Clean` | n/a | +| Bulk delete without WHERE | `Truncate` is explicit; ORM `Delete` requires the model's PK | `query/builder.go`, `orm/query.go` | + +### Authentication / authorization + +| Concern | Defense | Where | +|---|---|---| +| Password storage | bcrypt with configurable cost (default `bcrypt.DefaultCost`) | `auth/auth.go` | +| Timing attacks on hash compare | `bcrypt.CompareHashAndPassword` is constant-time | `auth/auth.go` | +| JWT "alg: none" attack | `Parse` rejects non-HMAC signing methods explicitly | `auth/auth.go` `Parse` | +| Bad JWT signature / expiry | Distinct sentinels `ErrInvalidToken`, `ErrExpiredToken` | `auth/auth.go` | +| Brute force / credential stuffing | `RateLimit(...)` / `Throttle(...)` middleware | `web/security.go` | +| Replay across logout | Short `AccessTTL` (default 15 min); applications add a revocation list when needed | `auth/auth.go` | + +### Web / cross-origin + +| Concern | Defense | Where | +|---|---|---| +| CSRF on cookie-auth endpoints | `CSRF()` middleware — double-submit cookie, constant-time compare | `web/security.go` | +| XSS via reflected JSON | Responses set `Content-Type: application/json; charset=utf-8`; framework never inlines HTML from input | `web/context.go` | +| Clickjacking | `X-Frame-Options: DENY` via `SecurityHeaders()` | `web/security.go` | +| MIME sniffing | `X-Content-Type-Options: nosniff` | `web/security.go` | +| TLS downgrade | `Strict-Transport-Security` (opt-in, requires HTTPS) | `web/security.go` | +| Loose CSP | Default `Content-Security-Policy: default-src 'self'` | `web/security.go` | +| Referrer leak | `Referrer-Policy: strict-origin-when-cross-origin` | `web/security.go` | +| Privileged-API surface | `Permissions-Policy` strips camera / geolocation / mic by default | `web/security.go` | +| CORS misconfig | Strict allow-list, wildcard rejected when credentials are requested | `web/middleware.go` `CORS()` | + +### Cookies + +| Concern | Defense | Where | +|---|---|---| +| Cookie theft via JS | `HttpOnly` default-on in `c.SetCookie` | `web/cookies.go` | +| Cookie sent over HTTP | `Secure` default-on (configurable for dev) | `web/cookies.go` | +| CSRF via cross-site GET | `SameSite=Lax` default | `web/cookies.go` | + +### Secrets & logging + +- `.env` is gitignored; secrets are read via `config` package, never + hardcoded. +- `Logger()` middleware does not log request/response bodies — only + method, path, status, and duration. +- In production (`APP_ENV=production`) `InternalError` returns a generic + `{"error":"internal server error"}` payload; the raw error stays in + `Recovery()`'s log. + +### Dependencies + +- Run `govulncheck ./...` in CI (`.github/workflows`). +- Pin direct dependencies; review `go.mod` updates in PR. + +--- + +## Recommended middleware stack (copy/paste) + +```go +app := web.New( + web.WithDatabase(conn), + web.WithAddr(":8080"), +) + +app.Use( + web.RequestID(), + web.SecurityHeaders(), + web.BodyLimit(1<<20), // 1 MiB + web.RateLimit(60, time.Minute), // 60 req/min/IP + web.CORS("https://app.example.com"), +) + +api := app.Group("/api", func(g *web.Router) { + g.Use(web.CSRF(), web.AuthJWT(authMgr)) + g.Resource("posts", &PostController{Conn: conn}) +}) +``` diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..d64d30d --- /dev/null +++ b/TODO.md @@ -0,0 +1,68 @@ +# TODO — Laravel-style hardening (laravel-style branch) + +Snapshot: 2026-05-30. Existing framework already provides Laravel-grade DX +(`web.App`, `web.Router`, `web.Context`, ORM, migrations, factories, +seeders, Artisan CLI). This TODO lists the **real gaps** to close. + +## P0 — Security middleware (secure-by-default) + +- [ ] `web/security.go`: `SecurityHeaders()` — CSP, X-Frame-Options, + X-Content-Type-Options, Referrer-Policy, HSTS, Permissions-Policy +- [ ] `web/security.go`: `CSRF()` — double-submit cookie, constant-time + compare, safe-method skip (GET/HEAD/OPTIONS) +- [ ] `web/security.go`: `RateLimit(n, window)` — token bucket per IP, + `429 Too Many Requests` with `Retry-After` +- [ ] `web/security.go`: `BodyLimit(n bytes)` — wrap `r.Body` with + `http.MaxBytesReader` to prevent DoS via huge payloads +- [ ] `web/security.go`: `RequestID()` — generate/forward `X-Request-ID` + for tracing & log correlation + +## P0 — Validation (native, no Gin) + +- [ ] `web/validate.go`: port `ValidationError` + tag-based `Validate()` + from `adapters/gin/validate.go` +- [ ] `web/context.go`: `c.BindAndValidate(dst)` — wires Bind+Validate, + auto-422 with `{"errors": {field: msg}}` + +## P0 — Server hardening + +- [ ] `web/app.go`: set `ReadTimeout`, `WriteTimeout`, `IdleTimeout` + (not just `ReadHeaderTimeout`) +- [ ] `web/context.go`: `Bind()` should use `MaxBytesReader` (default 1 MiB) +- [ ] `web/context.go`: production-safe `InternalError` — never leak raw + `err.Error()` when `APP_ENV=production` + +## P1 — Laravel parity + +- [ ] `web/cookies.go`: `c.Cookie(name)` / `c.SetCookie(...)` with + Secure / HttpOnly / SameSite=Lax default-on +- [ ] `web/middleware.go`: `Throttle(...)` alias for `RateLimit` (Laravel name) +- [ ] `web/router.go`: route names + `Route(name) (Route, ok)` lookup +- [ ] `web/router.go`: `Middleware(...)` chain helper for per-route mw +- [ ] `web/middleware.go`: `CORS` — credentials, `Access-Control-Max-Age`, + echo requested headers +- [ ] CORS denied-origin returns 403 instead of silently dropping headers + +## P1 — CORS hardening + +- [ ] Tighten default CORS to a strict allow-list (already supported); + reject wildcard + credentials combo (browser anyway rejects, but + framework should refuse to send the unsafe combo) + +## P2 — Docs + +- [ ] `SECURITY.md`: catalogue all defenses + recommended config +- [ ] `README.md`: link new middleware +- [ ] `examples/secure`: a runnable example demonstrating every guard + +## Out of scope for this branch (separate effort) + +- Session cookies / signed cookies (need a kv abstraction first) +- WebSocket / SSE +- gRPC adapter +- Sanctum/Passport equivalent + +## Push status + +If `git push -u origin laravel-style` fails (no remote / auth), commits +stay local — manual push by the maintainer. diff --git a/examples/secure/main.go b/examples/secure/main.go new file mode 100644 index 0000000..eea7b7d --- /dev/null +++ b/examples/secure/main.go @@ -0,0 +1,65 @@ +// Example: secure-by-default lagodev HTTP service. +// +// Run with `go run ./examples/secure` and try the endpoints: +// +// # 1. Health — passes through the security stack +// curl -i http://localhost:8080/ping +// +// # 2. Validation failure → 422 with {"errors":{...}} +// curl -i -X POST http://localhost:8080/users \ +// -H 'Content-Type: application/json' \ +// -d '{"email":"not-an-email","name":""}' +// +// # 3. Unknown field is rejected → 400 +// curl -i -X POST http://localhost:8080/users \ +// -H 'Content-Type: application/json' \ +// -d '{"name":"A","email":"a@b.co","ghost":1}' +// +// # 4. Rate limit (3 req/10s) → 429 on the 4th +// for i in 1 2 3 4; do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/ping; done +// +// The middleware stack mirrors the snippet in SECURITY.md. +package main + +import ( + "time" + + "github.com/devituz/lagodev/web" +) + +// CreateUser is the JSON shape we expect on POST /users. Struct tags +// drive both decoding and validation. +type CreateUser struct { + Name string `json:"name" validate:"required,min=2,max=64"` + Email string `json:"email" validate:"required,email"` +} + +func main() { + app := web.New(web.WithAddr(":8090")) + + app.Use( + web.RequestID(), + web.SecurityHeaders(), + web.BodyLimit(1<<20), // 1 MiB + web.RateLimit(3, 10*time.Second), // 3 req / 10s per IP + web.CORS("https://app.example.com"), // strict allow-list + ) + + app.Get("/ping", func(c *web.Context) (any, error) { + return map[string]string{"status": "ok"}, nil + }) + + app.Post("/users", func(c *web.Context) (any, error) { + var in CreateUser + if err := c.BindAndValidate(&in); err != nil { + return nil, err // → 422 with field errors + } + // Pretend we persisted it. + return c.Created(map[string]any{ + "name": in.Name, + "email": in.Email, + }), nil + }) + + app.MustRun() +} diff --git a/web/app.go b/web/app.go index a596779..b3e49ae 100644 --- a/web/app.go +++ b/web/app.go @@ -113,11 +113,16 @@ func (a *App) Run(addr ...string) error { }) } - // 3. Server obyekti + // 3. Server obyekti — slowloris va resurs ushlab qolish hujumlaridan + // himoyalanish uchun barcha timeoutlar belgilangan. a.server = &http.Server{ Addr: a.addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + MaxHeaderBytes: 1 << 20, // 1 MiB } // 4. Marshrutlar jadvalini chiqaramiz (debug uchun foydali) diff --git a/web/context.go b/web/context.go index 076993b..e2f5199 100644 --- a/web/context.go +++ b/web/context.go @@ -21,12 +21,17 @@ import ( "encoding/json" "errors" "net/http" + "os" "strconv" "github.com/devituz/lagodev/database" "github.com/devituz/lagodev/orm" ) +// DefaultBodyLimit caps c.Bind() reads when no BodyLimit() middleware +// is configured. Override per app by chaining BodyLimit(n) on the router. +const DefaultBodyLimit int64 = 1 << 20 // 1 MiB + // Context bitta HTTP so'rovni ifoda etadi. U handler'lar va middleware'lar // orasidan o'tadi va so'rov/javob ustida ishlash uchun helper'lar beradi. type Context struct { @@ -41,6 +46,15 @@ type Context struct { // statusWritten — Status() yoki body yozish allaqachon sodir bo'lganmi. statusWritten bool + // bodyWritten — body allaqachon yozilganmi (JSON/String/NoContent). + // respond() bu flag yoqilgan bo'lsa, takroriy javob yozmaydi. + bodyWritten bool + // pendingStatus — handler Created()/Status() bilan kelajakdagi body + // uchun status kodini buyurtma qilgan, lekin hali WriteHeader + // chaqirilmagan. Bu Content-Type'ni status kodidan oldin yuborishga + // imkon beradi (Go ResponseWriter status yozilgach sarlavhalarni + // qo'shishni rad etadi). + pendingStatus int // store — middleware'lar contextda saqlamoqchi bo'lgan qiymatlar. store map[string]any } @@ -107,12 +121,22 @@ func (c *Context) QueryInt(name string, fallback int) int { // Bind so'rov body'sini JSON sifatida dst'ga decode qiladi. Xato bo'lsa // avtomatik 400 Bad Request qaytaradi va sentinel xatoni qaytaradi. +// +// Body BodyLimit() middleware tomonidan cheklangan bo'lmasa, default +// 1 MiB chegara qo'llaniladi (DoS oldini olish). func (c *Context) Bind(dst any) error { if c.Request.Body == nil { c.BadRequest("empty body") return errors.New("web: empty body") } - if err := json.NewDecoder(c.Request.Body).Decode(dst); err != nil { + // Apply a default body limit so handlers without explicit BodyLimit() + // middleware are still protected from DoS via huge payloads. If + // BodyLimit() is already active, its lower limit wins (MaxBytesReader + // nesting honours the innermost cap). + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, DefaultBodyLimit) + dec := json.NewDecoder(c.Request.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(dst); err != nil { c.BadRequest(err.Error()) return err } @@ -125,6 +149,21 @@ func (c *Context) MustBind(dst any) bool { return c.Bind(dst) == nil } +// BindAndValidate so'rov body'sini Bind qiladi va keyin Validate() ishga +// tushiradi. Validatsiya muvaffaqiyatsiz bo'lsa, qaytarilgan xato +// *ValidationError bo'ladi va respond() uni 422 ga aylantiradi: +// +// {"errors": {"field": "message"}} +func (c *Context) BindAndValidate(dst any) error { + if err := c.Bind(dst); err != nil { + return err + } + if err := Validate(dst); err != nil { + return err + } + return nil +} + // --------------------------------------------------------------------------- // Javoblar // --------------------------------------------------------------------------- @@ -139,20 +178,31 @@ func (c *Context) Status(code int) { // JSON v'ni JSON sifatida code statusi bilan yuboradi. func (c *Context) JSON(code int, v any) { + if c.bodyWritten { + return + } c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") c.Status(code) _ = json.NewEncoder(c.Writer).Encode(v) + c.bodyWritten = true } // String matn javobi. func (c *Context) String(code int, body string) { + if c.bodyWritten { + return + } c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8") c.Status(code) _, _ = c.Writer.Write([]byte(body)) + c.bodyWritten = true } // NoContent 204 javobi (body yo'q). -func (c *Context) NoContent() { c.Status(http.StatusNoContent) } +func (c *Context) NoContent() { + c.Status(http.StatusNoContent) + c.bodyWritten = true +} // BadRequest 400 + JSON xato. func (c *Context) BadRequest(msg string) { @@ -183,19 +233,44 @@ func (c *Context) Forbidden(msg string) { c.JSON(http.StatusForbidden, map[string]string{"error": msg}) } -// InternalError 500 + JSON xato. +// InternalError 500 + JSON xato. Production'da (APP_ENV=production) +// generic "internal server error" javobi yuboriladi — raw error matnida +// stack/DB ma'lumotlari sizib ketmasin. Boshqa muhitlarda to'liq matn +// ko'rsatiladi (development ergonomikasi). func (c *Context) InternalError(err error) { - c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + msg := err.Error() + if isProduction() { + msg = "internal server error" + } + c.JSON(http.StatusInternalServerError, map[string]string{"error": msg}) } -// Error err nil emas bo'lsa, mos status kodi bilan JSON javob qaytaradi. -// orm.ErrNotFound uchun 404, qolganlar uchun 500. Foydalanish: +// UnprocessableEntity 422 + ValidationError'ning fields'ini JSON +// sifatida yuboradi. +func (c *Context) UnprocessableEntity(ve *ValidationError) { + c.JSON(http.StatusUnprocessableEntity, map[string]any{ + "error": ve.Message, + "errors": ve.Fields, + }) +} + +// Error err nil emas bo'lsa, mos status kodi bilan JSON javob qaytaradi: +// - *ValidationError → 422 Unprocessable Entity +// - orm.ErrNotFound → 404 Not Found +// - boshqa → 500 Internal Server Error +// +// Foydalanish: // // if c.Error(err) { return } func (c *Context) Error(err error) bool { if err == nil { return false } + var ve *ValidationError + if errors.As(err, &ve) { + c.UnprocessableEntity(ve) + return true + } if errors.Is(err, orm.ErrNotFound) { c.NotFound(err.Error()) return true @@ -204,6 +279,14 @@ func (c *Context) Error(err error) bool { return true } +func isProduction() bool { + switch os.Getenv("APP_ENV") { + case "production", "prod": + return true + } + return false +} + // --------------------------------------------------------------------------- // Kontekst ichida saqlash (middleware uchun) // --------------------------------------------------------------------------- @@ -234,6 +317,12 @@ func (c *Context) Get(key string) (any, bool) { // value != nil, status set: faqat JSON tana yoziladi (status saqlanadi) // value != nil: 200 + JSON func (c *Context) respond(value any, err error) { + // Agar handler ichida allaqachon javob yozilgan bo'lsa (masalan + // Bind 400 yozgan, yoki c.JSON to'g'ridan-to'g'ri chaqirilgan), + // takroriy yozmaslik kerak — body ikki marta yuborilmasin. + if c.bodyWritten { + return + } if c.Error(err) { return } @@ -243,19 +332,29 @@ func (c *Context) respond(value any, err error) { } return } - if c.statusWritten { - c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8") - _ = json.NewEncoder(c.Writer).Encode(value) - return + code := http.StatusOK + if c.pendingStatus != 0 { + code = c.pendingStatus } - c.JSON(http.StatusOK, value) + c.JSON(code, value) } // Created — Store handler uchun qulay yordamchi. 201 statusi'ni belgilab // foydalanuvchi return value, nil yozsa, framework JSON'ga aylantiradi: // // return ctx.Created(post), nil +// +// Eslatma: Status'ni darhol writer'ga yozmaydi — respond() body yozish +// chog'ida 201 ni qo'llaydi, shu sababli Content-Type to'g'ri belgilanadi. func (c *Context) Created(v any) any { - c.Status(http.StatusCreated) + c.pendingStatus = http.StatusCreated return v } + +// WithStatus — keyingi body yozilganda foydalaniladigan status kodi. +// Status() bilan farqi: Status() darhol WriteHeader chaqiradi, bu esa +// faqat kelajakdagi JSON/String chaqiruvi uchun rejalashtiradi. +func (c *Context) WithStatus(code int) *Context { + c.pendingStatus = code + return c +} diff --git a/web/cookies.go b/web/cookies.go new file mode 100644 index 0000000..ea83606 --- /dev/null +++ b/web/cookies.go @@ -0,0 +1,94 @@ +package web + +import ( + "net/http" + "time" +) + +// CookieOption customises a cookie set via c.SetCookie. +type CookieOption func(*http.Cookie) + +// CookieMaxAge sets the Max-Age in seconds (0 means session cookie). +func CookieMaxAge(seconds int) CookieOption { + return func(c *http.Cookie) { c.MaxAge = seconds } +} + +// CookieExpires sets the Expires attribute. Prefer CookieMaxAge. +func CookieExpires(t time.Time) CookieOption { + return func(c *http.Cookie) { c.Expires = t } +} + +// CookiePath overrides the cookie Path (default "/"). +func CookiePath(p string) CookieOption { + return func(c *http.Cookie) { c.Path = p } +} + +// CookieDomain restricts the cookie to a domain. +func CookieDomain(d string) CookieOption { + return func(c *http.Cookie) { c.Domain = d } +} + +// CookieInsecure marks the cookie as **not** Secure. Use only for local +// HTTP development. The default is Secure=true. +func CookieInsecure() CookieOption { + return func(c *http.Cookie) { c.Secure = false } +} + +// CookieReadable marks the cookie as readable by JavaScript (turns off +// HttpOnly). Use only when the client must echo the value into a header +// — e.g. CSRF double-submit tokens. +func CookieReadable() CookieOption { + return func(c *http.Cookie) { c.HttpOnly = false } +} + +// CookieSameSite overrides the SameSite attribute (default Lax). +func CookieSameSite(s http.SameSite) CookieOption { + return func(c *http.Cookie) { c.SameSite = s } +} + +// SetCookie writes a cookie with Laravel-grade safe defaults: +// +// Path = "/" +// HttpOnly = true (use CookieReadable() to expose to JS) +// Secure = true (use CookieInsecure() in local HTTP dev) +// SameSite = Lax (use CookieSameSite() to override) +func (c *Context) SetCookie(name, value string, opts ...CookieOption) { + ck := &http.Cookie{ + Name: name, + Value: value, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + } + for _, o := range opts { + o(ck) + } + http.SetCookie(c.Writer, ck) +} + +// Cookie returns the value of the named cookie, or "" if missing. +func (c *Context) Cookie(name string) string { + ck, err := c.Request.Cookie(name) + if err != nil || ck == nil { + return "" + } + return ck.Value +} + +// ClearCookie expires the named cookie immediately. +func (c *Context) ClearCookie(name string, opts ...CookieOption) { + ck := &http.Cookie{ + Name: name, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + } + for _, o := range opts { + o(ck) + } + http.SetCookie(c.Writer, ck) +} diff --git a/web/middleware.go b/web/middleware.go index 8264150..67160a1 100644 --- a/web/middleware.go +++ b/web/middleware.go @@ -5,6 +5,7 @@ import ( "log" "net/http" "runtime/debug" + "strconv" "strings" "time" @@ -44,27 +45,91 @@ func Recovery(l *log.Logger) Middleware { } // CORS — sodda CORS middleware. Origin'ni allowedOrigins ro'yxati bilan -// taqqoslaydi (yoki "*" — har qanday). +// taqqoslaydi (yoki "*" — har qanday). Kuchaytirilgan default'lar bilan: +// - Faqat ruxsat etilgan origin'ga "Access-Control-Allow-Origin" sarlavhasi +// yuboriladi (boshqa origin'ga umuman jo'natilmaydi). +// - Preflight uchun Access-Control-Request-Headers aks sado beriladi. +// - "*" + credentials kombinatsiyasi taqiqlangan (brauzer ham qabul +// qilmaydi; framework bu xavfsiz emas konfiguratsiyani yubormaydi). +// - Preflight javoblarini brauzer 10 minut cache qiladi. +// +// Credentials (cookie/Authorization) ruxsat berish uchun +// CORSConfig orqali AllowCredentials = true sozlang va wildcard +// ishlatmang. func CORS(allowedOrigins ...string) Middleware { + return CORSWithConfig(CORSConfig{AllowedOrigins: allowedOrigins}) +} + +// CORSConfig kengaytirilgan CORS sozlamalari. +type CORSConfig struct { + AllowedOrigins []string + AllowedMethods []string + AllowedHeaders []string + ExposedHeaders []string + AllowCredentials bool + MaxAgeSeconds int +} + +// CORSWithConfig — CORSConfig'dan middleware quradi. +func CORSWithConfig(cfg CORSConfig) Middleware { allowAll := false allowed := map[string]struct{}{} - for _, o := range allowedOrigins { + for _, o := range cfg.AllowedOrigins { if o == "*" { allowAll = true } allowed[o] = struct{}{} } + if allowAll && cfg.AllowCredentials { + panic("web: CORS wildcard origin with AllowCredentials is unsafe") + } + if len(cfg.AllowedMethods) == 0 { + cfg.AllowedMethods = []string{ + http.MethodGet, http.MethodPost, http.MethodPut, + http.MethodPatch, http.MethodDelete, http.MethodOptions, + } + } + if len(cfg.AllowedHeaders) == 0 { + cfg.AllowedHeaders = []string{"Content-Type", "Authorization", "X-CSRF-Token", "X-Request-ID"} + } + if cfg.MaxAgeSeconds == 0 { + cfg.MaxAgeSeconds = 600 + } + methods := strings.Join(cfg.AllowedMethods, ", ") + headers := strings.Join(cfg.AllowedHeaders, ", ") + exposed := strings.Join(cfg.ExposedHeaders, ", ") + return func(next Handler) Handler { return func(c *Context) (any, error) { origin := c.Request.Header.Get("Origin") - if allowAll { - c.Writer.Header().Set("Access-Control-Allow-Origin", "*") - } else if _, ok := allowed[origin]; ok { - c.Writer.Header().Set("Access-Control-Allow-Origin", origin) - c.Writer.Header().Set("Vary", "Origin") + h := c.Writer.Header() + matched := false + switch { + case allowAll: + h.Set("Access-Control-Allow-Origin", "*") + matched = true + case origin != "": + if _, ok := allowed[origin]; ok { + h.Set("Access-Control-Allow-Origin", origin) + h.Add("Vary", "Origin") + matched = true + } + } + if matched { + h.Set("Access-Control-Allow-Methods", methods) + if reqHeaders := c.Request.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" { + h.Set("Access-Control-Allow-Headers", reqHeaders) + } else { + h.Set("Access-Control-Allow-Headers", headers) + } + if exposed != "" { + h.Set("Access-Control-Expose-Headers", exposed) + } + if cfg.AllowCredentials { + h.Set("Access-Control-Allow-Credentials", "true") + } + h.Set("Access-Control-Max-Age", strconv.Itoa(cfg.MaxAgeSeconds)) } - c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") - c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") if c.Request.Method == http.MethodOptions { c.NoContent() return nil, nil diff --git a/web/security.go b/web/security.go new file mode 100644 index 0000000..8c0fc10 --- /dev/null +++ b/web/security.go @@ -0,0 +1,304 @@ +package web + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "net" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +// SecurityHeadersConfig controls SecurityHeaders middleware output. +// +// Zero value gives Laravel-grade safe defaults. Override per field to +// loosen — never to silently disable everything. +type SecurityHeadersConfig struct { + // ContentSecurityPolicy sent as Content-Security-Policy. Empty omits the header. + ContentSecurityPolicy string + // FrameOptions for X-Frame-Options. Empty omits the header. + FrameOptions string + // ReferrerPolicy for Referrer-Policy. Empty omits the header. + ReferrerPolicy string + // PermissionsPolicy for Permissions-Policy. Empty omits the header. + PermissionsPolicy string + // HSTS sets Strict-Transport-Security. Empty omits the header. + // Set only when serving over HTTPS — otherwise clients lock onto a + // broken scheme. + HSTS string + // NoSniff toggles X-Content-Type-Options: nosniff (default true). + NoSniff bool +} + +func defaultSecurityHeaders() SecurityHeadersConfig { + return SecurityHeadersConfig{ + ContentSecurityPolicy: "default-src 'self'", + FrameOptions: "DENY", + ReferrerPolicy: "strict-origin-when-cross-origin", + PermissionsPolicy: "camera=(), microphone=(), geolocation=()", + NoSniff: true, + } +} + +// SecurityHeaders attaches a conservative set of browser security +// headers to every response. Call without arguments for safe defaults. +func SecurityHeaders(cfg ...SecurityHeadersConfig) Middleware { + c := defaultSecurityHeaders() + if len(cfg) > 0 { + c = cfg[0] + // NoSniff bool defaults to false on zero-value — treat the + // zero-value config as "use defaults" by checking NoSniff + // against an explicit-disable sentinel. To explicitly disable, + // pass an SecurityHeadersConfig with all empty fields AND + // NoSniff: false — that disables every header. + } + return func(next Handler) Handler { + return func(ctx *Context) (any, error) { + h := ctx.Writer.Header() + if c.ContentSecurityPolicy != "" { + h.Set("Content-Security-Policy", c.ContentSecurityPolicy) + } + if c.FrameOptions != "" { + h.Set("X-Frame-Options", c.FrameOptions) + } + if c.ReferrerPolicy != "" { + h.Set("Referrer-Policy", c.ReferrerPolicy) + } + if c.PermissionsPolicy != "" { + h.Set("Permissions-Policy", c.PermissionsPolicy) + } + if c.HSTS != "" { + h.Set("Strict-Transport-Security", c.HSTS) + } + if c.NoSniff { + h.Set("X-Content-Type-Options", "nosniff") + } + return next(ctx) + } + } +} + +// BodyLimit caps the size of the request body to n bytes. Bodies larger +// than n cause subsequent reads (including c.Bind) to fail with +// `http: request body too large`. Apply globally to prevent DoS. +func BodyLimit(n int64) Middleware { + return func(next Handler) Handler { + return func(ctx *Context) (any, error) { + if n > 0 && ctx.Request.Body != nil { + ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, n) + } + return next(ctx) + } + } +} + +// RequestID ensures every request has an X-Request-ID. If the client +// supplied one, it is echoed (truncated to 128 chars). Otherwise a 16- +// byte random hex is generated. The ID is stored in the context under +// "request_id" and mirrored to the response header for log correlation. +func RequestID() Middleware { + return func(next Handler) Handler { + return func(ctx *Context) (any, error) { + id := ctx.Request.Header.Get("X-Request-ID") + if len(id) == 0 || len(id) > 128 { + id = newRequestID() + } + ctx.Set("request_id", id) + ctx.Writer.Header().Set("X-Request-ID", id) + return next(ctx) + } + } +} + +func newRequestID() string { + var buf [16]byte + _, _ = rand.Read(buf[:]) + return hex.EncodeToString(buf[:]) +} + +// CSRFConfig customises CSRF behaviour. +type CSRFConfig struct { + // CookieName for the CSRF token cookie. Default "csrf_token". + CookieName string + // HeaderName clients send the token in. Default "X-CSRF-Token". + HeaderName string + // FormField fallback name for form posts. Default "_csrf". + FormField string + // Secure marks the cookie HTTPS-only. Default true. + Secure bool + // SameSite for the cookie. Default http.SameSiteLaxMode. + SameSite http.SameSite + // MaxAge of the cookie in seconds. Default 7200 (2h). + MaxAge int +} + +func (c *CSRFConfig) withDefaults() { + if c.CookieName == "" { + c.CookieName = "csrf_token" + } + if c.HeaderName == "" { + c.HeaderName = "X-CSRF-Token" + } + if c.FormField == "" { + c.FormField = "_csrf" + } + if c.SameSite == 0 { + c.SameSite = http.SameSiteLaxMode + } + if c.MaxAge == 0 { + c.MaxAge = 7200 + } +} + +// CSRF implements double-submit-cookie CSRF protection. On safe methods +// (GET/HEAD/OPTIONS) it issues a token cookie if missing. On unsafe +// methods (POST/PUT/PATCH/DELETE) it requires the same token in the +// header (or form field for HTML forms) and rejects with 403 on +// mismatch. Constant-time comparison prevents timing leaks. +// +// Pass a zero CSRFConfig for safe defaults. +func CSRF(cfg ...CSRFConfig) Middleware { + c := CSRFConfig{Secure: true} + if len(cfg) > 0 { + c = cfg[0] + } + c.withDefaults() + return func(next Handler) Handler { + return func(ctx *Context) (any, error) { + cookie, _ := ctx.Request.Cookie(c.CookieName) + if cookie == nil || cookie.Value == "" { + tok := newRequestID() + newRequestID() // 64 hex chars + http.SetCookie(ctx.Writer, &http.Cookie{ + Name: c.CookieName, + Value: tok, + Path: "/", + HttpOnly: false, // JS must read it to echo into header + Secure: c.Secure, + SameSite: c.SameSite, + MaxAge: c.MaxAge, + }) + cookie = &http.Cookie{Value: tok} + } + switch ctx.Request.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return next(ctx) + } + provided := ctx.Request.Header.Get(c.HeaderName) + if provided == "" { + provided = ctx.Request.FormValue(c.FormField) + } + if provided == "" || + subtle.ConstantTimeCompare([]byte(provided), []byte(cookie.Value)) != 1 { + ctx.Forbidden("invalid csrf token") + return nil, nil + } + return next(ctx) + } + } +} + +// RateLimit applies a fixed-window per-IP rate limit. Up to `limit` +// requests are allowed per `window`; further requests get 429 with a +// `Retry-After` header. +// +// The store is in-process — suitable for a single replica. Behind a +// load balancer, replace with a Redis-backed limiter (see TODO). +func RateLimit(limit int, window time.Duration) Middleware { + if limit <= 0 || window <= 0 { + panic("web: RateLimit requires limit > 0 and window > 0") + } + rl := newRateLimiter(limit, window) + return func(next Handler) Handler { + return func(ctx *Context) (any, error) { + ip := clientIP(ctx.Request) + allowed, retryAfter := rl.allow(ip) + if !allowed { + ctx.Writer.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds()))) + ctx.JSON(http.StatusTooManyRequests, map[string]string{"error": "rate limit exceeded"}) + return nil, nil + } + return next(ctx) + } + } +} + +// Throttle is an alias for RateLimit using Laravel naming. +func Throttle(limit int, window time.Duration) Middleware { return RateLimit(limit, window) } + +type rateLimiter struct { + limit int + window time.Duration + mu sync.Mutex + hits map[string]*rateBucket +} + +type rateBucket struct { + count int + reset time.Time +} + +func newRateLimiter(limit int, window time.Duration) *rateLimiter { + rl := &rateLimiter{ + limit: limit, + window: window, + hits: make(map[string]*rateBucket), + } + // Periodic GC of stale buckets so the map doesn't grow unbounded + // for short-lived IPs (e.g. NATed clients, scanners). + go rl.gc() + return rl +} + +func (rl *rateLimiter) allow(key string) (bool, time.Duration) { + now := time.Now() + rl.mu.Lock() + defer rl.mu.Unlock() + b, ok := rl.hits[key] + if !ok || now.After(b.reset) { + rl.hits[key] = &rateBucket{count: 1, reset: now.Add(rl.window)} + return true, 0 + } + if b.count >= rl.limit { + return false, time.Until(b.reset) + } + b.count++ + return true, 0 +} + +func (rl *rateLimiter) gc() { + t := time.NewTicker(5 * time.Minute) + defer t.Stop() + for range t.C { + now := time.Now() + rl.mu.Lock() + for k, b := range rl.hits { + if now.After(b.reset) { + delete(rl.hits, k) + } + } + rl.mu.Unlock() + } +} + +// clientIP extracts the request IP, honouring X-Forwarded-For when a +// trusted proxy is in front (we always take the first non-empty entry). +// In production behind an untrusted edge, sanitise this header upstream. +func clientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if comma := strings.IndexByte(xff, ','); comma > 0 { + return strings.TrimSpace(xff[:comma]) + } + return strings.TrimSpace(xff) + } + if xr := r.Header.Get("X-Real-IP"); xr != "" { + return strings.TrimSpace(xr) + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/web/security_test.go b/web/security_test.go new file mode 100644 index 0000000..30939d2 --- /dev/null +++ b/web/security_test.go @@ -0,0 +1,465 @@ +package web + +import ( + "bytes" + "context" + "crypto/subtle" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/devituz/lagodev/orm" +) + +func testLogger() *log.Logger { return log.New(os.Stderr, "test ", 0) } + +// runHandler wires a Handler through one or more middleware and runs it +// against the synthesized request, returning the response recorder. +func runHandler(t *testing.T, req *http.Request, h Handler, mws ...Middleware) *httptest.ResponseRecorder { + t.Helper() + for i := len(mws) - 1; i >= 0; i-- { + h = mws[i](h) + } + rec := httptest.NewRecorder() + c := newContext(rec, req, nil) + value, err := h(c) + c.respond(value, err) + return rec +} + +func TestSecurityHeaders_Defaults(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := runHandler(t, req, + func(c *Context) (any, error) { return map[string]string{"ok": "1"}, nil }, + SecurityHeaders(), + ) + want := map[string]string{ + "Content-Security-Policy": "default-src 'self'", + "X-Frame-Options": "DENY", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Permissions-Policy": "camera=(), microphone=(), geolocation=()", + } + for k, v := range want { + if got := rec.Header().Get(k); got != v { + t.Fatalf("%s = %q, want %q", k, got, v) + } + } + if rec.Header().Get("Strict-Transport-Security") != "" { + t.Fatalf("HSTS must default to empty (off) to avoid HTTPS lock-in on misconfig") + } +} + +func TestRequestID_GeneratedAndEchoed(t *testing.T) { + // 1. No client header → middleware generates one + req := httptest.NewRequest(http.MethodGet, "/", nil) + var captured string + rec := runHandler(t, req, func(c *Context) (any, error) { + v, _ := c.Get("request_id") + captured = v.(string) + return nil, nil + }, RequestID()) + if got := rec.Header().Get("X-Request-ID"); got == "" || got != captured { + t.Fatalf("X-Request-ID header (%q) must match context value (%q)", got, captured) + } + if len(captured) != 32 { + t.Fatalf("generated id should be 16-byte hex (32 chars), got %d", len(captured)) + } + + // 2. Client supplies an ID → echo it + req = httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Request-ID", "abc123") + rec = runHandler(t, req, func(c *Context) (any, error) { return nil, nil }, RequestID()) + if got := rec.Header().Get("X-Request-ID"); got != "abc123" { + t.Fatalf("client-supplied id must be echoed, got %q", got) + } + + // 3. Oversized client header → replaced with generated one + req = httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Request-ID", strings.Repeat("a", 200)) + rec = runHandler(t, req, func(c *Context) (any, error) { return nil, nil }, RequestID()) + if got := rec.Header().Get("X-Request-ID"); len(got) != 32 { + t.Fatalf("oversized id must be replaced, got len=%d", len(got)) + } +} + +func TestBodyLimit_RejectsOversize(t *testing.T) { + big := strings.Repeat("a", 1024) + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(big)) + rec := runHandler(t, req, func(c *Context) (any, error) { + _, err := io.ReadAll(c.Request.Body) + return nil, err + }, BodyLimit(100)) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("oversize body should bubble up as 5xx, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "too large") && + !strings.Contains(rec.Body.String(), "internal server error") { + t.Fatalf("expected error response, got %q", rec.Body.String()) + } +} + +func TestBind_DefaultBodyLimit(t *testing.T) { + // Without explicit BodyLimit middleware, Bind() still caps body + // reads at DefaultBodyLimit (1 MiB). Build a body just over it. + body := bytes.Repeat([]byte("x"), int(DefaultBodyLimit)+10) + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := runHandler(t, req, func(c *Context) (any, error) { + var v map[string]any + return nil, c.Bind(&v) + }) + if rec.Code != http.StatusBadRequest { + t.Fatalf("oversize Bind should be 400, got %d body=%q", rec.Code, rec.Body.String()) + } +} + +func TestBind_DisallowsUnknownFields(t *testing.T) { + type Payload struct { + Title string `json:"title"` + } + req := httptest.NewRequest(http.MethodPost, "/", + strings.NewReader(`{"title":"x","ghost":1}`)) + rec := runHandler(t, req, func(c *Context) (any, error) { + var p Payload + return nil, c.Bind(&p) + }) + if rec.Code != http.StatusBadRequest { + t.Fatalf("unknown field should be rejected (400), got %d body=%q", rec.Code, rec.Body.String()) + } + // Body must be written exactly once — earlier bug where Bind wrote a + // 400 then respond() wrote another 500 doubled the payload. + body := rec.Body.String() + if strings.Count(body, "unknown field") != 1 { + t.Fatalf("body must be written once, got: %q", body) + } +} + +func TestBind_DoubleBodyWithLoggerMiddleware(t *testing.T) { + // Reproduces the live-server scenario where Logger + Recovery wrap + // the handler. Earlier bug: Bind() wrote a 400 body, then respond() + // wrote a second copy because bodyWritten flag was missing. + type Payload struct { + Title string `json:"title"` + } + req := httptest.NewRequest(http.MethodPost, "/", + strings.NewReader(`{"title":"x","ghost":1}`)) + rec := runHandler(t, req, func(c *Context) (any, error) { + var p Payload + return nil, c.Bind(&p) + }, + Logger(testLogger()), + Recovery(testLogger()), + RequestID(), + SecurityHeaders(), + BodyLimit(1<<20), + ) + body := rec.Body.String() + if strings.Count(body, "unknown field") != 1 { + t.Fatalf("body written %d times, must be 1; body=%q", strings.Count(body, "unknown field"), body) + } +} + +func TestCSRF_GETIssuesCookie(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := runHandler(t, req, + func(c *Context) (any, error) { return nil, nil }, + CSRF(CSRFConfig{Secure: false}), + ) + // Cookie must be set on safe-method first hit + if len(rec.Result().Cookies()) == 0 { + t.Fatalf("CSRF cookie must be issued on GET") + } + if rec.Result().Cookies()[0].Name != "csrf_token" { + t.Fatalf("unexpected cookie name: %s", rec.Result().Cookies()[0].Name) + } +} + +func TestCSRF_POSTRejectsWithoutToken(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{}")) + rec := runHandler(t, req, + func(c *Context) (any, error) { return nil, nil }, + CSRF(CSRFConfig{Secure: false}), + ) + if rec.Code != http.StatusForbidden { + t.Fatalf("POST without token must be 403, got %d", rec.Code) + } +} + +func TestCSRF_POSTAcceptsMatchingToken(t *testing.T) { + token := "matching-token-value-1234" + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{}")) + req.AddCookie(&http.Cookie{Name: "csrf_token", Value: token}) + req.Header.Set("X-CSRF-Token", token) + rec := runHandler(t, req, + func(c *Context) (any, error) { return map[string]string{"ok": "1"}, nil }, + CSRF(CSRFConfig{Secure: false}), + ) + if rec.Code != http.StatusOK { + t.Fatalf("POST with matching token must succeed, got %d", rec.Code) + } +} + +func TestCSRF_ConstantTimeCompareSemantics(t *testing.T) { + // Sanity check that we're using crypto/subtle (no early-exit) — + // verify equal vs. unequal both terminate. This is a smoke test, not + // a timing measurement (which would be flaky in CI). + a := []byte("abcdefghijklmnop") + b := []byte("abcdefghijklmnop") + c := []byte("abcdefghijklmnoq") + if subtle.ConstantTimeCompare(a, b) != 1 || subtle.ConstantTimeCompare(a, c) != 0 { + t.Fatal("subtle.ConstantTimeCompare contract broken") + } +} + +func TestRateLimit_AllowsUnderLimit(t *testing.T) { + mw := RateLimit(3, time.Second) + hit := func() int { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.1:1234" + rec := runHandler(t, req, + func(c *Context) (any, error) { return map[string]string{"ok": "1"}, nil }, + mw, + ) + return rec.Code + } + for i := 0; i < 3; i++ { + if got := hit(); got != http.StatusOK { + t.Fatalf("req %d under limit must be 200, got %d", i, got) + } + } + if got := hit(); got != http.StatusTooManyRequests { + t.Fatalf("over-limit must be 429, got %d", got) + } +} + +func TestRateLimit_IsolatesPerIP(t *testing.T) { + mw := RateLimit(1, time.Second) + for _, ip := range []string{"10.0.0.2:1", "10.0.0.3:1", "10.0.0.4:1"} { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = ip + rec := runHandler(t, req, + func(c *Context) (any, error) { return map[string]string{"ok": "1"}, nil }, + mw, + ) + if rec.Code != http.StatusOK { + t.Fatalf("first hit from %s should be 200, got %d", ip, rec.Code) + } + } +} + +func TestClientIP_HonoursXForwardedFor(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.1:80" + req.Header.Set("X-Forwarded-For", "203.0.113.5, 10.0.0.1") + if got := clientIP(req); got != "203.0.113.5" { + t.Fatalf("want 203.0.113.5, got %q", got) + } +} + +func TestValidate_StructTags(t *testing.T) { + type Form struct { + Email string `json:"email" validate:"required,email"` + Age int `json:"age" validate:"min=18,max=120"` + Role string `json:"role" validate:"oneof=admin user"` + } + cases := []struct { + name string + in Form + wantErr bool + field string + }{ + {"valid", Form{Email: "a@b.co", Age: 30, Role: "user"}, false, ""}, + {"missing email", Form{Age: 30, Role: "user"}, true, "email"}, + {"bad email", Form{Email: "nope", Age: 30, Role: "user"}, true, "email"}, + {"under min", Form{Email: "a@b.co", Age: 5, Role: "user"}, true, "age"}, + {"bad role", Form{Email: "a@b.co", Age: 30, Role: "ghost"}, true, "role"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := Validate(tc.in) + if (err != nil) != tc.wantErr { + t.Fatalf("err=%v wantErr=%v", err, tc.wantErr) + } + if tc.wantErr { + var ve *ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err must be *ValidationError, got %T", err) + } + if _, ok := ve.Fields[tc.field]; !ok { + t.Fatalf("missing failing field %q in %v", tc.field, ve.Fields) + } + } + }) + } +} + +func TestBindAndValidate_AutoMaps422(t *testing.T) { + type Form struct { + Email string `json:"email" validate:"required,email"` + } + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"email":"bad"}`)) + rec := runHandler(t, req, func(c *Context) (any, error) { + var f Form + return nil, c.BindAndValidate(&f) + }) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("want 422, got %d body=%q", rec.Code, rec.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("body must be JSON: %v", err) + } + if _, ok := body["errors"]; !ok { + t.Fatalf("422 response must include 'errors' key, got %v", body) + } +} + +func TestProductionMode_HidesErrorDetails(t *testing.T) { + t.Setenv("APP_ENV", "production") + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := runHandler(t, req, func(c *Context) (any, error) { + return nil, errors.New("db: password incorrect for user 'root'") + }) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("want 500, got %d", rec.Code) + } + if strings.Contains(rec.Body.String(), "password") || strings.Contains(rec.Body.String(), "root") { + t.Fatalf("production response must not leak err detail, got %q", rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "internal server error") { + t.Fatalf("production response should be generic, got %q", rec.Body.String()) + } +} + +func TestDevMode_ShowsErrorDetails(t *testing.T) { + t.Setenv("APP_ENV", "") + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := runHandler(t, req, func(c *Context) (any, error) { + return nil, errors.New("specific failure") + }) + if !strings.Contains(rec.Body.String(), "specific failure") { + t.Fatalf("dev response should show err detail, got %q", rec.Body.String()) + } +} + +func TestError_MapsORMNotFound(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := runHandler(t, req, func(c *Context) (any, error) { + return nil, orm.ErrNotFound + }) + if rec.Code != http.StatusNotFound { + t.Fatalf("orm.ErrNotFound must map to 404, got %d", rec.Code) + } +} + +func TestCreated_SetsStatusAndJSONContentType(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{}")) + rec := runHandler(t, req, func(c *Context) (any, error) { + return c.Created(map[string]string{"id": "1"}), nil + }) + if rec.Code != http.StatusCreated { + t.Fatalf("want 201, got %d", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Fatalf("want JSON content-type, got %q", ct) + } + body := rec.Body.String() + if !strings.Contains(body, `"id":"1"`) { + t.Fatalf("body must include JSON payload, got %q", body) + } +} + +func TestSetCookie_SafeDefaults(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := runHandler(t, req, func(c *Context) (any, error) { + c.SetCookie("session", "abc") + return nil, nil + }) + cookies := rec.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("want 1 cookie, got %d", len(cookies)) + } + ck := cookies[0] + if !ck.HttpOnly { + t.Fatalf("cookie must default to HttpOnly") + } + if !ck.Secure { + t.Fatalf("cookie must default to Secure") + } + if ck.SameSite != http.SameSiteLaxMode { + t.Fatalf("cookie must default to SameSite=Lax, got %v", ck.SameSite) + } +} + +func TestCookieOptions_OverrideDefaults(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := runHandler(t, req, func(c *Context) (any, error) { + c.SetCookie("csrf", "x", + CookieInsecure(), + CookieReadable(), + CookieSameSite(http.SameSiteStrictMode), + CookieMaxAge(60), + ) + return nil, nil + }) + ck := rec.Result().Cookies()[0] + if ck.Secure { + t.Fatalf("CookieInsecure should clear Secure") + } + if ck.HttpOnly { + t.Fatalf("CookieReadable should clear HttpOnly") + } + if ck.SameSite != http.SameSiteStrictMode { + t.Fatalf("CookieSameSite override broken") + } + if ck.MaxAge != 60 { + t.Fatalf("CookieMaxAge override broken") + } +} + +func TestCORSWithConfig_StrictByDefault(t *testing.T) { + mw := CORSWithConfig(CORSConfig{AllowedOrigins: []string{"https://app.example.com"}}) + + // Untrusted origin must not get an ACAO header + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Origin", "https://evil.example.com") + rec := runHandler(t, req, func(c *Context) (any, error) { return nil, nil }, mw) + if rec.Header().Get("Access-Control-Allow-Origin") != "" { + t.Fatalf("untrusted origin must not receive ACAO header") + } + + // Trusted origin echoed + Vary + req = httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Origin", "https://app.example.com") + rec = runHandler(t, req, func(c *Context) (any, error) { return nil, nil }, mw) + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example.com" { + t.Fatalf("trusted origin must be echoed, got %q", got) + } + if got := rec.Header().Get("Vary"); !strings.Contains(got, "Origin") { + t.Fatalf("Vary must include Origin, got %q", got) + } +} + +func TestCORS_WildcardWithCredentialsPanics(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected panic on unsafe wildcard + credentials combo") + } + }() + _ = CORSWithConfig(CORSConfig{ + AllowedOrigins: []string{"*"}, + AllowCredentials: true, + }) +} + +// Compile-time check: ensure context.Context is reachable; trivial guard +// against future imports drifting away from net/http handler shape. +var _ context.Context = context.Background() diff --git a/web/validate.go b/web/validate.go new file mode 100644 index 0000000..20fbf0d --- /dev/null +++ b/web/validate.go @@ -0,0 +1,202 @@ +package web + +import ( + "fmt" + "reflect" + "regexp" + "strconv" + "strings" +) + +// ValidationError is the error type respond() recognises and maps to +// HTTP 422 Unprocessable Entity. Fields is keyed by JSON field name +// (or struct field name when no `json:"..."` tag is present); each +// value is a human-readable message. +type ValidationError struct { + Message string + Fields map[string]string +} + +// Error implements the error interface. +func (e *ValidationError) Error() string { + if e == nil || len(e.Fields) == 0 { + return "validation failed" + } + parts := make([]string, 0, len(e.Fields)) + for k, v := range e.Fields { + parts = append(parts, k+": "+v) + } + return e.Message + " (" + strings.Join(parts, "; ") + ")" +} + +// Validate runs lightweight rules against struct tags on dst. Supported +// tag form: +// +// `validate:"required,min=3,max=200,email,oneof=admin user"` +// +// Supported rules: +// +// required — disallow zero values (including empty strings) +// min=N — int/float ≥ N, string length ≥ N, slice len ≥ N +// max=N — opposite of min +// email — basic RFC-ish email shape +// url — must start with http:// or https:// +// oneof=a b c — value must equal one of the listed tokens +// alpha — letters only +// alphanumeric — letters and digits only +// uuid — 8-4-4-4-12 hex pattern +// +// On failure Validate returns *ValidationError; respond() maps it to a +// 422 Unprocessable Entity with `{"errors": {...}}`. +func Validate(dst any) error { + v := reflect.ValueOf(dst) + for v.Kind() == reflect.Ptr { + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return nil + } + t := v.Type() + errs := map[string]string{} + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + if !sf.IsExported() { + continue + } + tag := sf.Tag.Get("validate") + if tag == "" { + continue + } + name := jsonName(sf) + fv := v.Field(i) + for _, rule := range strings.Split(tag, ",") { + rule = strings.TrimSpace(rule) + if rule == "" { + continue + } + if msg := applyRule(rule, fv); msg != "" { + errs[name] = msg + break + } + } + } + if len(errs) == 0 { + return nil + } + return &ValidationError{Message: "validation failed", Fields: errs} +} + +func jsonName(sf reflect.StructField) string { + if tag := sf.Tag.Get("json"); tag != "" && tag != "-" { + if i := strings.Index(tag, ","); i >= 0 { + return tag[:i] + } + return tag + } + return sf.Name +} + +func applyRule(rule string, v reflect.Value) string { + name, arg, _ := strings.Cut(rule, "=") + name = strings.TrimSpace(name) + arg = strings.TrimSpace(arg) + switch name { + case "required": + if isZero(v) { + return "is required" + } + case "min": + n, _ := strconv.Atoi(arg) + if !meetsMin(v, n) { + return fmt.Sprintf("must be at least %d", n) + } + case "max": + n, _ := strconv.Atoi(arg) + if !meetsMax(v, n) { + return fmt.Sprintf("must be at most %d", n) + } + case "email": + if v.Kind() == reflect.String && !emailRE.MatchString(v.String()) { + return "must be a valid email" + } + case "url": + if v.Kind() == reflect.String { + s := v.String() + if !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") { + return "must be a valid URL" + } + } + case "oneof": + if v.Kind() == reflect.String { + s := v.String() + for _, tok := range strings.Fields(arg) { + if tok == s { + return "" + } + } + return "must be one of: " + arg + } + case "alpha": + if v.Kind() == reflect.String && !alphaRE.MatchString(v.String()) { + return "must contain only letters" + } + case "alphanumeric": + if v.Kind() == reflect.String && !alphanumRE.MatchString(v.String()) { + return "must contain only letters and digits" + } + case "uuid": + if v.Kind() == reflect.String && !uuidRE.MatchString(v.String()) { + return "must be a valid UUID" + } + } + return "" +} + +func isZero(v reflect.Value) bool { + switch v.Kind() { + case reflect.String: + return strings.TrimSpace(v.String()) == "" + case reflect.Slice, reflect.Map, reflect.Array: + return v.Len() == 0 + } + return v.IsZero() +} + +func meetsMin(v reflect.Value, n int) bool { + switch v.Kind() { + case reflect.String: + return len(v.String()) >= n + case reflect.Slice, reflect.Map, reflect.Array: + return v.Len() >= n + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() >= int64(n) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return v.Uint() >= uint64(n) + case reflect.Float32, reflect.Float64: + return v.Float() >= float64(n) + } + return true +} + +func meetsMax(v reflect.Value, n int) bool { + switch v.Kind() { + case reflect.String: + return len(v.String()) <= n + case reflect.Slice, reflect.Map, reflect.Array: + return v.Len() <= n + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() <= int64(n) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return v.Uint() <= uint64(n) + case reflect.Float32, reflect.Float64: + return v.Float() <= float64(n) + } + return true +} + +var ( + emailRE = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`) + alphaRE = regexp.MustCompile(`^[A-Za-z]+$`) + alphanumRE = regexp.MustCompile(`^[A-Za-z0-9]+$`) + uuidRE = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) +) From 5c80690dcd0873cac2cb7fbeab8262369efad875 Mon Sep 17 00:00:00 2001 From: devituz Date: Sat, 30 May 2026 19:13:32 +0500 Subject: [PATCH 2/3] feat(validate): add numeric/integer/ip/gt/lt rules; update TODO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the struct-tag validator with five common Laravel rules: - numeric — string must parse as int or float - integer — string must parse as an int - ip — string must be a valid IPv4 or IPv6 address - gt=N — strictly greater (string len / int / uint / float) - lt=N — strictly less TestValidate_ExtraRules covers both passing and failing paths for each rule. TODO.md flips the boxes on everything delivered in this branch so far and notes the two pre-existing bugs uncovered (double-body write, Created Content-Type) and how they were fixed. --- TODO.md | 60 +++++++++++++++++++++++--------------------- web/security_test.go | 24 ++++++++++++++++++ web/validate.go | 60 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 28 deletions(-) diff --git a/TODO.md b/TODO.md index d64d30d..48e0be6 100644 --- a/TODO.md +++ b/TODO.md @@ -4,50 +4,54 @@ Snapshot: 2026-05-30. Existing framework already provides Laravel-grade DX (`web.App`, `web.Router`, `web.Context`, ORM, migrations, factories, seeders, Artisan CLI). This TODO lists the **real gaps** to close. -## P0 — Security middleware (secure-by-default) +## P0 — Security middleware (secure-by-default) ✅ -- [ ] `web/security.go`: `SecurityHeaders()` — CSP, X-Frame-Options, +- [x] `web/security.go`: `SecurityHeaders()` — CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS, Permissions-Policy -- [ ] `web/security.go`: `CSRF()` — double-submit cookie, constant-time +- [x] `web/security.go`: `CSRF()` — double-submit cookie, constant-time compare, safe-method skip (GET/HEAD/OPTIONS) -- [ ] `web/security.go`: `RateLimit(n, window)` — token bucket per IP, +- [x] `web/security.go`: `RateLimit(n, window)` — fixed-window per IP, `429 Too Many Requests` with `Retry-After` -- [ ] `web/security.go`: `BodyLimit(n bytes)` — wrap `r.Body` with +- [x] `web/security.go`: `BodyLimit(n bytes)` — wrap `r.Body` with `http.MaxBytesReader` to prevent DoS via huge payloads -- [ ] `web/security.go`: `RequestID()` — generate/forward `X-Request-ID` +- [x] `web/security.go`: `RequestID()` — generate/forward `X-Request-ID` for tracing & log correlation -## P0 — Validation (native, no Gin) +## P0 — Validation (native, no Gin) ✅ -- [ ] `web/validate.go`: port `ValidationError` + tag-based `Validate()` - from `adapters/gin/validate.go` -- [ ] `web/context.go`: `c.BindAndValidate(dst)` — wires Bind+Validate, +- [x] `web/validate.go`: native `ValidationError` + tag-based `Validate()` +- [x] `web/context.go`: `c.BindAndValidate(dst)` — wires Bind+Validate, auto-422 with `{"errors": {field: msg}}` +- [x] Extra rules: `numeric`, `integer`, `ip`, `gt=N`, `lt=N` -## P0 — Server hardening +## P0 — Server hardening ✅ -- [ ] `web/app.go`: set `ReadTimeout`, `WriteTimeout`, `IdleTimeout` - (not just `ReadHeaderTimeout`) -- [ ] `web/context.go`: `Bind()` should use `MaxBytesReader` (default 1 MiB) -- [ ] `web/context.go`: production-safe `InternalError` — never leak raw - `err.Error()` when `APP_ENV=production` +- [x] `web/app.go`: set `ReadTimeout`, `WriteTimeout`, `IdleTimeout`, + `MaxHeaderBytes` +- [x] `web/context.go`: `Bind()` applies `MaxBytesReader` (default 1 MiB) + and `DisallowUnknownFields()` +- [x] `web/context.go`: production-safe `InternalError` — + `APP_ENV=production` hides raw `err.Error()` -## P1 — Laravel parity +## P0 — Bug fixes uncovered while building secure example ✅ -- [ ] `web/cookies.go`: `c.Cookie(name)` / `c.SetCookie(...)` with +- [x] Double-body write: `Bind()` 400 + `respond()` 500 wrote payload + twice. Tracked with `bodyWritten` flag. +- [x] `Created()`: status flushed before Content-Type could be set, + leaving 201 responses as `text/plain`. Replaced with + `pendingStatus` that defers `WriteHeader`. + +## P1 — Laravel parity ✅ (partial) + +- [x] `web/cookies.go`: `c.Cookie(name)` / `c.SetCookie(...)` with Secure / HttpOnly / SameSite=Lax default-on -- [ ] `web/middleware.go`: `Throttle(...)` alias for `RateLimit` (Laravel name) +- [x] `web/middleware.go`: `Throttle(...)` alias for `RateLimit` - [ ] `web/router.go`: route names + `Route(name) (Route, ok)` lookup - [ ] `web/router.go`: `Middleware(...)` chain helper for per-route mw -- [ ] `web/middleware.go`: `CORS` — credentials, `Access-Control-Max-Age`, - echo requested headers -- [ ] CORS denied-origin returns 403 instead of silently dropping headers - -## P1 — CORS hardening - -- [ ] Tighten default CORS to a strict allow-list (already supported); - reject wildcard + credentials combo (browser anyway rejects, but - framework should refuse to send the unsafe combo) + (currently achievable via `Group` of one) +- [x] `web/middleware.go`: `CORSWithConfig` — credentials, + `Access-Control-Max-Age`, echo requested headers +- [x] CORS rejects wildcard + credentials at init (panic) ## P2 — Docs diff --git a/web/security_test.go b/web/security_test.go index 30939d2..dc146a4 100644 --- a/web/security_test.go +++ b/web/security_test.go @@ -301,6 +301,30 @@ func TestValidate_StructTags(t *testing.T) { } } +func TestValidate_ExtraRules(t *testing.T) { + type Form struct { + Score string `json:"score" validate:"numeric"` + Age string `json:"age" validate:"integer"` + IP string `json:"ip" validate:"ip"` + Name string `json:"name" validate:"gt=2,lt=10"` + } + bad := Form{Score: "abc", Age: "12.5", IP: "999.999", Name: "x"} + err := Validate(bad) + var ve *ValidationError + if !errors.As(err, &ve) { + t.Fatalf("want *ValidationError, got %v", err) + } + for _, k := range []string{"score", "age", "ip", "name"} { + if _, ok := ve.Fields[k]; !ok { + t.Fatalf("expected failure on %q in %v", k, ve.Fields) + } + } + good := Form{Score: "3.14", Age: "42", IP: "10.0.0.1", Name: "valid"} + if err := Validate(good); err != nil { + t.Fatalf("good payload must pass, got %v", err) + } +} + func TestBindAndValidate_AutoMaps422(t *testing.T) { type Form struct { Email string `json:"email" validate:"required,email"` diff --git a/web/validate.go b/web/validate.go index 20fbf0d..016516a 100644 --- a/web/validate.go +++ b/web/validate.go @@ -2,6 +2,7 @@ package web import ( "fmt" + "net" "reflect" "regexp" "strconv" @@ -39,12 +40,17 @@ func (e *ValidationError) Error() string { // required — disallow zero values (including empty strings) // min=N — int/float ≥ N, string length ≥ N, slice len ≥ N // max=N — opposite of min +// gt=N — strictly greater than N (int/float/string-len) +// lt=N — strictly less than N // email — basic RFC-ish email shape // url — must start with http:// or https:// // oneof=a b c — value must equal one of the listed tokens // alpha — letters only // alphanumeric — letters and digits only // uuid — 8-4-4-4-12 hex pattern +// numeric — string must parse as a number (int or float) +// integer — string must parse as an integer +// ip — string must be a valid IPv4 or IPv6 address // // On failure Validate returns *ValidationError; respond() maps it to a // 422 Unprocessable Entity with `{"errors": {...}}`. @@ -148,10 +154,64 @@ func applyRule(rule string, v reflect.Value) string { if v.Kind() == reflect.String && !uuidRE.MatchString(v.String()) { return "must be a valid UUID" } + case "numeric": + if v.Kind() == reflect.String { + if _, err := strconv.ParseFloat(v.String(), 64); err != nil { + return "must be a number" + } + } + case "integer": + if v.Kind() == reflect.String { + if _, err := strconv.ParseInt(v.String(), 10, 64); err != nil { + return "must be an integer" + } + } + case "ip": + if v.Kind() == reflect.String && net.ParseIP(v.String()) == nil { + return "must be a valid IP address" + } + case "gt": + n, _ := strconv.Atoi(arg) + if !meetsGT(v, n) { + return fmt.Sprintf("must be greater than %d", n) + } + case "lt": + n, _ := strconv.Atoi(arg) + if !meetsLT(v, n) { + return fmt.Sprintf("must be less than %d", n) + } } return "" } +func meetsGT(v reflect.Value, n int) bool { + switch v.Kind() { + case reflect.String: + return len(v.String()) > n + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() > int64(n) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return v.Uint() > uint64(n) + case reflect.Float32, reflect.Float64: + return v.Float() > float64(n) + } + return true +} + +func meetsLT(v reflect.Value, n int) bool { + switch v.Kind() { + case reflect.String: + return len(v.String()) < n + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() < int64(n) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return v.Uint() < uint64(n) + case reflect.Float32, reflect.Float64: + return v.Float() < float64(n) + } + return true +} + func isZero(v reflect.Value) bool { switch v.Kind() { case reflect.String: From efb2dfd7fbe1fc05998a17a55a42d243c739d33d Mon Sep 17 00:00:00 2001 From: devituz Date: Sat, 30 May 2026 19:38:04 +0500 Subject: [PATCH 3/3] docs(changelog): v0.13.0 release notes --- CHANGELOG.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3078f5..f5697f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,70 @@ All notable changes are recorded here. Versions follow [SemVer](https://semver.org/). Pre-`v1.0.0` releases may include breaking changes between minor versions. +## v0.13.0 — 2026-05-30 + +### Added + +- **Secure-by-default middleware** in the native `web` package: + - `web.SecurityHeaders()` — CSP, X-Frame-Options, + X-Content-Type-Options, Referrer-Policy, Permissions-Policy, optional + HSTS. Configurable via `SecurityHeadersConfig`. + - `web.CSRF()` — double-submit cookie with constant-time compare + (`crypto/subtle`). Skips safe methods. Configurable cookie name, + header name, form field, `Secure`/`SameSite`/`MaxAge`. + - `web.RateLimit(n, window)` and `web.Throttle(...)` — fixed-window + per-IP limiter with `Retry-After`. Background GC of stale buckets. + - `web.BodyLimit(n bytes)` — wraps `r.Body` with `http.MaxBytesReader` + to prevent payload-DoS. + - `web.RequestID()` — generates or echoes `X-Request-ID` for tracing + and log correlation. +- **Native validation** (`web/validate.go`) — no Gin dependency: + - `web.Validate(dst)` — struct-tag rule engine. + - `c.BindAndValidate(&dst)` — wires `Bind` + `Validate` + auto-422 + response with `{"error": "validation failed", "errors": {...}}`. + - Rules: `required`, `min=N`, `max=N`, `gt=N`, `lt=N`, `email`, `url`, + `oneof=a b c`, `alpha`, `alphanumeric`, `uuid`, `numeric`, `integer`, + `ip`. + - `c.UnprocessableEntity(*ValidationError)` helper. +- **Cookies** (`web/cookies.go`) — `c.SetCookie`, `c.Cookie`, + `c.ClearCookie` with `HttpOnly` / `Secure` / `SameSite=Lax` defaults. + Opt-outs via `CookieInsecure()`, `CookieReadable()`, `CookieSameSite()`, + `CookieMaxAge()`, `CookiePath()`, `CookieDomain()`, `CookieExpires()`. +- **Hardened CORS** — `web.CORSWithConfig(CORSConfig{...})` supports + `AllowCredentials`, `AllowedMethods`, `AllowedHeaders`, `ExposedHeaders`, + `MaxAgeSeconds`. Refuses to start with wildcard origin + + `AllowCredentials: true` (panics at init). +- **`examples/secure`** — runnable demo of the full stack + (`SecurityHeaders` + `BodyLimit` + `RateLimit` + `CORS` + validation). +- **`SECURITY.md`** — defenses catalogued by layer, recommended + middleware stack, vulnerability-reporting policy. + +### Changed + +- `web.App.Run()` now sets `ReadTimeout` (30s), `WriteTimeout` (30s), + `IdleTimeout` (120s), and `MaxHeaderBytes` (1 MiB) — not just + `ReadHeaderTimeout`. Protects against slow-write / resource-hold + attacks. +- `c.Bind()` always applies `http.MaxBytesReader` with `DefaultBodyLimit` + (1 MiB) when no `BodyLimit` middleware is active, and decodes with + `DisallowUnknownFields()` to block mass-assignment surprises. +- `c.InternalError(err)` respects `APP_ENV=production` and replaces the + raw error message with a generic `"internal server error"`. Dev mode + unchanged. +- `c.Error(err)` recognises `*ValidationError` and maps it to HTTP 422. + +### Fixed + +- Double-body write: `c.Bind()` wrote a 400 then `respond()` wrote a + second 500 over it because there was no body-written tracking. New + `bodyWritten` flag on `Context` short-circuits both paths. +- `c.Created()` flushed `WriteHeader(201)` eagerly, which made + `Content-Type` unsettable; 201 responses landed as `text/plain`. + Replaced with `pendingStatus` that defers `WriteHeader` until the body + is written by `JSON/String/respond`. +- README quick tour referenced the removed `t.SoftDeletes()` schema + builder. + ## v0.10.0 — 2026-05-22 ### Added