diff --git a/docs/services/authentication.md b/docs/services/authentication.md index 6f5eddec..3b311955 100644 --- a/docs/services/authentication.md +++ b/docs/services/authentication.md @@ -1,6 +1,6 @@ --- title: Authentication -description: Mint and verify JWTs with bouncer, turn guards into route middleware, revoke tokens through a jti blacklist and hash passwords with bcrypt. +description: JWTs with bouncer, cookie transport, guards as route middleware, a jti blacklist, Cloudflare Turnstile checks and bcrypt passwords. section: services order: 50 --- @@ -166,6 +166,30 @@ fmt.Println(rec.Code, rec.Body.String()) // 401 {"error":"Authentication required"} ``` +## Turnstile + +[Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) keeps bots off guest and registration endpoints. The frontend widget produces a token, the request carries it, and the server checks it with Cloudflare's siteverify endpoint. surf registers the check as named middleware with two modes: + +| Middleware | Without a secret | With a secret | +|------------|------------------|---------------| +| `turnstile:open` | passes (a development no-op) | the token must verify | +| `turnstile:closed` | 503 in the production environment, passes elsewhere | the token must verify | + +Use `turnstile:closed` where an unprotected endpoint would be a hole, such as guest entry: a production deployment that forgot the secret answers 503 instead of letting every bot in. Use `turnstile:open` where the endpoint has other protection and must keep working without Turnstile, such as registration. With a secret set both modes behave the same: a missing token, a token Cloudflare rejects, a token longer than 2048 characters, a failed siteverify call or one slower than 5 seconds answers 422. + +The middleware reads the token from the `turnstile_token` field (`turnstile.field` changes the name): the query string first, then a JSON object body or a form body. It reads the body and restores it, so the handler sees the same bytes. A multipart body is not searched; put the token in the query string there. + +| Key | Default | Controls | +|-----|---------|----------| +| `turnstile.secret` | empty | The site's secret key. Set it through `SUMMER_TURNSTILE__SECRET`; a PHP application's `TURNSTILE_SECRET` value moves to that variable. | +| `turnstile.field` | `turnstile_token` | The request field carrying the token. | + +Refusals are written with neutral bodies, `{"error":"turnstile_unavailable"}` (503) and `{"error":"turnstile_failed"}` (422). To keep an existing response envelope, publish a `bouncer.TurnstileWriter` on the app in your plugin's `Register`; it receives the status and the `bouncer.TurnstileVerdict`. A published `*bouncer.Turnstile` replaces the verifier built from config, for example to give it its own `http.Client`. + +Cloudflare's [testing page](https://developers.cloudflare.com/turnstile/troubleshooting/testing/) lists secrets that always pass (`1x0000000000000000000000000000000AA`) or always fail (`2x0000000000000000000000000000000AA`), for staging environments and manual checks. A token is valid for 300 seconds and only once: verify it before any write and never retry a verification with the same token. + +The middleware runs before the handler, so a request with both a bad token and invalid fields gets the Turnstile answer, not the validation errors. When a ported endpoint must keep the order it had, such as validating a name first, call `bouncer.Turnstile.Verify` from the handler instead of using the middleware: it takes the context, token, client IP and mode and returns the verdict, plus an error for logging that never contains the secret or the token. + ## Passwords `bouncer.HashPassword` hashes with bcrypt at the cost you give it, `bouncer.CheckPassword` compares in constant time, and `bouncer.NeedsRehash` reports a hash made below your configured cost. WinterCMS stores bcrypt hashes too, so existing passwords keep working: diff --git a/docs/services/routing.md b/docs/services/routing.md index 660ebf68..f0a1dd58 100644 --- a/docs/services/routing.md +++ b/docs/services/routing.md @@ -99,6 +99,7 @@ The framework registers these names: | `throttle:` or `throttle:,` | Rate limiting; see [Rate limiting](rate-limiting.md). | | `body.limit:` | Replaces the default request body limit for the route. | | `cors:` | CORS headers from `http.cors_profiles.`; see [CORS profiles](#cors-profiles). | +| `turnstile:open`, `turnstile:closed` | Cloudflare Turnstile verification; see [Turnstile](authentication.md#turnstile). | | `locale.from-principal` | Switches the request locale to the signed-in user's preferred locale. | | `backend` | The admin guard, when the admin is enabled. | diff --git a/modules/bouncer/README.md b/modules/bouncer/README.md index fee6a990..b974caa4 100644 --- a/modules/bouncer/README.md +++ b/modules/bouncer/README.md @@ -32,6 +32,7 @@ Use cases: - Scope limits for personal API tokens: `bouncer.ScopeLimit` on `bouncer.Principal.Scope` (nil means unrestricted, as for a JWT session). A token guard sets it after filtering the token's scopes by the request method; `Full` means the token acts as the owner on every method (`*`) or on GET/HEAD (`admin:read`). - Blacklist stores behind `bouncer.BlacklistStore`: `bouncer.MemoryBlacklist` for tests and `bouncer.PostgresBlacklist` for production, which works on a caller-supplied table with `jti`, `expires_at` and `valid_until` columns and rejects unsafe table names. - Cookie transport: `bouncer.LoadCookieConfig` reads the `auth.cookie.*` block into a `bouncer.CookieConfig` and refuses an invalid domain, `same_site: none` without Secure and `secure: false` in the production environment; `bouncer.CookieConfig.TokenCookie` builds the httpOnly token cookie whose Max-Age is the refresh window, and `bouncer.CookieConfig.ForgetTokenCookie` the matching cookie that deletes it. `bouncer.CookieRefreshHandler` and `bouncer.CookieLogoutHandler` are ready-made handlers for a cookie-based refresh and logout; the application supplies the refresh or revoke call and a writer for its own response envelope. +- Cloudflare Turnstile: `bouncer.Turnstile.Verify` checks a token against siteverify with a 5 second timeout, in PHP's open mode (an empty secret passes) or closed mode (an empty secret is `bouncer.TurnstileUnavailable` in production); `bouncer.Turnstile.Middleware` reads the token from the query, a JSON body or a form body, restores the body and writes refusals through a `bouncer.TurnstileWriter`. surf registers it as `turnstile:open` and `turnstile:closed`. - Passwords: `bouncer.HashPassword`, `bouncer.CheckPassword` and `bouncer.NeedsRehash` (bcrypt, cost chosen by the caller). ## Usage @@ -167,13 +168,23 @@ func RefreshRoute(cfg bouncer.CookieConfigSource, users bouncer.UserProvider, se | `bouncer.CookieLogoutOptions` | Cookie config, revoke func and writer of `bouncer.CookieLogoutHandler`. | | `bouncer.CookieResult` | What the cookie handlers pass to the writer: status, rotated token and error. | | `bouncer.ErrNoTokenCookie` | The refresh request carried no token cookie (401). | +| `bouncer.Turnstile` | Cloudflare Turnstile verifier: `Secret`, `Production`, `Endpoint`, `Client`. | +| `bouncer.NewTurnstile` | A verifier with the default endpoint and a client with `bouncer.TurnstileTimeout`. | +| `bouncer.Turnstile.Verify` | Checks a token for a client IP in open or closed mode; returns a `bouncer.TurnstileVerdict` and an error for logging. | +| `bouncer.Turnstile.Middleware` | Middleware that reads the token field, verifies it and answers 503 or 422 through a `bouncer.TurnstileWriter`. | +| `bouncer.TurnstileVerdict` | `bouncer.TurnstilePassed`, `bouncer.TurnstileFailed` or `bouncer.TurnstileUnavailable`. | +| `bouncer.TurnstileWriter` | Writes a Turnstile refusal in the application's envelope; publish one on the app to replace the neutral default. | +| `bouncer.TurnstileSiteverifyURL` | Cloudflare's siteverify endpoint. | +| `bouncer.TurnstileTimeout` | 5 seconds, the bound on one siteverify call. | +| `bouncer.TurnstileMaxTokenLength` | 2048, Cloudflare's token limit; longer tokens fail without a call. | +| `bouncer.DefaultTurnstileField` | `turnstile_token`, the default request field. | | `bouncer.HashPassword` | Returns a bcrypt hash at the given cost. | | `bouncer.CheckPassword` | Reports whether a password matches a hash. | | `bouncer.NeedsRehash` | Reports whether a hash was made below the configured cost. | ## Configuration -`bouncer.LoadCookieConfig` reads these keys; nothing else in bouncer reads config. The per-guard token lifetimes (`ttl`, `refresh_ttl`) stay with the guard's own config, such as the user plugin's `jwt` block or `admin.jwt.*`. +`bouncer.LoadCookieConfig` reads these keys; nothing else in bouncer reads config (surf builds the Turnstile verifier from `turnstile.secret` and `turnstile.field`, see the [surf README](../surf/README.md#configuration)). The per-guard token lifetimes (`ttl`, `refresh_ttl`) stay with the guard's own config, such as the user plugin's `jwt` block or `admin.jwt.*`. | Key | Default | Controls | |-----|---------|----------| @@ -189,7 +200,7 @@ Environment variables follow the compass mapping, for example `SUMMER_AUTH__COOK - SummerCMS modules: none. - Third-party: `github.com/golang-jwt/jwt/v5`, `golang.org/x/crypto/bcrypt`. -- Standard library: `context`, `crypto/rand`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `math`, `net/http`, `net/url`, `reflect`, `regexp`, `strconv`, `strings`, `sync`, `time`. +- Standard library: `bytes`, `context`, `crypto/rand`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `io`, `log/slog`, `math`, `mime`, `net`, `net/http`, `net/url`, `reflect`, `regexp`, `strconv`, `strings`, `sync`, `time`. - Tests additionally use `github.com/testcontainers/testcontainers-go` with its `modules/postgres` package, and `github.com/jackc/pgx/v5/stdlib`. ## Testing @@ -198,4 +209,4 @@ Environment variables follow the compass mapping, for example `SUMMER_AUTH__COOK go test ./modules/bouncer/... ``` -The Postgres blacklist concurrency test starts a PostgreSQL container through testcontainers-go and needs Docker. Run `go test -short ./modules/bouncer/...` to skip it; the remaining tests need no external services. +The Postgres blacklist concurrency test starts a PostgreSQL container through testcontainers-go and needs Docker. Run `go test -short ./modules/bouncer/...` to skip it; the remaining tests need no external services (Turnstile tests use an `httptest` siteverify fake through `Endpoint`). diff --git a/modules/bouncer/turnstile.go b/modules/bouncer/turnstile.go new file mode 100644 index 00000000..3b072cf2 --- /dev/null +++ b/modules/bouncer/turnstile.go @@ -0,0 +1,304 @@ +package bouncer + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "mime" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + // TurnstileSiteverifyURL is Cloudflare's server-side validation endpoint. + TurnstileSiteverifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" + // TurnstileTimeout bounds one siteverify call. + TurnstileTimeout = 5 * time.Second + // TurnstileMaxTokenLength is Cloudflare's documented token limit; a + // longer token fails without a call. + TurnstileMaxTokenLength = 2048 + // DefaultTurnstileField is the request field carrying the token. + DefaultTurnstileField = "turnstile_token" +) + +// turnstileAnswerLimit caps how much of the siteverify answer is read. +const turnstileAnswerLimit = 4 << 10 + +// errTurnstileRefused marks a siteverify answer with success false: a normal +// rejection, logged below warn level. +var errTurnstileRefused = errors.New("bouncer: turnstile siteverify refused the token") + +// TurnstileVerdict is the outcome of a Turnstile check. +type TurnstileVerdict int + +const ( + // TurnstilePassed lets the request through. + TurnstilePassed TurnstileVerdict = iota + // TurnstileFailed is a missing or rejected token, or a siteverify call + // that failed or timed out (422). + TurnstileFailed + // TurnstileUnavailable is fail-closed mode without a secret in the + // production environment (503). + TurnstileUnavailable +) + +// String names the verdict for logs. +func (v TurnstileVerdict) String() string { + switch v { + case TurnstilePassed: + return "passed" + case TurnstileFailed: + return "failed" + case TurnstileUnavailable: + return "unavailable" + default: + return fmt.Sprintf("TurnstileVerdict(%d)", int(v)) + } +} + +// TurnstileWriter writes a Turnstile refusal in the application's response +// envelope: status is 503 for TurnstileUnavailable and 422 for +// TurnstileFailed. An application publishes one on the backpack app to keep +// its own error shape. +type TurnstileWriter func(w http.ResponseWriter, r *http.Request, status int, verdict TurnstileVerdict) + +// Turnstile verifies Cloudflare Turnstile tokens against siteverify. +type Turnstile struct { + // Secret is the site's secret key; empty means unconfigured. + Secret string + // Production makes an unconfigured fail-closed check unavailable + // instead of passing. + Production bool + // Endpoint defaults to TurnstileSiteverifyURL; tests point it at a fake. + Endpoint string + // Client defaults to an *http.Client with Timeout TurnstileTimeout. + Client *http.Client +} + +// NewTurnstile returns a verifier for secret with the default endpoint and +// client. +func NewTurnstile(secret string, production bool) *Turnstile { + return &Turnstile{ + Secret: secret, + Production: production, + Endpoint: TurnstileSiteverifyURL, + Client: &http.Client{Timeout: TurnstileTimeout}, + } +} + +// Verify checks token for the client at ip. With failClosed false (open +// mode, for registration) an empty secret passes; with failClosed true +// (closed mode, for guest entry) an empty secret is TurnstileUnavailable in +// production and passes elsewhere. With a secret set both modes require a +// siteverify answer with success true: an empty token, a token longer than +// TurnstileMaxTokenLength, success false, a transport error, a timeout, a +// non-2xx status or an undecodable answer is TurnstileFailed. The returned +// error explains a failure for logging; it never contains the secret or the +// token. +func (t *Turnstile) Verify(ctx context.Context, token, ip string, failClosed bool) (TurnstileVerdict, error) { + if t == nil || strings.TrimSpace(t.Secret) == "" { + if failClosed && (t == nil || t.Production) { + return TurnstileUnavailable, nil + } + return TurnstilePassed, nil + } + if token == "" || len(token) > TurnstileMaxTokenLength { + return TurnstileFailed, nil + } + form := url.Values{"secret": {t.Secret}, "response": {token}} + if ip != "" { + form.Set("remoteip", ip) + } + if ctx == nil { + ctx = context.Background() + } + ctx, cancel := context.WithTimeout(ctx, TurnstileTimeout) + defer cancel() + endpoint := t.Endpoint + if endpoint == "" { + endpoint = TurnstileSiteverifyURL + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return TurnstileFailed, errors.New("bouncer: turnstile siteverify request could not be built") + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + client := t.Client + if client == nil { + client = &http.Client{Timeout: TurnstileTimeout} + } + res, err := client.Do(req) + if err != nil { + return TurnstileFailed, fmt.Errorf("bouncer: turnstile siteverify failed: %s", turnstileTransportError(err)) + } + defer res.Body.Close() + body, err := io.ReadAll(io.LimitReader(res.Body, turnstileAnswerLimit)) + if err != nil { + return TurnstileFailed, fmt.Errorf("bouncer: turnstile siteverify answer could not be read: %s", turnstileTransportError(err)) + } + if res.StatusCode < 200 || res.StatusCode > 299 { + return TurnstileFailed, fmt.Errorf("bouncer: turnstile siteverify answered %d", res.StatusCode) + } + var answer struct { + Success *bool `json:"success"` + ErrorCodes []string `json:"error-codes"` + } + if err := json.Unmarshal(body, &answer); err != nil || answer.Success == nil { + return TurnstileFailed, errors.New("bouncer: turnstile siteverify answer is not a siteverify JSON object") + } + if !*answer.Success { + return TurnstileFailed, fmt.Errorf("%w (error-codes: %s)", errTurnstileRefused, turnstileCodes(answer.ErrorCodes)) + } + return TurnstilePassed, nil +} + +// turnstileTransportError describes a failed call without the URL. +func turnstileTransportError(err error) string { + var uerr *url.Error + if errors.As(err, &uerr) { + err = uerr.Err + } + if errors.Is(err, context.DeadlineExceeded) { + return "timeout" + } + return err.Error() +} + +// turnstileCodes joins Cloudflare's error codes for a log line, keeping only +// the characters the documented codes use. +func turnstileCodes(codes []string) string { + out := make([]string, 0, len(codes)) + for _, c := range codes { + c = strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + return r + } + return -1 + }, c) + if c != "" { + out = append(out, c) + } + if len(out) == 8 { + break + } + } + if len(out) == 0 { + return "none" + } + return strings.Join(out, ",") +} + +// Middleware returns middleware that verifies the token in the request field +// named field (DefaultTurnstileField when empty): the query parameter first, +// else the body, a JSON object's top-level string field or an +// application/x-www-form-urlencoded field. The body is read fully (bound it +// with the router's body limit) and restored with the same bytes before the +// handler runs. TurnstileUnavailable writes 503 and TurnstileFailed 422 +// through write, or through neutral JSON bodies ({"error":"turnstile_unavailable"}, +// {"error":"turnstile_failed"}) when write is nil; TurnstilePassed calls the +// handler. clientIP supplies the remoteip (nil uses the connection address). +// A failed siteverify call is logged once at warn with the error text only. +func (t *Turnstile) Middleware(failClosed bool, field string, clientIP func(*http.Request) string, write TurnstileWriter) func(http.Handler) http.Handler { + if field == "" { + field = DefaultTurnstileField + } + if write == nil { + write = defaultTurnstileWriter + } + if clientIP == nil { + clientIP = remoteAddrIP + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, err := turnstileToken(r, field) + if err != nil { + status := http.StatusBadRequest + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + status = http.StatusRequestEntityTooLarge + } + write(w, r, status, TurnstileFailed) + return + } + verdict, verr := t.Verify(r.Context(), token, clientIP(r), failClosed) + if verr != nil { + if errors.Is(verr, errTurnstileRefused) { + slog.DebugContext(r.Context(), "bouncer: turnstile token refused", "error", verr.Error()) + } else { + slog.WarnContext(r.Context(), "bouncer: turnstile verification failed", "error", verr.Error()) + } + } + switch verdict { + case TurnstilePassed: + next.ServeHTTP(w, r) + case TurnstileUnavailable: + write(w, r, http.StatusServiceUnavailable, verdict) + default: + write(w, r, http.StatusUnprocessableEntity, TurnstileFailed) + } + }) + } +} + +// turnstileToken reads field from the query, else from the body, and +// restores r.Body. +func turnstileToken(r *http.Request, field string) (string, error) { + if v := r.URL.Query().Get(field); v != "" { + return v, nil + } + if r.Body == nil || r.Body == http.NoBody { + return "", nil + } + body, err := io.ReadAll(r.Body) + _ = r.Body.Close() + r.Body = io.NopCloser(bytes.NewReader(body)) + if err != nil { + return "", err + } + mediaType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) + switch { + case mediaType == "application/x-www-form-urlencoded": + values, err := url.ParseQuery(string(body)) + if err != nil { + return "", nil + } + return values.Get(field), nil + case mediaType == "application/json" || strings.HasSuffix(mediaType, "+json") || + (mediaType == "" && bytes.HasPrefix(bytes.TrimSpace(body), []byte("{"))): + var obj map[string]json.RawMessage + if json.Unmarshal(body, &obj) != nil { + return "", nil + } + var v string + if raw, ok := obj[field]; !ok || json.Unmarshal(raw, &v) != nil { + return "", nil + } + return v, nil + } + return "", nil +} + +func defaultTurnstileWriter(w http.ResponseWriter, _ *http.Request, status int, verdict TurnstileVerdict) { + code := "turnstile_failed" + if verdict == TurnstileUnavailable { + code = "turnstile_unavailable" + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, `{"error":"`+code+`"}`) +} + +func remoteAddrIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/modules/surf/README.md b/modules/surf/README.md index 6ccb3300..b7c749b0 100644 --- a/modules/surf/README.md +++ b/modules/surf/README.md @@ -27,7 +27,7 @@ Use cases: - Overlapping constrained routes: two routes that ServeMux would refuse side by side, such as `/shelves/items/{id}` and `/shelves/{shelfId}/follow`, boot as one overlap family when their constraints keep them apart. Requests are tried against the family's routes in registration order, as Laravel does, and each route keeps its own path values and middleware. - Named middleware from plugins (`pact.HasMiddleware`), parameterized middleware used as `name:param` (`pact.HasMiddlewareFactories`) and house middleware for the JSON envelope and error handling (`pact.HasHouseMiddleware`). Duplicate or unknown names fail boot. - Raw groups (`surf.Router.GroupRaw`) for routes that must not be wrapped in house middleware, such as webhooks or file streams: house middleware is refused there, the default body limit is skipped and a panic returns a bare 500. -- Built-in middleware names: `throttle:` or `throttle:,`, `body.limit:`, `cors:`, `locale.from-principal`, plus `backend` (the admin guard) when the admin is enabled. +- Built-in middleware names: `throttle:` or `throttle:,`, `body.limit:`, `cors:`, `turnstile:open` and `turnstile:closed` (Cloudflare Turnstile through `bouncer.Turnstile`, built from `turnstile.secret`, with the client IP from `surf.ClientIP` and refusals written by a published `bouncer.TurnstileWriter` when there is one), `locale.from-principal`, plus `backend` (the admin guard) when the admin is enabled. - Named CORS profiles: `cors:` applies `http.cors_profiles.` (read by `surf.LoadCORSProfiles`, validated like `http.cors`) to a route group, and surf registers an `OPTIONS` route for each such path that answers the preflight from the profile, since ServeMux would answer it with 405 before the middleware runs. An unknown profile, two profiles on one path and a profiled path that `http.cors.paths` also matches fail boot. - Fixed-window rate limiting (`surf.FixedWindowLimiter`): named buckets from plugins that implement `surf.BucketProvider`, or inline limits keyed by the signed-in user, or by client IP for guests. Rejected requests get a 429 with `Retry-After` and `X-RateLimit-*` headers. The in-process `surf.MemoryStore` sits behind the `surf.Store` interface; `surf.BuildRouter` uses a `surf.Store` published on the app instead when there is one, which is how `serve` shares counters across instances through [cooler](../cooler/README.md)'s Redis store (`http.rate_limit.store: redis`). - Client IP resolution for limiter keys (`surf.ClientIP`) that only trusts `X-Forwarded-For` hops when the direct peer is inside a configured trusted proxy range (`surf.TrustedProxies`). @@ -159,6 +159,8 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur | `http.cors.max_age` | `0` | Preflight cache time in seconds. | | `http.cors.supports_credentials` | `false` | Sends `Access-Control-Allow-Credentials: true`. Combined with `*` in `allowed_origins` it fails boot. With credentials, a pattern or several origins, every response on a CORS path carries `Vary: Origin`, preflights and disallowed origins included. | | `http.cors_profiles..*` | none | A CORS profile for `cors:`: the `http.cors` keys except `paths` (`allowed_origins`, `allowed_origins_patterns`, `allowed_methods`, `allowed_headers`, `exposed_headers`, `max_age`, `supports_credentials`), validated the same way. Names use lower-case letters, digits, underscore and dash. | +| `turnstile.secret` | empty | Cloudflare Turnstile secret for `turnstile:open` and `turnstile:closed`. Without it `turnstile:open` passes and `turnstile:closed` answers 503 in the production environment (passes elsewhere). A `*bouncer.Turnstile` published on the app replaces the verifier built from this key. Environment: `SUMMER_TURNSTILE__SECRET`. | +| `turnstile.field` | `turnstile_token` | Request field (query, JSON body or form body) carrying the Turnstile token. | | `http.rate_limit.store` | `memory` | Where `throttle` counters live: `memory` (per instance) or `redis` (shared by every instance through [cooler](../cooler/README.md), which then needs `redis.url`). Only `serve` installs the Redis store; `route:list` keeps the memory store and never dials Redis. Any other value fails at boot. Environment: `SUMMER_HTTP__RATE_LIMIT__STORE`. | | `http.access_log` | `false` | Logs one `http request` record per request (method, matched pattern, status, duration in milliseconds; never the path or query). Environment: `SUMMER_HTTP__ACCESS_LOG`. | | `storage.uploads.serve` | `false` | Mounts the uploads route `GET /{path...}` (prefix default `/storage/uploads`; it must then be an absolute URL path, or start-up fails) on `attach.StaticHandlerPublic` with `X-Content-Type-Options: nosniff`, a sandboxing CSP and `Content-Disposition: attachment` for anything but a raster image. Prefer a separate origin for uploads in production. Environment: `SUMMER_STORAGE__UPLOADS__SERVE`. | diff --git a/modules/surf/router.go b/modules/surf/router.go index 32fa6aa1..c7abbd23 100644 --- a/modules/surf/router.go +++ b/modules/surf/router.go @@ -9,6 +9,7 @@ import ( "time" "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" @@ -437,6 +438,9 @@ func (r *Router) wrap(rt route) (http.Handler, error) { return nil, fmt.Errorf("surf: plugin %q route %s %s references unknown CORS profile %q (define http.cors_profiles.%s)", rt.pluginID, rt.method, rt.path, param, param) } } + if base == "turnstile" && param != "open" && param != "closed" { + return nil, fmt.Errorf("surf: plugin %q route %s %s: turnstile:%s must be turnstile:open or turnstile:closed", rt.pluginID, rt.method, rt.path, param) + } if base == "body.limit" { if _, err := parseBodyLimit(param); err != nil { return nil, fmt.Errorf("surf: plugin %q: %w", rt.pluginID, err) @@ -521,6 +525,20 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) { }); err != nil { return nil, err } + turnstile, turnstileField := buildTurnstile(app) + if err := r.RegisterMiddlewareFactory("surf", "turnstile", func(param string) pact.Middleware { + // wrap refuses a param other than open or closed before this runs. + var write bouncer.TurnstileWriter + if app != nil { + if pub, ok := app.Lookup[bouncer.TurnstileWriter](); ok && pub != nil { + write = pub + } + } + clientIP := func(req *http.Request) string { return ClientIP(req, trusted) } + return turnstile.Middleware(param == "closed", turnstileField, clientIP, write) + }); err != nil { + return nil, err + } if app != nil { corsCfg, err := LoadCORSConfig(app.Config) if err != nil { @@ -620,6 +638,25 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) { return r, nil } +// buildTurnstile returns the verifier behind turnstile:open|closed and the +// token field: a *bouncer.Turnstile published on the app wins, else one built +// from turnstile.secret, with Production set in the production environment. +// turnstile.field defaults to bouncer.DefaultTurnstileField. +func buildTurnstile(app *backpack.App) (*bouncer.Turnstile, string) { + field := bouncer.DefaultTurnstileField + if app == nil || app.Config == nil { + return bouncer.NewTurnstile("", false), field + } + if f := strings.TrimSpace(app.Config.String("turnstile.field")); f != "" { + field = f + } + if pub, ok := app.Lookup[*bouncer.Turnstile](); ok && pub != nil { + return pub, field + } + production := strings.EqualFold(strings.TrimSpace(app.Config.Environment()), "production") + return bouncer.NewTurnstile(strings.TrimSpace(app.Config.String("turnstile.secret")), production), field +} + // checkAdminPrefix fails boot when a plugin other than cabana owns a route at // or under the admin prefix: the admin SPA and API own that whole subtree. func (r *Router) checkAdminPrefix(prefix string) error { diff --git a/modules/surf/transport_smoke_test.go b/modules/surf/transport_smoke_test.go index 9bc2a59e..42b73eb2 100644 --- a/modules/surf/transport_smoke_test.go +++ b/modules/surf/transport_smoke_test.go @@ -3,11 +3,13 @@ package surf import ( "context" "encoding/json" + "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" + "sync/atomic" "testing" "time" @@ -262,3 +264,86 @@ func TestCORSProfileSmoke(t *testing.T) { t.Fatalf("BuildRouter with cors:missing error = %v", err) } } + +// turnstileRoutes declares POST /guest (turnstile:closed) and POST /register +// (turnstile:open); both echo the body they received. +func turnstileRoutes(closedMW, openMW string) routesPluginFunc { + echo := func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(b) + } + return routesPluginFunc{id: "acme.guest", routes: func(r pact.Router) { + r.Post("/guest", echo, closedMW) + r.Post("/register", echo, openMW) + }} +} + +func TestTurnstileMiddlewareSmoke(t *testing.T) { + var calls atomic.Int64 + fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + _ = r.ParseForm() + ok := r.PostForm.Get("response") == "ok" && r.PostForm.Get("secret") == "test-secret" + w.Header().Set("Content-Type", "application/json") + if ok { + _, _ = io.WriteString(w, `{"success":true}`) + return + } + _, _ = io.WriteString(w, `{"success":false,"error-codes":["invalid-input-response"]}`) + })) + defer fake.Close() + files := func() map[string]string { return map[string]string{"http.yaml": transportBodyLimits} } + post := func(h http.Handler, path, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec + } + + // A configured verifier (published, pointed at the fake). + app := transportApp(t, "development", files()) + verifier := bouncer.NewTurnstile("test-secret", false) + verifier.Endpoint = fake.URL + if err := app.Publish[*bouncer.Turnstile](verifier); err != nil { + t.Fatal(err) + } + h, err := Assemble(app, []party.Plugin{turnstileRoutes("turnstile:closed", "turnstile:open")}) + if err != nil { + t.Fatal(err) + } + body := `{"turnstile_token":"ok","name":"x"}` + if rec := post(h, "/guest", body); rec.Code != http.StatusOK || rec.Body.String() != body { + t.Fatalf("guest with a good token = %d %q, want 200 and the full body", rec.Code, rec.Body.String()) + } + if rec := post(h, "/guest", `{"turnstile_token":"bad","name":"x"}`); rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("guest with a bad token = %d, want 422", rec.Code) + } + + // Open mode with no secret passes without a siteverify call. + before := calls.Load() + h, err = Assemble(transportApp(t, "development", files()), []party.Plugin{turnstileRoutes("turnstile:closed", "turnstile:open")}) + if err != nil { + t.Fatal(err) + } + if rec := post(h, "/register", `{"name":"x"}`); rec.Code != http.StatusOK { + t.Fatalf("open mode without a secret = %d, want 200", rec.Code) + } + if calls.Load() != before { + t.Fatal("open mode without a secret called siteverify") + } + + // Closed mode with no secret in production is unavailable. + h, err = Assemble(transportApp(t, "production", files()), []party.Plugin{turnstileRoutes("turnstile:closed", "turnstile:open")}) + if err != nil { + t.Fatal(err) + } + if rec := post(h, "/guest", body); rec.Code != http.StatusServiceUnavailable { + t.Fatalf("closed mode unconfigured in production = %d, want 503", rec.Code) + } + + if _, err := BuildRouter(transportApp(t, "development", files()), []party.Plugin{turnstileRoutes("turnstile:maybe", "turnstile:open")}); err == nil || !strings.Contains(err.Error(), "turnstile:maybe") { + t.Fatalf("turnstile:maybe error = %v", err) + } +}