384 lines
12 KiB
Markdown
384 lines
12 KiB
Markdown
# Lesson 9 — Rate Limiting & Security Hardening
|
|||
|
|
|
||
|
|
> **New Go concepts in this lesson:** almost none new at the language
|
||
|
|
> level — this lesson is mostly about correctly configuring existing
|
||
|
|
> tools (`httprate`, `cors`, cookie flags) rather than new syntax. A good
|
||
|
|
> lesson to consolidate everything from Go Basics so far.
|
||
|
|
|
||
|
|
Four separate concerns, each small on its own: **rate limiting** (stop
|
||
|
|
abuse/brute-force), **secure cookie flags** (protect the session cookie
|
||
|
|
itself), **CORS** (control which websites can call your API from a
|
||
|
|
browser), and a basic **CSRF** mitigation for our cookie-based sessions.
|
||
|
|
|
||
|
|
## Part A — standalone playgrounds
|
||
|
|
|
||
|
|
### 1. Rate limiting with `httprate`
|
||
|
|
|
||
|
|
```bash
|
||
|
|
mkdir ~/go-playground/security-demo && cd ~/go-playground/security-demo
|
||
|
|
go mod init security-demo
|
||
|
|
go get github.com/go-chi/httprate@latest
|
||
|
|
go get github.com/go-chi/chi/v5@latest
|
||
|
|
```
|
||
|
|
|
||
|
|
**`main.go`**
|
||
|
|
```go
|
||
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"fmt"
|
||
|
|
"log"
|
||
|
|
"net/http"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/go-chi/chi/v5"
|
||
|
|
"github.com/go-chi/httprate"
|
||
|
|
)
|
||
|
|
|
||
|
|
func main() {
|
||
|
|
r := chi.NewRouter()
|
||
|
|
|
||
|
|
// 1. Limit EVERY client to 5 requests per 10 seconds, keyed by IP.
|
||
|
|
r.Use(httprate.LimitByIP(5, 10*time.Second))
|
||
|
|
|
||
|
|
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
fmt.Fprintln(w, "pong")
|
||
|
|
})
|
||
|
|
|
||
|
|
log.Println("listening on :4000")
|
||
|
|
log.Fatal(http.ListenAndServe(":4000", r))
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Run it and hammer it:
|
||
|
|
```bash
|
||
|
|
go run .
|
||
|
|
|
||
|
|
for i in $(seq 1 8); do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:4000/ping; done
|
||
|
|
```
|
||
|
|
You should see `200` five times, then `429` (Too Many Requests) for the
|
||
|
|
rest, until 10 seconds pass.
|
||
|
|
|
||
|
|
- `httprate.LimitByIP(5, 10*time.Second)` — a ready-made middleware (same
|
||
|
|
`func(http.Handler) http.Handler` shape you already know) tracking
|
||
|
|
request counts **per client IP**, in a sliding window. Exceeding the
|
||
|
|
limit auto-responds with `429 Too Many Requests` — you don't write that
|
||
|
|
logic yourself.
|
||
|
|
- Why keyed by IP: without a key, one abusive client could exhaust the
|
||
|
|
"budget" for every other user too. `LimitByIP` isolates each caller's
|
||
|
|
own quota. (Other keying strategies exist too — `LimitByRealIP`, or
|
||
|
|
custom keys like "by user ID" once authenticated.) This matters most on
|
||
|
|
`/login` and `/register` — without it, someone could script thousands
|
||
|
|
of password guesses per second against `/login`.
|
||
|
|
|
||
|
|
### 2. Cookie security flags
|
||
|
|
|
||
|
|
No need to run this one — just understand each flag, since we set these
|
||
|
|
on `scs`'s cookie config (already partly done in Lesson 6), not by hand:
|
||
|
|
|
||
|
|
```go
|
||
|
|
http.SetCookie(w, &http.Cookie{
|
||
|
|
Name: "session_id",
|
||
|
|
Value: "abc123",
|
||
|
|
Path: "/",
|
||
|
|
HttpOnly: true, // JS cannot read this cookie
|
||
|
|
Secure: true, // browser only sends it over HTTPS
|
||
|
|
SameSite: http.SameSiteLaxMode, // restricts cross-site sending
|
||
|
|
})
|
||
|
|
```
|
||
|
|
- `HttpOnly: true` — blocks `document.cookie` access from JavaScript.
|
||
|
|
Defeats a whole class of XSS attacks that try to steal the session
|
||
|
|
cookie via injected script.
|
||
|
|
- `Secure: true` — the browser will refuse to send this cookie over plain
|
||
|
|
HTTP, only HTTPS. **Important gotcha**: if you set this while
|
||
|
|
developing locally over `http://localhost`, the cookie won't be sent at
|
||
|
|
all — you'll be confused why sessions "don't work." We'll make this
|
||
|
|
environment-dependent in Part B.
|
||
|
|
- `SameSite: http.SameSiteLaxMode` — controls whether the cookie is sent
|
||
|
|
on cross-site requests. `Lax` (a good default) sends the cookie on
|
||
|
|
top-level navigations (clicking a link to your site) but not on
|
||
|
|
cross-site `POST`s triggered by another page (like a malicious
|
||
|
|
`<form>` auto-submitting to your `/logout`) — this is your main defense
|
||
|
|
against CSRF for cookie-based auth. `Strict` is even tighter but can
|
||
|
|
break legitimate cross-site navigation flows (like our own OAuth
|
||
|
|
callback from Google!). `None` disables the protection entirely and
|
||
|
|
requires `Secure: true`.
|
||
|
|
|
||
|
|
### 3. CORS
|
||
|
|
|
||
|
|
CORS only matters for requests made **from browser JavaScript running on
|
||
|
|
a different origin** than your API (e.g., a React app on
|
||
|
|
`http://localhost:3000` calling your API on `http://localhost:8080`). It
|
||
|
|
does **not** protect your API from curl, mobile apps, or server-to-server
|
||
|
|
calls — CORS is a browser-enforced rule, not a server-side security
|
||
|
|
boundary. It controls *which websites* a browser will let call your API
|
||
|
|
with the user's cookies/credentials attached.
|
||
|
|
|
||
|
|
```bash
|
||
|
|
go get github.com/go-chi/cors@latest
|
||
|
|
```
|
||
|
|
|
||
|
|
```go
|
||
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"net/http"
|
||
|
|
|
||
|
|
"github.com/go-chi/chi/v5"
|
||
|
|
"github.com/go-chi/cors"
|
||
|
|
)
|
||
|
|
|
||
|
|
func main() {
|
||
|
|
r := chi.NewRouter()
|
||
|
|
|
||
|
|
r.Use(cors.Handler(cors.Options{
|
||
|
|
AllowedOrigins: []string{"http://localhost:3000"}, // your frontend's origin
|
||
|
|
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
|
||
|
|
AllowedHeaders: []string{"Content-Type"},
|
||
|
|
AllowCredentials: true, // required for cookies to be sent cross-origin
|
||
|
|
}))
|
||
|
|
|
||
|
|
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
w.Write([]byte("pong"))
|
||
|
|
})
|
||
|
|
|
||
|
|
http.ListenAndServe(":4000", r)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
- `AllowedOrigins` — an explicit allowlist. **Never** use `"*"` (wildcard)
|
||
|
|
together with `AllowCredentials: true` — browsers actually forbid that
|
||
|
|
combination outright, and even without credentials it's a bad default
|
||
|
|
for anything handling auth.
|
||
|
|
- `AllowCredentials: true` — without this, the browser won't include
|
||
|
|
cookies on cross-origin requests to your API at all, so session-based
|
||
|
|
auth from a separate frontend wouldn't work.
|
||
|
|
|
||
|
|
## Part B — apply it all to the project
|
||
|
|
|
||
|
|
**Get the dependencies:**
|
||
|
|
```bash
|
||
|
|
go get github.com/go-chi/httprate@latest
|
||
|
|
go get github.com/go-chi/cors@latest
|
||
|
|
```
|
||
|
|
|
||
|
|
**Update `internal/router/router.go`** — apply a general limit to
|
||
|
|
everything, and a stricter one specifically to auth endpoints:
|
||
|
|
```go
|
||
|
|
package router
|
||
|
|
|
||
|
|
import (
|
||
|
|
"database/sql"
|
||
|
|
"log/slog"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/alexedwards/scs/v2"
|
||
|
|
"github.com/go-chi/chi/v5"
|
||
|
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||
|
|
"github.com/go-chi/cors"
|
||
|
|
"github.com/go-chi/httprate"
|
||
|
|
|
||
|
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
|
||
|
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/handlers"
|
||
|
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/middleware"
|
||
|
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/models"
|
||
|
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/oauth"
|
||
|
|
)
|
||
|
|
|
||
|
|
func New(logger *slog.Logger, db *sql.DB, sessions *scs.SessionManager, cfg config.Config) *chi.Mux {
|
||
|
|
r := chi.NewRouter()
|
||
|
|
|
||
|
|
r.Use(chimw.RequestID)
|
||
|
|
r.Use(middleware.RequestLogger(logger))
|
||
|
|
r.Use(chimw.Recoverer)
|
||
|
|
r.Use(chimw.Timeout(60 * time.Second))
|
||
|
|
|
||
|
|
r.Use(cors.Handler(cors.Options{
|
||
|
|
AllowedOrigins: cfg.AllowedOrigins,
|
||
|
|
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
|
||
|
|
AllowedHeaders: []string{"Content-Type"},
|
||
|
|
AllowCredentials: true,
|
||
|
|
}))
|
||
|
|
|
||
|
|
// A generous global limit - mostly to stop runaway scripts/bots.
|
||
|
|
r.Use(httprate.LimitByIP(100, time.Minute))
|
||
|
|
|
||
|
|
r.Use(sessions.LoadAndSave)
|
||
|
|
|
||
|
|
r.Get("/health", handlers.Health)
|
||
|
|
|
||
|
|
userRepo := models.NewUserRepository(db)
|
||
|
|
authHandler := handlers.NewAuthHandler(userRepo, sessions, logger)
|
||
|
|
requireAuth := middleware.RequireAuth(sessions, userRepo, logger)
|
||
|
|
|
||
|
|
// A much stricter limit specifically on login/register, since these
|
||
|
|
// are exactly what a credential-stuffing / brute-force script targets.
|
||
|
|
r.Group(func(r chi.Router) {
|
||
|
|
r.Use(httprate.LimitByIP(5, time.Minute))
|
||
|
|
r.Post("/register", authHandler.Register)
|
||
|
|
r.Post("/login", authHandler.Login)
|
||
|
|
})
|
||
|
|
|
||
|
|
r.Post("/logout", authHandler.Logout)
|
||
|
|
|
||
|
|
r.Group(func(r chi.Router) {
|
||
|
|
r.Use(requireAuth)
|
||
|
|
r.Get("/me", authHandler.Me)
|
||
|
|
})
|
||
|
|
|
||
|
|
googleConfig := oauth.NewGoogleConfig(cfg)
|
||
|
|
googleHandler := handlers.NewGoogleOAuthHandler(googleConfig, userRepo, sessions, logger)
|
||
|
|
|
||
|
|
r.Get("/auth/google/login", googleHandler.Login)
|
||
|
|
r.Get("/auth/google/callback", googleHandler.Callback)
|
||
|
|
|
||
|
|
return r
|
||
|
|
}
|
||
|
|
```
|
||
|
|
- Two separate `httprate.LimitByIP` calls at different scopes — the
|
||
|
|
global `100/minute` is a loose safety net for the whole API, while the
|
||
|
|
`r.Group` around `/register` and `/login` layers a *much* tighter
|
||
|
|
`5/minute` on top. Both limits apply simultaneously to requests inside
|
||
|
|
the group (they stack).
|
||
|
|
- `/logout` deliberately sits *outside* that strict group — you don't
|
||
|
|
want to rate-limit a legitimate logged-in user trying to log out.
|
||
|
|
- `cors.Handler(...)` now reads `cfg.AllowedOrigins` instead of a
|
||
|
|
hardcoded value.
|
||
|
|
|
||
|
|
**Extend `internal/config/config.go`** for CORS origins and cookie
|
||
|
|
security:
|
||
|
|
```go
|
||
|
|
import "strings"
|
||
|
|
|
||
|
|
type Config struct {
|
||
|
|
Port string
|
||
|
|
Env string // "development" or "production"
|
||
|
|
|
||
|
|
DBHost string
|
||
|
|
DBPort string
|
||
|
|
DBUser string
|
||
|
|
DBPassword string
|
||
|
|
DBName string
|
||
|
|
|
||
|
|
RedisAddr string
|
||
|
|
|
||
|
|
GoogleClientID string
|
||
|
|
GoogleClientSecret string
|
||
|
|
GoogleRedirectURL string
|
||
|
|
|
||
|
|
AllowedOrigins []string
|
||
|
|
}
|
||
|
|
|
||
|
|
func Load() Config {
|
||
|
|
return Config{
|
||
|
|
Port: getEnv("PORT", "8080"),
|
||
|
|
Env: getEnv("ENV", "development"),
|
||
|
|
|
||
|
|
DBHost: getEnv("DB_HOST", "127.0.0.1"),
|
||
|
|
DBPort: getEnv("DB_PORT", "3306"),
|
||
|
|
DBUser: getEnv("DB_USER", "root"),
|
||
|
|
DBPassword: getEnv("DB_PASSWORD", "devpass"),
|
||
|
|
DBName: getEnv("DB_NAME", "go_simple_api"),
|
||
|
|
|
||
|
|
RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:6379"),
|
||
|
|
|
||
|
|
GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
|
||
|
|
GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
|
||
|
|
GoogleRedirectURL: getEnv("GOOGLE_REDIRECT_URL", "http://localhost:8080/auth/google/callback"),
|
||
|
|
|
||
|
|
AllowedOrigins: strings.Split(getEnv("ALLOWED_ORIGINS", "http://localhost:3000"), ","),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
- `Env` — distinguishes development from production, used next for the
|
||
|
|
cookie's `Secure` flag.
|
||
|
|
- `strings.Split(getEnv(...), ",")` — lets you configure multiple allowed
|
||
|
|
origins via one comma-separated env var (see Go Basics Part 3 on
|
||
|
|
slices), e.g. `ALLOWED_ORIGINS=http://localhost:3000,https://myapp.com`.
|
||
|
|
|
||
|
|
**Update `internal/session/session.go`** — make `Secure` environment-aware,
|
||
|
|
fixing the localhost gotcha from Part A:
|
||
|
|
```go
|
||
|
|
package session
|
||
|
|
|
||
|
|
import (
|
||
|
|
"net/http"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/alexedwards/scs/redisstore"
|
||
|
|
"github.com/alexedwards/scs/v2"
|
||
|
|
"github.com/gomodule/redigo/redis"
|
||
|
|
|
||
|
|
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
|
||
|
|
)
|
||
|
|
|
||
|
|
func New(cfg config.Config) *scs.SessionManager {
|
||
|
|
pool := &redis.Pool{
|
||
|
|
MaxIdle: 10,
|
||
|
|
Dial: func() (redis.Conn, error) {
|
||
|
|
return redis.Dial("tcp", cfg.RedisAddr)
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
manager := scs.New()
|
||
|
|
manager.Store = redisstore.New(pool)
|
||
|
|
manager.Lifetime = 24 * time.Hour
|
||
|
|
manager.Cookie.Name = "session_id"
|
||
|
|
manager.Cookie.HttpOnly = true
|
||
|
|
manager.Cookie.SameSite = http.SameSiteLaxMode
|
||
|
|
manager.Cookie.Secure = cfg.Env == "production" // only require HTTPS in prod
|
||
|
|
|
||
|
|
return manager
|
||
|
|
}
|
||
|
|
```
|
||
|
|
`manager.Cookie.Secure = cfg.Env == "production"` — in development
|
||
|
|
(`ENV` unset or `"development"`), the cookie works over plain
|
||
|
|
`http://localhost`. In production, set `ENV=production` and the cookie
|
||
|
|
will refuse to be sent over anything but HTTPS.
|
||
|
|
|
||
|
|
**Update `cmd/api/main.go`** — no change needed; `router.New(logger, db,
|
||
|
|
sessions, cfg)` already passes `cfg`, which now carries `AllowedOrigins`
|
||
|
|
and `Env`.
|
||
|
|
|
||
|
|
**Add to your `.env`:**
|
||
|
|
```
|
||
|
|
ENV=development
|
||
|
|
ALLOWED_ORIGINS=http://localhost:3000
|
||
|
|
```
|
||
|
|
|
||
|
|
## Try it
|
||
|
|
|
||
|
|
```bash
|
||
|
|
go run ./cmd/api
|
||
|
|
```
|
||
|
|
|
||
|
|
**Rate limiting:**
|
||
|
|
```bash
|
||
|
|
for i in $(seq 1 7); do
|
||
|
|
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8080/login \
|
||
|
|
-H "Content-Type: application/json" \
|
||
|
|
-d '{"email":"nope@example.com","password":"wrong"}'
|
||
|
|
done
|
||
|
|
```
|
||
|
|
You should see `401` (wrong credentials) for the first 5, then `429`
|
||
|
|
(rate limited) for the rest.
|
||
|
|
|
||
|
|
**CORS:**
|
||
|
|
```bash
|
||
|
|
curl -i -X OPTIONS http://localhost:8080/login \
|
||
|
|
-H "Origin: http://localhost:3000" \
|
||
|
|
-H "Access-Control-Request-Method: POST"
|
||
|
|
```
|
||
|
|
Look for `Access-Control-Allow-Origin: http://localhost:3000` in the
|
||
|
|
response headers.
|
||
|
|
|
||
|
|
A note on what we're *not* doing yet: full CSRF-token-based protection (a
|
||
|
|
token embedded in forms and checked server-side) is a deeper topic on its
|
||
|
|
own, and `SameSite=Lax` already covers the most common cookie-based CSRF
|
||
|
|
vector for a JSON API like this. If you later build a traditional
|
||
|
|
HTML-form frontend served from the same origin, that's when a dedicated
|
||
|
|
CSRF token library becomes worth adding — treat current protections as
|
||
|
|
sufficient for this course's scope.
|
||
|
|
|
||
|
|
Once rate limiting and CORS both check out, move to Lesson 10 — Docker,
|
||
|
|
docker-compose, and the full course wrap-up.
|