arx

package module
v0.5.0 Latest Latest
Warning

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

Go to latest
Published: Apr 4, 2026 License: MIT Imports: 13 Imported by: 0

README

ARX

A Go web framework that ships with a hardened security layer by default.

Go Reference Go Version


The problem

Most Go frameworks treat security as an afterthought — you get a bare router and a list of third-party middleware packages to assemble yourself. Skip one, configure one wrong, and your application ships with a gap. ARX inverts this: the secure path is the default path, and opting out requires deliberate configuration.

What ARX provides

  • Routing built on net/http (Go 1.22+), fully stdlib-compatible
  • Type-safe request binding via Go generics — no reflect calls from the framework itself
  • Swappable serializer — stdlib encoding/json by default, replaceable at initialization
  • Security headers on every response out of the box (HSTS, CSP, X-Frame-Options, Referrer-Policy, Permissions-Policy)
  • WAF layer that scans query parameters and request bodies for SQLi, XSS, and path traversal patterns before they reach your handler
  • Route groups with prefix and middleware inheritance
  • Graceful shutdown on SIGINT/SIGTERM

Requirements

Go 1.22 or later. No external runtime dependencies.

Installation

go get github.com/arx-go/arx

Quick start

package main

import (
    "log/slog"
    "net/http"

    "github.com/arx-go/arx"
    "github.com/arx-go/arx/shield"
)

func main() {
    app := arx.New(arx.Config{
        Addr: ":8080",
    })

    // Security headers + WAF on every route.
    app.Use(shield.Default()...)

    app.GET("/ping", func(c *arx.Context) error {
        return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
    })

    if err := app.Run(); err != nil {
        slog.Error("server stopped", "error", err)
    }
}

Routing

ARX uses Go 1.22's net/http.ServeMux under the hood, so route patterns follow the same syntax.

app.GET("/users/{id}", getUser)
app.POST("/users", createUser)
app.PUT("/users/{id}", updateUser)
app.DELETE("/users/{id}", deleteUser)

Path parameters are read via c.PathParam:

func getUser(c *arx.Context) error {
    id := c.PathParam("id")
    return c.JSON(http.StatusOK, map[string]string{"id": id})
}

Request binding

Use the generic Bind[T] function for type-safe decoding. ARX does not call reflect to make this work:

type CreateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

func createUser(c *arx.Context) error {
    req, err := arx.Bind[CreateUserRequest](c)
    if err != nil {
        return arx.ErrBadRequest(err.Error())
    }
    // req is typed as CreateUserRequest, no casting needed.
    return c.JSON(http.StatusCreated, req)
}

Middleware

Middleware follows the standard func(Handler) Handler pattern and can be applied globally, to a group, or to a single route.

func requireAuth(next arx.Handler) arx.Handler {
    return func(c *arx.Context) error {
        token := c.Request().Header.Get("Authorization")
        if token == "" {
            return arx.ErrUnauthorized()
        }
        return next(c)
    }
}

// Global
app.Use(requireAuth)

// Group-scoped
api := app.Group("/api/v1", requireAuth)
api.GET("/profile", getProfile)

// Route-scoped
app.GET("/admin", dashboard, requireAuth)

Route groups

Groups inherit their parent's middleware and prefix. Nesting is supported.

api := app.Group("/api/v1")
api.Use(requireAuth)

users := api.Group("/users")
users.GET("/{id}", getUser)
users.POST("", createUser)

// Resolves to: GET /api/v1/users/{id}, POST /api/v1/users

The shield package

shield is ARX's built-in security middleware layer. It ships two components:

Security headers
app.Use(shield.Headers(shield.DefaultHeadersConfig()))

Default headers set on every response:

Header Default value
Strict-Transport-Security max-age=63072000; includeSubDomains
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
Content-Security-Policy default-src 'self'
Permissions-Policy geolocation=(), microphone=(), camera=()
X-Permitted-Cross-Domain-Policies none

Customize via HeadersConfig:

cfg := shield.DefaultHeadersConfig()
cfg.ContentSecurityPolicy = "default-src 'self'; img-src *"
cfg.HSTSPreload = true

app.Use(shield.Headers(cfg))
WAF

The WAF scans query parameters and request bodies for common injection patterns before the request reaches your handler. All regex patterns are compiled once at startup — zero per-request overhead.

app.Use(shield.WAF(shield.DefaultWAFConfig()))

Detected threat classes: SQL injection, cross-site scripting, path traversal.

Log-only mode for gradual rollout:

cfg := shield.WAFConfig{
    MaxBodyBytes:  65536,
    BlockOnDetect: false, // detect and report, do not block
    OnThreat: func(r *http.Request, kind shield.ThreatKind, input string) {
        slog.Warn("waf detection", "kind", kind, "path", r.URL.Path)
    },
}
app.Use(shield.WAF(cfg))

Apply both at once with shield.Default():

app.Use(shield.Default()...)

Session management

ARX ships server-side sessions with hardened cookie defaults. Session data never leaves the server — only a signed, opaque ID is stored in the cookie.

import (
    "os"
    "log"
    "net/http"

    "github.com/arx-go/arx"
    "github.com/arx-go/arx/session"
)

func main() {
    store := session.NewMemoryStore()

    sm, err := session.New(
        []byte(os.Getenv("SESSION_SECRET")), // min 32 bytes, from env
        store,
        session.DefaultConfig(),
    )
    if err != nil {
        log.Fatal(err)
    }

    app := arx.New(arx.Config{Addr: ":8080"})
    app.Use(sm.Middleware())

    app.POST("/login", func(c *arx.Context) error {
        sess := session.FromRequest(c.Request())
        sess.Set("user_id", 42)
        // Rotate the session ID after login to prevent session fixation.
        if err := sess.Rotate(); err != nil {
            return arx.ErrInternalServer()
        }
        return c.Status(http.StatusOK)
    })

    app.POST("/logout", func(c *arx.Context) error {
        session.FromRequest(c.Request()).Invalidate()
        return c.Status(http.StatusOK)
    })

    if err := app.Run(); err != nil {
        log.Fatal(err)
    }
}

Cookie defaults set by session.DefaultConfig():

Attribute Default
HttpOnly true
Secure true
SameSite Strict
MaxAge 24 hours
Path /

To use a different backend (Redis, Postgres), implement the session.Store interface:

type Store interface {
    Get(ctx context.Context, id string) (*Session, error)
    Save(ctx context.Context, s *Session, ttl time.Duration) error
    Delete(ctx context.Context, id string) error
}

Rate limiting

ARX ships a token bucket rate limiter in the ratelimit package. Tokens refill continuously — no thundering herd at window boundaries, and the first burst of N requests always passes.

import (
    "time"
    "github.com/arx-go/arx/ratelimit"
)

// Global: 100 req/min per IP on every endpoint.
app.Use(ratelimit.PerIP(100, time.Minute))

// Route-level: tighter limit on sensitive endpoints.
app.POST("/login", handler, ratelimit.PerIP(5, time.Minute))
app.POST("/password-reset", handler, ratelimit.PerIP(3, time.Minute))

Custom key extraction (per authenticated user, falls back to IP):

app.Use(ratelimit.New(ratelimit.Config{
    Rate:   1000,
    Window: time.Hour,
    KeyFunc: func(r *http.Request) string {
        sess := session.FromRequest(r)
        if sess != nil {
            if id, ok := sess.Get("user_id"); ok {
                return fmt.Sprintf("user:%v", id)
            }
        }
        return ratelimit.IPKey(r)
    },
    OnLimitReached: func(r *http.Request) {
        slog.Warn("rate limit exceeded", "path", r.URL.Path, "ip", ratelimit.IPKey(r))
    },
}).Middleware())

When a request is denied, ARX responds with 429 Too Many Requests and a Retry-After header containing the number of seconds until the next token is available.

Security note: IPKey reads the first entry from X-Forwarded-For. Behind a trusted reverse proxy this is correct. Without a proxy, clients can spoof this header — set a custom KeyFunc that uses r.RemoteAddr exclusively if you are not behind a proxy.

CSRF protection

ARX ships synchronizer token CSRF protection in the csrf package. The token is stored server-side in the session and injected into every safe-method response (GET, HEAD, OPTIONS, TRACE) as an X-CSRF-Token header. State-changing requests (POST, PUT, PATCH, DELETE) must include the token — either in the X-CSRF-Token header or the csrf_token form field.

csrf.Protect() must run after session.Middleware() in the chain.

import "github.com/arx-go/arx/csrf"

app.Use(sm.Middleware())  // session must come first
app.Use(csrf.Protect())   // then CSRF

// SPA / JSON API: read X-CSRF-Token from any GET response, send it back on mutations.
// fetch('/api/data', {
//   method: 'POST',
//   headers: { 'X-CSRF-Token': token },
// })

// Server-rendered forms (HTMX / Templ):
// csrf.TemplateField(r) → <input type="hidden" name="csrf_token" value="...">

Read the token from inside a handler:

app.GET("/", func(c *arx.Context) error {
    token := csrf.Token(c.Request())
    return c.JSON(http.StatusOK, map[string]string{"csrf_token": token})
})

Custom error handling (e.g., custom error message or redirect):

app.Use(csrf.New(csrf.Config{
    ErrorHandler: func(c *arx.Context) error {
        return arx.NewHTTPError(http.StatusForbidden, "invalid or missing CSRF token")
    },
}).Middleware())

Safe methods (GET, HEAD, OPTIONS, TRACE) are never blocked — CSRF is only validated on state-changing methods.

Typed generics

Typed path parameters

arx.Param[T] converts a path parameter to a concrete type at the call site. No strconv boilerplate, no silent string return on a typo:

app.GET("/users/{id}", func(c *arx.Context) error {
    id, err := arx.Param[int](c, "id")
    if err != nil {
        return arx.ErrBadRequest(err.Error())
    }
    return c.JSON(http.StatusOK, map[string]int{"id": id})
})

Supported types: int, int64, float64, string, bool. The error message always includes the parameter name for easy debugging.

Typed context values

arx.Key[T] provides compile-time safe context values. Keys carry their value type — arx.Get returns T directly, no type assertion needed. Two keys with the same T are still distinct:

// Declare package-level keys once.
var UserKey = arx.NewKey[*User]()
var TenantKey = arx.NewKey[*Tenant]()

// Set in middleware:
func authMiddleware(next arx.Handler) arx.Handler {
    return func(c *arx.Context) error {
        user := loadUser(c.Request())
        arx.Set(c, UserKey, user) // stores *User
        return next(c)
    }
}

// Get in a handler — no type assertion, compiler-enforced:
func getProfile(c *arx.Context) error {
    user, ok := arx.Get(c, UserKey) // user is *User
    if !ok {
        return arx.ErrUnauthorized()
    }
    return c.JSON(http.StatusOK, user)
}
HTML responses

For server-rendered HTML (HTMX, plain templates):

app.GET("/page", func(c *arx.Context) error {
    return c.HTML(http.StatusOK, "<h1>Hello</h1>")
})

Error handling

Return an *arx.HTTPError from a handler to send a specific status code:

func getUser(c *arx.Context) error {
    user, err := db.Find(c.PathParam("id"))
    if err != nil {
        return arx.ErrNotFound()
    }
    return c.JSON(http.StatusOK, user)
}

Available constructors: ErrBadRequest, ErrUnauthorized, ErrForbidden, ErrNotFound, ErrMethodNotAllowed, ErrInternalServer.

Custom errors:

return arx.NewHTTPError(http.StatusUnprocessableEntity, "validation failed")

Any error that is not an *HTTPError is logged server-side and returns a generic 500 to the client — internal details are never leaked.

Override the error handler entirely:

app := arx.New(arx.Config{
    Addr: ":8080",
    ErrorHandler: func(c *arx.Context, err error) {
        // your centralized error logic
    },
})

Custom serializer

Swap encoding/json for any library at initialization:

app := arx.New(arx.Config{
    Addr:       ":8080",
    Serializer: myfasterserializer.New(),
})

The Serializer interface:

type Serializer interface {
    Encode(w io.Writer, v any) error
    Decode(r io.Reader, v any) error
    ContentType() string
}

Configuration reference

arx.New(arx.Config{
    Addr:           ":8080",          // required
    ReadTimeout:    5 * time.Second,  // default: 5s
    WriteTimeout:   10 * time.Second, // default: 10s
    IdleTimeout:    60 * time.Second, // default: 60s
    MaxHeaderBytes: 1 << 20,          // default: 1MB
    Serializer:     nil,              // default: encoding/json
    ErrorHandler:   nil,              // default: log + 500
    Logger:         nil,              // default: slog.Default()
})

Design principles

Secure by default, configurable by choice. Security is not a plugin to enable. Every endpoint is hardened before a single line of handler code runs.

The standard library is sacred. ARX extends net/http, never replaces it. Any stdlib http.Handler works alongside ARX handlers.

No magic at runtime. No reflection from the framework. No init() functions. No global state beyond compiled regex patterns. Everything is wired explicitly.

Fail loud at the boundary. Invalid configuration and bad input are caught at initialization or at the request edge — not buried in application logic.

Explicit over implicit. DI is explicit. Configuration is explicit. Side effects are explicit. If something happens, you can trace exactly why.

Roadmap

Version Planned additions
v0.1.0 Core router, middleware, security headers, WAF
v0.2.0 Server-side session management (signed cookies, server-side store, session fixation protection)
v0.3.0 Rate limiting (token bucket, per-IP and per-route)
v0.4.0 CSRF protection
v0.5.0 Typed generics: Param[T], Key[T], c.HTML()
v0.6.0 SSE and AI streaming endpoint helpers
v1.0.0 Stable API, production-ready

Contributing

This project is in early development. If you find a bug or want to propose a feature, open an issue first before submitting a pull request.

All code must pass go test ./... and go vet ./.... No external test dependencies.

License

MIT. See LICENSE.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Bind

func Bind[T any](c *Context) (T, error)

Bind decodes the request body into a value of type T and returns it. This is the preferred binding method — it is type-safe and ARX itself does not call the reflect package to make it work.

Example:

type CreateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

req, err := arx.Bind[CreateUserRequest](c)

func Get added in v0.5.0

func Get[T any](c *Context, key Key[T]) (T, bool)

Get retrieves the value stored under key from the request context. Returns the zero value of T and false if the key is not present. The return type is T — no type assertion required at the call site.

func Param added in v0.5.0

func Param[T paramType](c *Context, name string) (T, error)

Param reads a named path parameter and converts it to type T. Supported types: int, int64, float64, string, bool. Returns an error if the parameter cannot be parsed as T. Error messages include the parameter name for easy debugging.

func Set added in v0.5.0

func Set[T any](c *Context, key Key[T], value T)

Set stores value in the request context under key. Downstream handlers and middleware can retrieve it with Get. The context update is visible to any code that runs after Set returns.

Types

type App

type App struct {
	*Router
	// contains filtered or unexported fields
}

App is the top-level ARX application. It owns the router, HTTP server, and the error handler. Create one with New(), add routes, then call Run().

func New

func New(cfg Config) *App

New creates an App from the given Config, applying defaults for any zero-value fields.

func (*App) Run

func (a *App) Run() error

Run starts the HTTP server and blocks until SIGINT or SIGTERM is received. On signal, it initiates a graceful shutdown: active requests get 30 seconds to finish before the server is forcibly closed.

type Config

type Config struct {
	// Addr is the TCP address to listen on, e.g. ":8080" or "127.0.0.1:9000".
	// There is no default — binding to the wrong address causes silent ops headaches.
	Addr string

	// ReadTimeout caps how long the server waits for a full request (headers + body).
	// Protects against slow-read attacks like Slowloris. Default: 5s.
	ReadTimeout time.Duration

	// WriteTimeout caps how long the server waits to write the full response.
	// For streaming endpoints this should be higher than ReadTimeout. Default: 10s.
	WriteTimeout time.Duration

	// IdleTimeout caps how long an idle keep-alive connection is kept open. Default: 60s.
	IdleTimeout time.Duration

	// MaxHeaderBytes limits the size of request headers in bytes. Default: 1MB (stdlib default).
	MaxHeaderBytes int

	// Serializer overrides the default encoding/json backend.
	// Leave nil to use the stdlib JSON serializer.
	Serializer Serializer

	// ErrorHandler is called when a handler returns a non-nil error.
	// If nil, the default handler logs the error and sends a 500 or the HTTPError's code.
	ErrorHandler func(*Context, error)

	// Logger is used for framework-level messages (startup, shutdown, handler errors).
	// Defaults to slog.Default().
	Logger *slog.Logger
}

Config holds everything needed to initialize an ARX app. All fields have sensible defaults except Addr, which you must provide.

type Context

type Context struct {
	// contains filtered or unexported fields
}

Context is the per-request object passed to every handler. It wraps stdlib's ResponseWriter and Request, adds typed response helpers, and carries the app-level Serializer so handlers never need to import encoding/json (or whatever the configured backend is) directly.

func (*Context) Bind

func (c *Context) Bind(v any) error

Bind decodes the request body into v using the configured Serializer. Prefer the generic Bind[T] function over this method when you want compile-time type safety without a type assertion on the caller's side.

func (*Context) HTML added in v0.5.0

func (c *Context) HTML(status int, html string) error

HTML sends an HTML response with the given status code. Content-Type is set to "text/html; charset=utf-8". The html string is written as-is — callers are responsible for safe content. When using Templ, render to a bytes.Buffer first: component.Render(ctx, &buf), then c.HTML(200, buf.String()).

func (*Context) JSON

func (c *Context) JSON(status int, v any) error

JSON encodes v as JSON and writes it with the given HTTP status code. It uses whatever Serializer was configured on the app at startup.

func (*Context) PathParam

func (c *Context) PathParam(name string) string

PathParam returns a URL path parameter by name. For a route registered as /users/{id}, call c.PathParam("id"). Returns an empty string if the name was not in the route pattern.

func (*Context) QueryParam

func (c *Context) QueryParam(name string) string

QueryParam returns the first value for a URL query parameter. Returns an empty string if the key is absent.

func (*Context) ReplaceWriter added in v0.2.0

func (c *Context) ReplaceWriter(w http.ResponseWriter)

ReplaceWriter swaps the underlying ResponseWriter. Middleware that needs to buffer the response (to set headers like Set-Cookie after the handler returns but before the bytes reach the client) should call this before invoking the next handler.

func (*Context) Request

func (c *Context) Request() *http.Request

Request returns the underlying *http.Request.

func (*Context) Response

func (c *Context) Response() http.ResponseWriter

Response returns the underlying http.ResponseWriter. Use this when you need direct access to headers or want to stream a response.

func (*Context) Status

func (c *Context) Status(code int) error

Status sends a response with only a status code and no body. Useful for 204 No Content, 202 Accepted, and similar responses.

func (*Context) String

func (c *Context) String(status int, s string) error

String sends a plain-text response with the given status code.

type Group

type Group struct {
	// contains filtered or unexported fields
}

Group is a set of routes that share a URL prefix and a middleware chain. Create one with Router.Group() or App.Group().

Groups can be nested — calling Group on a Group produces a child group whose prefix and middleware stack are inherited from the parent.

func (*Group) DELETE

func (g *Group) DELETE(path string, h Handler, mw ...Middleware)

func (*Group) GET

func (g *Group) GET(path string, h Handler, mw ...Middleware)

func (*Group) Group

func (g *Group) Group(prefix string, mw ...Middleware) *Group

Group creates a nested child group with an appended prefix and additional middleware. The child inherits all middleware from its parent.

func (*Group) PATCH

func (g *Group) PATCH(path string, h Handler, mw ...Middleware)

func (*Group) POST

func (g *Group) POST(path string, h Handler, mw ...Middleware)

func (*Group) PUT

func (g *Group) PUT(path string, h Handler, mw ...Middleware)

func (*Group) Use

func (g *Group) Use(mw ...Middleware)

Use adds middleware that applies to all routes registered on this group.

type HTTPError

type HTTPError struct {
	Code    int
	Message string
}

HTTPError is returned from handlers when you want the framework to send a specific HTTP status code. The default error handler knows how to unwrap it and respond correctly without leaking stack traces to the client.

func ErrBadRequest

func ErrBadRequest(msg string) *HTTPError

func ErrForbidden

func ErrForbidden() *HTTPError

func ErrInternalServer

func ErrInternalServer() *HTTPError

func ErrMethodNotAllowed

func ErrMethodNotAllowed() *HTTPError

func ErrNotFound

func ErrNotFound() *HTTPError

func ErrUnauthorized

func ErrUnauthorized() *HTTPError

func NewHTTPError

func NewHTTPError(code int, msg string) *HTTPError

NewHTTPError creates an HTTPError with the given status code and message.

func (*HTTPError) Error

func (e *HTTPError) Error() string

type Handler

type Handler func(*Context) error

Handler is the function signature every ARX route handler must implement. Returning an error lets the framework centralize error-to-response logic rather than scattering w.WriteHeader calls across every handler.

type Key added in v0.5.0

type Key[T any] struct {
	// contains filtered or unexported fields
}

Key is a typed context key. Create one with NewKey[T](). Keys are unique — no two NewKey calls produce the same key. The type parameter T determines what value type this key stores and retrieves, making Get return T directly without any type assertion.

func NewKey added in v0.5.0

func NewKey[T any]() Key[T]

NewKey creates a new unique typed context key. Declare keys as package-level variables so they are created once:

var UserKey = arx.NewKey[*User]()

type Middleware

type Middleware func(Handler) Handler

Middleware wraps a Handler to inject behavior before or after it runs. The standard contract: call next(c) to continue down the chain, skip it to short-circuit (e.g., auth rejection).

type Router

type Router struct {
	// contains filtered or unexported fields
}

Router wraps net/http.ServeMux with ARX's handler signature, middleware support, and route grouping. It does not replace the stdlib — it composes on top of it, meaning any stdlib-compatible handler can still be registered via ServeHTTP.

func (*Router) DELETE

func (r *Router) DELETE(path string, h Handler, mw ...Middleware)

func (*Router) GET

func (r *Router) GET(path string, h Handler, mw ...Middleware)

func (*Router) Group

func (r *Router) Group(prefix string, mw ...Middleware) *Group

Group returns a RouteGroup that prefixes all its routes with the given path and applies the given middleware to all of them.

func (*Router) PATCH

func (r *Router) PATCH(path string, h Handler, mw ...Middleware)

func (*Router) POST

func (r *Router) POST(path string, h Handler, mw ...Middleware)

func (*Router) PUT

func (r *Router) PUT(path string, h Handler, mw ...Middleware)

func (*Router) ServeHTTP

func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request)

ServeHTTP makes Router implement http.Handler so it can plug into any stdlib-compatible server, test helper, or reverse proxy setup.

func (*Router) Use

func (r *Router) Use(mw ...Middleware)

Use appends middleware to the router's global chain. All routes registered after this call will have these middleware applied. Call Use before registering routes, not after.

type Serializer

type Serializer interface {
	Encode(w io.Writer, v any) error
	Decode(r io.Reader, v any) error
	// ContentType returns the MIME type this serializer produces,
	// used to set the Content-Type response header automatically.
	ContentType() string
}

Serializer handles encoding responses and decoding request bodies. The default implementation uses encoding/json from stdlib. Swap it out at app initialization if you need better performance (e.g., goccy/go-json) or a zero-reflection code-generated marshaler in v2.

func DefaultSerializer

func DefaultSerializer() Serializer

DefaultSerializer returns the stdlib JSON serializer. Pass a different implementation to Config.Serializer when initializing the app if you need a faster or code-generated alternative.

Directories

Path Synopsis
Package shield provides the "Citadel" security middleware stack for ARX.
Package shield provides the "Citadel" security middleware stack for ARX.

Jump to

Keyboard shortcuts

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