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:
- CSRF.
app.Middleware.CSRF()is double-submit cookie protection. Unsafe methods must send the token back or get a 403 through your error pipeline. - Rate limiting.
app.Middleware.RateLimit(n, window)keys on the client IP and answers 429 withRetry-After. Limits live in process memory, so each instance counts on its own.app.AutoMiddleware()reads three switches from the config:Security.CSRF(on by default) installs CSRF protection,Security.RateLimit(off by default) adds a limit of 100 requests a minute per client, andSecurity.CORS(on by default) sends the CORS headers forCORS_ORIGINS. Turn the rate limit on only afterTRUSTED_PROXIESnames your proxy, or every visitor shares the proxy's budget.
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
SECRET_KEY(32 bytes or more) signsc.SetSignedCookieand flash messages. Generate one withopenssl rand -base64 32. Without it, production still starts, but signing calls fail withErrNoSecretKey.- To rotate, set the new key as
SECRET_KEYand the old one inSECRET_KEY_PREVIOUS(comma separated). Old values still verify; new ones are signed with the new key. - Printing a
Configredacts both keys. A signed cookie is signed, not encrypted: never put a secret in one. - Keep keys in the environment or a secret manager, not in the repository or the image.
DB_LOG_QUERIESis off by default because statement arguments can be personal data; leave it off in production.
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.