httpsec
The response headers OWASP's HTTP Headers, HSTS and CORS cheat sheets ask of every service, for any router.
import "gitlab.internal/tuan.quang.tran/aioz-common/httpsec"A survey of our services (2026-09-12) found security headers in two of ten repositories, HSTS in none, and eight of eleven CORS configs allowing any origin - one reflecting any Origin with credentials, which lets every website read a logged-in user's data. This package is one answer for all of them. It imports no framework: gin and echo call it from a few lines each, below.
Usage
headers, err := httpsec.NewHeaders(cfg.Headers)
if err != nil {
return err
}
cors, err := httpsec.NewCORS(cfg.CORS)
if err != nil {
return err
}
handler = headers.Wrap(cors.Wrap(mux)) // net/httpheaders:
content-security-policy: "default-src 'none'; frame-ancestors 'none'"
cross-origin-resource-policy: same-site
hsts: false # the default; see "HSTS" below before turning it on
hsts-max-age: 17520h
cors:
allowed-origins: [https://app.aioz.io, https://*.aioz.io] # empty: CORS off
allow-credentials: truegin
router.Use(func(c *gin.Context) {
headers.Apply(c.Writer.Header())
if cors.Handle(c.Writer.Header(), c.Request) {
c.AbortWithStatus(http.StatusNoContent) // a preflight
return
}
c.Next()
})Global middleware also runs for unmatched routes, so a preflight to a path with no OPTIONS route is still answered.
echo
e.Pre(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
headers.Apply(c.Response().Header())
if cors.Handle(c.Response().Header(), c.Request()) {
return c.NoContent(http.StatusNoContent) // a preflight
}
return next(c)
}
})e.Pre runs before routing, so echo's own OPTIONS handling never sees a preflight.
In both, remove the framework's CORS middleware (gin-contrib/cors, middleware.CORS()): two of them answer twice, with different rules.
Headers
| Header | Value | Why |
|---|---|---|
Content-Security-Policy | default-src 'none'; frame-ancestors 'none' | An API serves no HTML; a response rendered as a page can run nothing |
Strict-Transport-Security | max-age=63072000; includeSubDomains, only with hsts: true | Browsers refuse plain HTTP for two years |
X-Content-Type-Options | nosniff | JSON is never sniffed into HTML or script |
X-Frame-Options | DENY | No clickjacking (older browsers; CSP covers the rest) |
Referrer-Policy | strict-origin-when-cross-origin | URLs, with their tokens, stay on our site |
Cross-Origin-Opener-Policy | same-origin | No cross-origin window handle |
Cross-Origin-Resource-Policy | same-site | Other sites cannot embed responses |
Permissions-Policy | geolocation=(), camera=(), microphone=() | Nothing may ask for them |
X-XSS-Protection | 0 | The old auditor could leak data; none remain |
Cache-Control | no-store | Responses carry user data; no cache keeps them |
The headers are set before the handler runs, so one endpoint can loosen one - a Cache-Control on a public resource - without its own middleware. AllowCaching leaves Cache-Control to handlers everywhere.
HSTS
HSTS is off by default for now; hsts: true turns it on. A browser that sees the header refuses plain HTTP to this host - and, with includeSubDomains, to every host under it - for the whole max-age, two years by default. Nothing the server sends later shortens that for a browser that already has it, so one service sending it from a parent domain can lock users out of an internal host still on HTTP. Turn it on per service once every host it covers serves HTTPS only; start with a short hsts-max-age (for example 24h) and raise it once nothing breaks.
The HSTS settings are validated whether it is on or off, and hsts-preload with hsts off is refused. hsts-preload asks browsers to ship the domain as HTTPS-only; it needs includeSubDomains and a year, and is hard to undo.
A service that serves HTML sets its own content-security-policy (start from default-src 'self'; frame-ancestors 'none') and usually cross-origin-resource-policy: same-origin.
A different policy for some routes
The default policy lets a page load nothing, which suits JSON and breaks any page an API serves, such as Swagger UI: its scripts, stylesheets and images are all refused and it renders blank. Give those routes their own policy instead of loosening it for every route:
csp, err := httpsec.NewContentSecurityPolicy(httpsec.SwaggerUIPolicy)
mux.Handle("/swagger/", csp.Wrap(swaggerHandler)) // inside headers.Wrap, so it replaces the defaulthttpapi's adapters have ginapi.ContentSecurityPolicy and echoapi.ContentSecurityPolicy for the same thing. SwaggerUIPolicy loads only from this server and allows inline script and style, which echo-swagger's start-up script needs; it is checked in headless Chrome against gin-swagger v1.6.1 and echo-swagger v1.4.1. Keep it on the docs route: never give it to a route that renders what a user wrote.
Media another site embeds
The default Cross-Origin-Resource-Policy is same-site, and a browser refuses a cross-site <video>, <img> or <track> load of a same-site response whatever CORS allows. Routes whose responses a player on a customer's page loads directly - a manifest, an mp4, a thumbnail, captions - set cross-origin instead:
corp, err := httpsec.NewCrossOriginResourcePolicy("cross-origin")
mux.Handle("/media/", corp.Wrap(mediaHandler)) // inside headers.Wrap, so it replaces the defaulthttpapi's adapters have ginapi.CrossOriginResourcePolicy and echoapi.CrossOriginResourcePolicy for the same thing. Give it only to routes that serve something meant to be embedded: it lets any site load the response.
CORS
- Empty
allowed-originsturns CORS off. NoAccess-Control-*header is sent and a preflight reaches the handler. This is the fix for the services that allow any origin today; roll it out with their real allowlists. https://*.example.commatches one or more subdomain labels, with that scheme and port only - neverexample.com, neverevilexample.com.*is for a public API without credentials, and must stand alone.Newrefuses*with credentials, credentials with no origins,null, a path or trailing slash, user info, and a wildcard over a TLD.- The configured method and header lists are sent as they are; a request's
Access-Control-Request-Headersis never echoed back. Vary: Originis added (never set) on every response, so a cache does not hand one origin's answer to another.- Every preflight is answered with 204 - from a refused origin, with nothing a browser accepts - so none reaches a handler.
CORS is not authorization. It decides which pages a browser lets read a response, not who may call: a refused origin is still served. With credentials, also refuse cross-origin state changes (CSRF) with Go's http.CrossOriginProtection:
csrf := http.NewCrossOriginProtection()
for _, origin := range trustedOrigins { // exact origins, no wildcards
if err := csrf.AddTrustedOrigin(origin); err != nil {
return err
}
}
handler = headers.Wrap(cors.Wrap(csrf.Handler(mux)))It refuses non-safe cross-origin browser requests using Sec-Fetch-Site, so it needs no token.
Several services
CORS is decided per response, by whatever answers the request. With many services behind shared infrastructure:
- One layer answers CORS, never two. If the gateway (nginx, traefik) already adds
Access-Control-*headers, leaveallowed-originsempty in the services behind it: that is CORS off, and nothing is added. When a gateway and a service both sendAccess-Control-Allow-Origin, the browser sees two values and rejects the response. Pick the layer per route. - Services behind one API origin (
api.aioz.io/stream,api.aioz.io/map) are called by the same front ends, so they need the same allowlist. Keep it in one place in the deploy config and give every service the same value. A preflight is per URL, so a service with a shorter list fails only its own paths - a bug that looks like it comes and goes. - Services on their own subdomains each list the front ends that call them.
https://*.aioz.iocovers every front end, and also every subdomain someone can put a page on: a dangling DNS record, a preview deployment, a host for user content. Withallow-credentials, prefer the explicit list. - Cookies are about sites, not origins. A front end on
app.aioz.ioand an API onapi.aioz.ioare one site:SameSite=Laxcookies are sent, and CORS is still needed because they are two origins. A front end on another domain needsSameSite=None; Securecookies as well as CORS. - A shared cache or CDN in front of several services is why
Vary: Originis always sent: it keeps one origin's answer from being served to another. cross-origin-resource-policy: same-sitelets our sites on other subdomains embed each other's responses. Usesame-originfor a service that must not be embedded even by them, andcross-originonly for a public asset host.
One API, two audiences: a dashboard and SDK users
A service like stream is called by its own dashboard and by customers' SDKs, on the customers' servers and in their users' browsers. No single allowlist is right for both. What decides the CORS policy is how each caller authenticates, not who it is:
- A cookie the browser sends by itself needs an exact origin allowlist with credentials, because any page the user visits can make the browser send it.
- A token the caller puts in a header is safe with
*, because a page can only send a token it already has.
So such a service has more than one policy, one per group of routes:
| Caller | Authenticates with | CORS | Other headers |
|---|---|---|---|
| Our dashboard front end | session cookie | none, when the dashboard and its API share an origin (app.x/api/*, best); otherwise the exact dashboard origin with credentials | defaults, plus http.CrossOriginProtection |
| Server-side SDK (Node, Go, Python on the customer's backend) | secret API key in a header | none needed: servers do not send preflights or check CORS | defaults |
| Browser SDK or player on customers' sites (any origin) | publishable key or short-lived token in a header, never a cookie | *, no credentials | defaults |
| Media delivery (HLS manifests, segments, thumbnails) | signed, expiring URLs | *; allow Range; expose Content-Length and Content-Range | AllowCaching, cross-origin-resource-policy: cross-origin |
| Embeddable player page (an iframe) | signed URL, or the key's allowed domains | none: it is a page, not a fetch | framing allowed (see below) |
| Webhooks | a signature header | not applicable: server to server | not applicable |
Why * is right for the public API
With * and no credentials, the browser never attaches cookies to a cross-origin call, so a malicious site can do nothing with the API that it could not do with curl. What protects a public API is the key design, not the origin:
- A secret key never reaches a browser. A browser SDK gets a publishable key with a narrow scope (play, list public videos) or a short-lived token that the customer's backend mints with its secret key.
- Browser uploads use a one-time upload URL or token, created server-side for exactly one upload. The secret key stays on the customer's server.
- Limits are per key and per address: a
ratelimitrule onKey(apiKeyID)beside theIPKeyrule.
Letting a customer restrict a key to their sites
A publishable key can carry the origins its owner allows, as Stripe's, Mapbox's and Algolia's do. The check cannot happen in the preflight, which carries no Authorization header and so names no key: answer preflights generally (*), then on the real request look the key up and refuse with 403 when the request's Origin is not in its list.
This keeps other websites from spending a customer's leaked publishable key. It is quota protection, not authentication: anything that is not a browser sends whatever Origin it likes. The check lives in the service's API-key middleware; CORS knows only a static list.
Wiring it
One policy per route group, each group wrapped separately:
dashboardCORS, err := httpsec.NewCORS(httpsec.CORSConfig{
AllowedOrigins: cfg.DashboardOrigins, // exact; empty when the dashboard is same-origin
AllowCredentials: true,
})
publicCORS, err := httpsec.NewCORS(httpsec.CORSConfig{
AllowedOrigins: []string{"*"}, // header tokens only, never credentials
AllowedHeaders: []string{"Authorization", "Content-Type", "X-Api-Key"}, // plus Range, tus headers if used
ExposedHeaders: []string{"Retry-After", "X-Request-Id", "Location"},
})
mediaHeaders, err := httpsec.NewHeaders(httpsec.HeadersConfig{
AllowCaching: true,
CrossOriginResourcePolicy: "cross-origin",
})Retry-After and X-Request-Id are listed in ExposedHeaders because neither is CORS-safelisted: without it, an SDK in a browser cannot read them.
Embeddable pages. Headers always sends X-Frame-Options: DENY, which blocks a player iframe. The embed handler sets its own Content-Security-Policy with frame-ancestors * (or the customer's domains) and deletes X-Frame-Options; the headers are set before the handler runs, so it can.
Mistakes to avoid
- One allowlist for everything. Adding every customer's domain to it does not scale, and a new customer is broken until someone edits the config.
- Credentials with a reflected
Origin. Any website can then read a logged-in user's data.NewCORSrefuses*with credentials; reflecting the origin by hand does the same damage. - Cookies for the browser SDK. They force credentialed CORS for unknown origins, which browsers refuse with
*and which is unsafe to fake. no-storeorsame-siteon media. Players on other sites cannot load the segments, and the CDN cannot cache them.
API
type HeadersConfig struct {
ContentSecurityPolicy string
CrossOriginResourcePolicy string
HSTSMaxAge time.Duration
HSTSOmitSubdomains bool
HSTSPreload bool
AllowCaching bool
}
func NewHeaders(config HeadersConfig) (*Headers, error)
func (h *Headers) Apply(dst http.Header)
func (h *Headers) Wrap(next http.Handler) http.Handler
type CORSConfig struct {
AllowedOrigins []string
AllowedMethods []string
AllowedHeaders []string
ExposedHeaders []string
AllowCredentials bool
MaxAge time.Duration
}
func NewCORS(config CORSConfig) (*CORS, error)
func (c *CORS) Handle(dst http.Header, r *http.Request) (preflight bool)
func (c *CORS) Wrap(next http.Handler) http.Handler
var Error = errs.Tag("httpsec")