Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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 {
Expand Down
108 changes: 108 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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})
})
```
72 changes: 72 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# 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) ✅

- [x] `web/security.go`: `SecurityHeaders()` — CSP, X-Frame-Options,
X-Content-Type-Options, Referrer-Policy, HSTS, Permissions-Policy
- [x] `web/security.go`: `CSRF()` — double-submit cookie, constant-time
compare, safe-method skip (GET/HEAD/OPTIONS)
- [x] `web/security.go`: `RateLimit(n, window)` — fixed-window per IP,
`429 Too Many Requests` with `Retry-After`
- [x] `web/security.go`: `BodyLimit(n bytes)` — wrap `r.Body` with
`http.MaxBytesReader` to prevent DoS via huge payloads
- [x] `web/security.go`: `RequestID()` — generate/forward `X-Request-ID`
for tracing & log correlation

## P0 — Validation (native, no Gin) ✅

- [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 ✅

- [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()`

## P0 — Bug fixes uncovered while building secure example ✅

- [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
- [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
(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

- [ ] `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.
65 changes: 65 additions & 0 deletions examples/secure/main.go
Original file line number Diff line number Diff line change
@@ -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()
}
7 changes: 6 additions & 1 deletion web/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
addr string
timeout time.Duration
logger *log.Logger
mu sync.Mutex

Check failure on line 33 in web/app.go

View workflow job for this annotation

GitHub Actions / Lint

field mu is unused (U1000)

Check failure on line 33 in web/app.go

View workflow job for this annotation

GitHub Actions / Lint

field mu is unused (U1000)
}

// Option — funksional sozlash usuli.
Expand Down Expand Up @@ -113,11 +113,16 @@
})
}

// 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)
Expand Down
Loading
Loading