Documentation / Security

Security

What HyperChi protects by default, what you switch on yourself, and the few places where input must never be trusted: client IPs, WebSockets, HTML, uploads, and secrets.

What is on by default

app.AutoMiddleware() installs the stack most apps want, and it must run before the first route. It adds request IDs, the client address (see below), GetHead, then app.Middleware.Default(): panic recovery, request logging, htmx headers, CORS, and the security headers. Outside development it also adds gzip, which is safe for CSRF tokens because they are masked on every render.

Middleware.Security() always sends X-Content-Type-Options: nosniff, X-Frame-Options: DENY, and a Referrer-Policy. When ENVIRONMENT=production it adds a Content-Security-Policy, a Permissions-Policy that turns off camera, microphone, and location, and Strict-Transport-Security (one year, this host only, unless you change HSTS). The default CSP allows 'unsafe-eval' because hc-* directives and some htmx features compile expressions. An app that avoids them can set CSP_POLICY to something stricter. config.Minimal() does that for a templates-and-assets site: same-origin only, no inline script.

Two protections are not in the default stack, because they need a decision from you:

CORS sends no headers in production unless you set CORS_ORIGINS to one origin.

Wire it up

cfg := config.FromEnv() // ENVIRONMENT, TRUSTED_PROXIES, CSP_POLICY, HSTS, SECRET_KEY
cfg.Security.CSPPolicy = "default-src 'self'; frame-ancestors 'none'; form-action 'self'"

app, err := hyperchi.NewWithConfigE(cfg)
if err != nil {
    return err
}
app.AutoMiddleware()
app.Use(
    app.Middleware.RateLimit(120, time.Minute),
    app.Middleware.CSRF(),
)

Put the token in every form with c.CSRFToken(), and send it as a header from htmx. Each call returns a freshly masked value, so call it once per render:

app.Get("/contact", func(c *hyperchi.Context) error {
    return c.View("contact", hyperchi.H{"csrf": c.CSRFToken()})
})
<form method="post" action="/contact">
  <input type="hidden" name="csrf_token" value="{{.csrf}}">
  ...
</form>

<body hx-headers:inherited='{"X-CSRF-Token": "{{.csrf}}"}'>

For a plain form endpoint with no session, app.ProtectForm is a lighter option: it refuses cross-origin browser posts, caps the body (4 KiB by default), and requires a URL-encoded form. It is not authentication.

Client IPs

Never read X-Forwarded-For, X-Real-IP, or RemoteAddr yourself for a rate limit or an audit log. Any client can send those headers. Use c.ClientIP():

app.Post("/signup", func(c *hyperchi.Context) error {
    app.Helpers.Logger.Info("signup from %s", c.ClientIP())
    return c.Status(http.StatusCreated).Text("ok")
})

With no configuration it is the TCP peer. Behind a reverse proxy, list the proxy's addresses in TRUSTED_PROXIES (or cfg.Security.TrustedProxies), for example 172.18.0.0/16. Only a request whose peer is in that list has its X-Forwarded-For read, and the chain is walked from the right, stopping at the first address that is not trusted. The leftmost entry is client-controlled and is never believed. An invalid CIDR fails at startup. Do not install chi's middleware.RealIP. If you build your own stack without AutoMiddleware, declare the proxy with chi's app.Use(middleware.ClientIPFromXFF("172.18.0.0/16")).

WebSockets

Sockets are same-origin by default. In development, EnableWebSocket and app.WebSocket relax the origin check so a remote browser can connect through a dev server. In production the default stays same-origin, and an explicit policy always wins. Set it before registering the route:

hub := app.Extensions().GetWebSocketHub()
hub.SetOriginPatterns([]string{"app.example.com"})
hub.SetIdentifyUser(func(r *http.Request) string {
    return security.GetUserIDFromContext(r.Context())
})
hub.SetRoomAuthorizer(func(conn *websocket.WebSocketConnection, room string) bool {
    return conn.UserID != "" && room == "user:"+conn.UserID
})
app.WebSocket("/ws", func(s *hyperchi.Socket) error {
    for {
        if _, err := s.Receive(); err != nil {
            return err
        }
    }
})

A connection's user ID comes only from SetIdentifyUser, never from ?user_id=, a header, or a message. A client's join_room is refused until you install a room authorizer, and the authorizer also checks ?rooms= at the handshake. Server code (s.Join, JoinRoom) skips it. For a page that only listens, hub.SetReceiveOnly(true) closes the connection on any client message.

Escaping

Templates use html/template, so {{.body}} is escaped for its context. Render user input through a template, never by building markup:

app.Post("/comments", func(c *hyperchi.Context) error {
    in, err := hyperchi.Bind[comment](c)
    if err != nil {
        return err
    }
    return c.Fragment("comment", hyperchi.H{"body": in.Body})
})

c.HTML takes a template.HTML, which is a promise that the markup is already safe. Writing c.HTML(template.HTML("<p>" + in.Body + "</p>")) breaks that promise, and it is a stored XSS hole. If you must assemble a string, pass every value through html.EscapeString first, or compose with app.RenderToString(name, data), which escapes through the same templates.

Uploads

security.DefaultUploadConfig() allows common image types, 10 MB, five files, and sets RequireAuth, which rejects an upload with no verified user in the request. Keep it on unless the endpoint is meant to be public. Validate first, then store:

cfg := security.DefaultUploadConfig()
cfg.MaxFileSize = 2 << 20
cfg.MaxFiles = 1
validator := security.NewUploadValidator(cfg)

app.Post("/avatar", func(c *hyperchi.Context) error {
    res := validator.ValidateUpload(c.Request())
    if !res.Valid {
        return hyperchi.Unprocessable(strings.Join(res.Errors, "; "), nil)
    }
    saved, err := res.SaveAll("uploads", security.SaveOptions{})
    if err != nil {
        return err
    }
    return c.Status(http.StatusCreated).JSON(saved)
})

SaveAll names each file with random bytes and an extension chosen from the type it sniffed in the content, writes through os.Root so nothing escapes the directory, and refuses HTML, XML, SVG, and JavaScript even if you list them. Show SavedFile.OriginalName to people; never use it as a path. When you serve stored files, set the content type from the extension and send X-Content-Type-Options: nosniff.

Secrets

Prove it

Each rule above has a test you can write in a few lines: a forged X-Forwarded-For, a POST without a token, an upload with the wrong type. Testing shows how, and Deploying covers the proxy and production settings that make ClientIP and HSTS true in the real world.