Files
warmbly/internal/api/middleware/security_headers.go
T

68 lines
2.6 KiB
Go

package middleware
import (
"strings"
"github.com/gin-gonic/gin"
)
// SecurityHeaders sets the response headers that tell a browser what it may do
// with an API response. None of them were being sent by any layer: not the Go
// services, not the nginx images, not the edge.
//
// The API answers JSON, so the policy can be the strictest one there is. It
// loads nothing, frames nothing, and may not be framed. That matters because
// the same origin also serves /public (customer uploads) and the OAuth bouncer
// pages, and those are the responses an attacker would want rendered.
//
// HSTS is only emitted on a request that actually arrived over TLS. Sending it
// over plain HTTP is ignored by browsers anyway, and a self-hosted instance
// deliberately running on a LAN without TLS must not be pinned to a scheme it
// does not serve.
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
h := c.Writer.Header()
// A browser must never guess a content type here: /public serves
// customer uploads from this origin.
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("Cross-Origin-Opener-Policy", "same-origin")
h.Set("Cross-Origin-Resource-Policy", "same-site")
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()")
// default-src 'none' suits a JSON API. The handful of routes that
// return HTML (the OAuth bouncers, the unsubscribe page) set their own
// policy over this one, and /public sets an image sandbox.
if _, already := h["Content-Security-Policy"]; !already {
h.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
}
if requestIsHTTPS(c) {
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
c.Next()
}
}
// requestIsHTTPS reports whether the client reached us over TLS. Termination is
// always upstream (Railway, Caddy, nginx), so the forwarded header is the
// signal.
//
// The header is read directly: gin's trusted-proxy handling covers ClientIP,
// not arbitrary headers. That is acceptable here because the only thing it
// decides is whether to send HSTS, a browser cannot set the header on a request
// it makes, and a non-browser client that sets it receives a header it ignores.
// Nothing is authorized on this value.
func requestIsHTTPS(c *gin.Context) bool {
if c.Request.TLS != nil {
return true
}
if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" {
return strings.EqualFold(strings.TrimSpace(strings.Split(proto, ",")[0]), "https")
}
return false
}