moat

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 20, 2026 License: MIT Imports: 0 Imported by: 0

README

moat

Composable HTTP security middleware for Go: rate limiting, CSRF protection, secure response headers, input validation, and path-traversal-safe file access.

The core module has zero external dependencies — standard library only.

key, err := csrf.GenerateSecret() // 32 bytes; store it, do not regenerate per process
if err != nil {
    log.Fatal(err)
}

chain, err := preset.API(preset.Config{
    CSRFSecret:      key,
    RateLimitBurst:  20,
    RateLimitPerSec: 5,
})
if err != nil {
    log.Fatal(err)
}

http.ListenAndServe(":8080", chain.Then(mux))

Status: pre-1.0. The API may change between minor versions. This library has not been independently audited — see Security.


Why this exists

Most Go security middleware either pulls in a large third-party stack or is copy-pasted from a forum answer without much thought for the edge cases: symlink-based traversal, IP-spoofed rate-limit bypass, timing attacks on token comparison, unbounded body reads, TOCTOU races.

This project does that plumbing carefully and writes down the reasoning, so it can be inspected rather than trusted — which is what you should do with any security library, including this one. The full design record, including the audit that reshaped several of these APIs, is in doc/DESIGN.md.

Design principles

  • Zero external dependencies in the core. Minimizes supply-chain attack surface and keeps the implementation auditable in one sitting. The optional Redis backend lives in a separate module, so it never appears in your go.sum unless you ask for it.
  • Secure by default, opt out explicitly. Strict CSP, DENY framing, Secure cookies, fail-closed rate limiting. Loosening a default is visible in code review; forgetting to configure something leaves you on the safe path.
  • Fail loudly rather than silently degrade. A preset that cannot build a safe chain returns an error instead of quietly omitting a protection.
  • func(http.Handler) http.Handler everywhere. Plain decorators — works with net/http, chi, gorilla/mux out of the box, and with Gin/Echo via small adapters (compiled and tested, in examples/).
  • Allow-list over block-list. Reject input that doesn't match a known-good shape rather than trying to strip what looks dangerous.
  • The library never logs or prints. Errors surface through hooks and return values; your application owns observability.

Install

go get github.com/JonasBorgesLM/moat

Optional Redis-backed distributed rate limiting (separate module, pulls in go-redis):

go get github.com/JonasBorgesLM/moat/redisstore

The core module requires Go 1.24+ — the floor set by os.Root, which pathguard is built on and which landed in 1.24. The core has no external dependencies, so nothing pushes it higher.

The redisstore module declares Go 1.25, forced there by its own dependencies (testcontainers-go requires 1.25); examples/ likewise, via gin. Neither floor affects you unless you import those modules.

Packages

Package What it does
middleware Chain — flat, order-explicit middleware composition
preset helmet.js-style bundles for one-call adoption
ratelimit Token bucket rate limiting, pluggable storage backend
csrf Stateless CSRF protection (signed double-submit cookie + Origin check)
secureheaders CSP, HSTS, X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy
validate Composable field rules + body size / content-type guards
sanitize Plain-text normalization (control chars, whitespace)
pathguard Traversal- and TOCTOU-safe file resolution and serving
realip Client IP behind trusted proxies, parsed right-to-left
secret Value — bytes that cannot be printed, logged or marshalled by accident
redisstore (separate module) Redis-backed ratelimit.Store, atomic via Lua

Every package is independently importable — take only what you need.


Usage

Quick start with a preset

Presets are the fastest safe starting point, in the spirit of helmet.js: opinionated defaults, per-component overrides, and no pretense of being a complete security strategy.

raw, err := base64.StdEncoding.DecodeString(os.Getenv("CSRF_SECRET"))
if err != nil {
    log.Fatal(err)
}
key := secret.New(raw) // wrap it so it cannot reach a log line

chain, err := preset.API(preset.Config{
    CSRFSecret:      key, // a secret.Value of 32 or more random bytes
    RateLimitBurst:  20,
    RateLimitPerSec: 5,
    TrustedProxies:  []string{"10.0.0.0/8"}, // your balancer, or DirectlyExposed: true
})

The order preset.API fixes is: security headers, rate limit, body size limit, CSRF. Headers run outermost so that every response carries them, including a 429 from the rate limiter or a 403 from CSRF — an error response is still a response, and is exactly as capable of being framed or rendered without a CSP as a 200 is. The body limit must precede CSRF because CSRF parses form-encoded bodies, and doing that before a bound is in place is the ordering bug that makes an oversized form a problem.

preset.API returns an error rather than build a chain whose protections are missing or ambiguous — a missing CSRF secret, missing rate limits, or a rate limiter that has not been told where the client address comes from. It is not a general guarantee that nothing can be misconfigured; it is a list of the cases that are checked, and it grew by one after TrustedProxies was found missing from it.

TrustedProxies is required whenever rate limiting is on, because a limiter is only as good as the identity it counts. Behind a load balancer r.RemoteAddr is the balancer for every client, so without this the whole service shares one bucket and any single client can hold everyone else at the limit — a denial of service that looks exactly like ordinary throttling. If nothing fronts the server, say so instead:

chain, err := preset.API(preset.Config{
    CSRFSecret:      key,
    RateLimitBurst:  20,
    RateLimitPerSec: 5,
    DirectlyExposed: true, // no proxy in front; RemoteAddr is the client
})

Skipping CSRF is possible, but only by saying so:

chain, err := preset.API(preset.Config{
    DisableCSRF:     true, // deliberate, greppable, reviewable
    RateLimitBurst:  20,
    RateLimitPerSec: 5,
})

Headers-only, the true parameter-free subset:

handler := preset.Headers().Then(mux)
Explicit composition

When you want to own the pipeline, compose it flat and in order. Nested wrapping is where ordering bugs hide — and an ordering bug here is a security bug, not a style issue:

chain := middleware.New(
    secureheaders.Middleware(),       // 1. outermost, so every response gets these — even the rejections below
    limiter.Middleware,               // 2. turn away abusive clients before spending more work
    validate.MaxBodyBytes(1<<20),     // 3. BEFORE anything that reads the body
    protector.Middleware,             // 4. CSRF can now read a bounded body
)
handler := chain.Then(mux)

Chain does not enforce order — that is preset's job. Chain is the escape hatch for people who know what they're doing.

Rate limiting
limiter := ratelimit.New(20, 5) // burst of 20, refills at 5 tokens/sec
handler := limiter.Middleware(mux)

Token bucket, not a fixed window — a fixed window allows up to 2× the intended rate across a boundary.

The default key function uses r.RemoteAddr, not X-Forwarded-For. That header is client-controlled and trivially spoofable; trusting it blindly turns rate limiting into a no-op.

RemoteAddr is not a safe default either, only a safe-failing one: behind a proxy it is the proxy for every client, so all clients share one bucket. Both directions are wrong, which is why preset.API refuses to guess. Constructing a limiter directly, use realip to derive the key:

extractor, err := realip.New([]string{"10.0.0.0/8"}) // your proxies, not your clients
if err != nil {
    log.Fatal(err)
}
limiter := ratelimit.New(20, 5, ratelimit.WithKeyFunc(extractor.KeyFunc()))

Distributed limiting across instances:

store, err := redisstore.New(redisClient)
limiter := ratelimit.New(20, 5, ratelimit.WithStore(store))

The Redis store is atomic (Lua, single round trip) and fails closed by default: if Redis is unreachable, requests are rejected rather than waved through. Switch with ratelimit.WithFailureMode(ratelimit.FailOpen) if availability matters more than enforcement for your use case.

The limiter is also callable directly, for the identity you only know after authenticating — an account, an API key, a tenant — which the middleware cannot derive from the request:

if !limiter.Allow(r.Context(), account.ID) {
    writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
    return
}

AllowN charges a weighted cost, so an expensive operation can pay more than a cheap one. The deduction is all-or-nothing and atomic — including on Redis, where the cost goes into the same Lua call rather than a loop:

if !limiter.AllowN(r.Context(), account.ID, report.Cost()) { ... }

Both apply the configured failure mode and report store errors to WithOnError. Use Take/TakeN instead when you want the Result and the error and intend to decide yourself. The middleware is a thin consumer of the same code path, so an in-handler call and a middleware call charge one bucket, not two.

CSRF
protector, err := csrf.New(key) // a secret.Value; 32+ random bytes, stable across deploys
handler := protector.Middleware(mux)

// in a handler rendering a form:
token, ok := csrf.Token(r)

Stateless signed double-submit cookie — no session store required — with Origin/Referer validation as defense in depth. The cookie is HttpOnly, Secure, SameSite=Lax, and carries the __Host- prefix, which browsers enforce as "Secure, Path=/, no Domain" — so no subdomain can overwrite it. Token returns (string, bool) rather than a silent empty string: an empty token silently breaking every POST is exactly the situation that gets CSRF disabled "to fix it".

Rotate the token when the session's privilege level changes — on login above all. An attacker who managed to plant a CSRF cookie in the victim's browser can compute the matching token; rotating overwrites the planted value, so the victim's browser starts sending one the attacker never saw:

if _, err := protector.Rotate(w, r); err != nil {
    // The CSPRNG failed. Abort the login rather than completing it with a
    // possibly attacker-known token still valid.
    return
}

csrf.Token(r) reports the new token immediately afterwards, so a handler that logs the user in and then renders a page stays correct.

Call it before writing anything to the response — it sets a cookie, and net/http discards a Set-Cookie added after the headers are sent. That mistake is detected rather than left to bite you: a late call returns csrf.ErrHeadersAlreadySent and changes nothing, instead of reporting success while the browser keeps the old cookie.

Note what it does not do: it rotates the CSRF secret, not your session identifier. Defeating session fixation also requires issuing a new session ID at login, which is your session layer's job.

Need a secret to get started?

key, err := csrf.GenerateSecret() // a secret.Value; never prints it

It returns a secret.Value, not a []byte, and csrf.New and preset.Config.CSRFSecret take one too. The wrapper is there because a signing key in a plain []byte is one %+v away from a log aggregator — and a Config struct is exactly the kind of thing that gets logged at startup. Printing, marshalling or slog-ing a Value yields [REDACTED]; the bytes come out only through an explicit .Bytes() call that a reviewer can see. Wrap bytes from your own secret store with secret.New.

Or from a shell, where printing is your explicit intent rather than a library's side effect:

go run github.com/JonasBorgesLM/moat/cmd/moat-secret
Secure headers
handler := secureheaders.Middleware(
    secureheaders.WithCSP("default-src 'self' https://cdn.example.com"),
)(mux)

Defaults: X-Frame-Options: DENY, nosniff, one-year HSTS with includeSubDomains, Referrer-Policy: strict-origin-when-cross-origin, a locked-down Permissions-Policy, and this CSP:

default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'

The last three directives are there because none of them inherits from default-src: dropping them silently removes clickjacking, <base>-hijacking and form-exfiltration protection. Keep them when you relax the policy.

HSTS preload is off by default — it is effectively irreversible. Setting max-age to 0 omits the header rather than sending max-age=0, which would instruct browsers to forget an existing policy.

Real pages have inline scripts, and the strict default CSP forbids them. The usual escape is 'unsafe-inline', which permits every inline script including an injected one — that is not a weaker CSP, it is no script policy at all. Use a per-request nonce instead:

headers := secureheaders.Middleware(secureheaders.WithNonce())

// in the handler
nonce, ok := secureheaders.Nonce(r)   // (string, bool), like csrf.Token
<script nonce="{{.Nonce}}"> ... </script>

WithNonce adds script-src if the policy lacks it, so the default CSP goes from "scripts may be same-origin" to "scripts must carry this request's nonce". That is stricter, and it means <script src="/app.js"> needs the attribute too. If the CSPRNG fails the request is rejected rather than served with a policy that blocks its own scripts; customize that response with WithNonceErrorHandler.

For responses carrying a credential — an OAuth token endpoint, a session bootstrap — mark them uncacheable. RFC 6749 §5.1 requires it:

mux.Handle("POST /token", secureheaders.NoStore(tokenHandler))

It is a per-route function rather than a Middleware option on purpose: no-store across a whole chain would also stop your static assets from being cached.

On logout, ask the browser to discard what it already has — ending the session server-side does not empty localStorage or the back-button cache:

mux.Handle("POST /logout", secureheaders.ClearSiteData()(logoutHandler))

Defense in depth only: the header is ignored on plaintext origins and support is uneven, so it never replaces server-side invalidation. Note that "cookies" clears the whole registrable domain, so logging out of app.example.com also clears shop.example.com.

COOP, CORP and COEP are available but off by default:

secureheaders.Middleware(
    secureheaders.WithCrossOriginOpenerPolicy(secureheaders.COOPSameOriginAllowPopups),
    secureheaders.WithCrossOriginResourcePolicy(secureheaders.CORPSameOrigin),
)

They are opt-in because each breaks in a way your server cannot see — it keeps returning 200 while the browser discards the response. COOP same-origin breaks popup OAuth; CORP same-origin breaks any asset meant to be embedded cross-origin; COEP require-corp breaks essentially every third-party subresource and is only worth it if you need SharedArrayBuffer.

None of them becomes a default in v0.2.0. If COOPSameOriginAllowPopups or CORPSameOrigin is ever promoted it will be announced in the changelog as a behaviour change. COEP will never be a default.

Validation and sanitization
err := validate.Validate("email", input.Email,
    validate.Required(),
    validate.Email(),
    validate.MaxLen(254),
)

clean := sanitize.PlainText(input.DisplayName)

sanitize is not an HTML sanitizer and deliberately does not pretend to be one. The correct XSS defense is contextual output encoding — html/template does this for you. For fields that must never contain markup, reject rather than mangle: validate.NoHTMLTags().

A URL that a user supplies and you later render into an href needs its scheme constrained, or it is a script-execution primitive:

validate.URL()                    // http and https
validate.URL("https")             // https only

html/template will not save you here — inside an href, javascript:alert(1) is a valid URL, not something to escape. The rule also rejects control characters before parsing, because a browser strips \t, \n and \r from a URL before reading its scheme: java\tscript: navigates as javascript: while Go's net/url sees a different scheme entirely. That is the standard bypass for scheme allow-lists. Scheme-relative //evil.example.com is rejected too.

It does not check the host, so it is not on its own an open-redirect or SSRF defense — compare the parsed host against your own allow-list for those.

Path traversal
guard, err := pathguard.New("./public")
handler := http.StripPrefix("/files/", guard.FileServer())

Built on os.Root, which enforces containment at the syscall layer. This closes the TOCTOU window that resolve-then-open approaches leave open: an attacker who can write into the tree can swap a path component for a symlink between validation and the actual open. Manual checks cannot win that race.

Framework adapters

net/http, chi, and gorilla/mux work with no adapter. Gin and Echo need a few lines of glue — provided as compiled, tested code in examples/, not as snippets that rot:


Testing

This is a multi-module repository, and go test ./... at the root does not descend into redisstore/ or examples/. Test each module:

go test -race ./...                 # core: unit tests + examples, no infra needed
(cd redisstore && go test -race ./...)
(cd examples   && go test -race ./...)

Integration suites sit behind a build tag so the default run stays fast and needs no Docker:

go test -tags=integration -race ./integration/...   # full chain over a real TLS server
(cd redisstore && go test -tags=integration -race ./...)  # real Redis, via testcontainers

The Redis suite lives in the redisstore module rather than alongside the others, because testcontainers is a dependency and a test-only dependency in the core module would still land in every consumer's go.sum.

go test -fuzz=FuzzResolve -fuzztime=60s ./pathguard  # property: nothing escapes the root

Fuzz targets are treated as a security control, not decoration: "no input ever resolves outside root" is a stronger claim than any handful of ../../ test cases.

Security

This library has not been independently audited. It implements well-understood patterns carefully and documents its reasoning; that is not a guarantee.

Report vulnerabilities privately per SECURITY.md — not in public issues.

Known limitations, stated up front because they matter more than the feature list:

  • ratelimit.MemoryStore is per-process. Behind a load balancer, each instance enforces its own limit unless you use a shared store.
  • CSRF protection is defeated by same-origin XSS. If an attacker can run JS on your origin, they can read the token.
  • pathguard constrains filesystem paths; it does not sandbox process behavior.
  • None of this replaces TLS, secrets management, dependency scanning, or least-privilege infrastructure.

Contributing

See CONTRIBUTING.md. Changes affecting security properties need a matching update to doc/DESIGN.md — the reasoning is part of the deliverable.

License

MIT — see LICENSE.

Documentation

Overview

Package moat is the documentation root for a collection of composable HTTP security middleware for Go. It contains no code; every feature lives in a subpackage that can be imported on its own.

The core module depends on the Go standard library only. This is a supply-chain decision: a security library that pulls in transitive dependencies asks you to trust code neither you nor its author has read. The optional Redis-backed rate-limit store lives in a separate module (github.com/JonasBorgesLM/moat/redisstore) so that go-redis never appears in your go.sum unless you ask for it.

Packages

Conventions

Every middleware is a plain decorator, func(http.Handler) http.Handler, so it composes with net/http, chi, gorilla/mux and anything else following that convention without adapters.

The library never logs, prints, or panics in normal operation. Errors are returned or surfaced through hooks such as ratelimit.WithOnError, leaving observability entirely to the host application.

Defaults are the secure choice. Loosening one is always an explicit, greppable opt-in, so that forgetting to configure something leaves you on the safe path and relaxing a control is visible in code review.

Threat model

This library implements well-understood patterns carefully; it is not a WAF, not an IDS, and not a substitute for defense in depth. It has not been independently audited. The design record, including the audit that reshaped several of these APIs, is in doc/DESIGN.md. Per-package documentation states the specific attacks each component does and does not defend against.

Directories

Path Synopsis
cmd
moat-secret command
Command moat-secret prints a fresh CSRF secret.
Command moat-secret prints a fresh CSRF secret.
Package csrf provides stateless CSRF protection using a signed double-submit cookie, with Origin/Referer validation as defense in depth.
Package csrf provides stateless CSRF protection using a signed double-submit cookie, with Origin/Referer validation as defense in depth.
Package integration holds end-to-end tests that exercise the whole library through a real HTTP server and a real client.
Package integration holds end-to-end tests that exercise the whole library through a real HTTP server and a real client.
Package middleware provides Chain, a flat and order-explicit way to compose net/http middleware.
Package middleware provides Chain, a flat and order-explicit way to compose net/http middleware.
Package pathguard resolves user-supplied paths under a fixed root directory without letting them escape it.
Package pathguard resolves user-supplied paths under a fixed root directory without letting them escape it.
Package preset provides batteries-included middleware bundles, in the spirit of helmet.js: a sensible starting point in one call, with per-component overrides, and with manual composition remaining the serious path.
Package preset provides batteries-included middleware bundles, in the spirit of helmet.js: a sensible starting point in one call, with per-component overrides, and with manual composition remaining the serious path.
Package ratelimit provides token-bucket rate limiting as net/http middleware, with a pluggable storage backend.
Package ratelimit provides token-bucket rate limiting as net/http middleware, with a pluggable storage backend.
Package realip derives a client IP address from a request that arrived through one or more reverse proxies.
Package realip derives a client IP address from a request that arrived through one or more reverse proxies.
redisstore module
Package sanitize normalizes text that is expected to be plain.
Package sanitize normalizes text that is expected to be plain.
Package secret holds byte values that must not escape through formatting.
Package secret holds byte values that must not escape through formatting.
Package secureheaders sets the HTTP response headers that instruct a browser to enforce security policy on your behalf.
Package secureheaders sets the HTTP response headers that instruct a browser to enforce security policy on your behalf.
Package validate provides composable input validation rules and HTTP request guards.
Package validate provides composable input validation rules and HTTP request guards.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL