diff --git a/internal/api/middleware/security_headers.go b/internal/api/middleware/security_headers.go index c2f6401b5..1580bfe1c 100644 --- a/internal/api/middleware/security_headers.go +++ b/internal/api/middleware/security_headers.go @@ -1,6 +1,7 @@ package middleware import ( + "net" "strings" "github.com/gin-gonic/gin" @@ -48,13 +49,14 @@ func SecurityHeaders() gin.HandlerFunc { // PageHeaders is the part of SecurityHeaders a service serving framable pages // can carry (the forms service): no content sniffing, a referrer policy, and -// HSTS over TLS. Each page sets its own CSP. -func PageHeaders() gin.HandlerFunc { +// HSTS over TLS on ownHost only, never on a customer's domain. Each page sets its own CSP. +func PageHeaders(ownHost string) gin.HandlerFunc { + ownHost = strings.ToLower(ownHost) return func(c *gin.Context) { h := c.Writer.Header() h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "strict-origin-when-cross-origin") - if requestIsHTTPS(c) { + if ownHost != "" && requestIsHTTPS(c) && strings.EqualFold(requestHostname(c.Request.Host), ownHost) { h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") } c.Next() @@ -79,3 +81,11 @@ func requestIsHTTPS(c *gin.Context) bool { } return false } + +// requestHostname is the request's Host without a port. +func requestHostname(host string) string { + if h, _, err := net.SplitHostPort(host); err == nil { + return h + } + return host +} diff --git a/internal/formserver/server.go b/internal/formserver/server.go index d432e3b98..fdda618c9 100644 --- a/internal/formserver/server.go +++ b/internal/formserver/server.go @@ -16,6 +16,7 @@ import ( "fmt" "html" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -23,6 +24,7 @@ import ( "github.com/gin-gonic/gin" "github.com/warmbly/warmbly/internal/api/middleware" + "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/formwire" ) @@ -138,7 +140,7 @@ func (s *Server) Router(trustedProxies []string) (*gin.Engine, error) { gin.SetMode(gin.ReleaseMode) } r := gin.New() - r.Use(middleware.RequestLogger(), gin.Recovery(), middleware.PageHeaders()) + r.Use(middleware.RequestLogger(), gin.Recovery(), middleware.PageHeaders(formsHost())) // Same posture as the backend: trust no proxy unless the operator names // it, so a forged X-Forwarded-For cannot dodge the submit limiter. if len(trustedProxies) > 0 { @@ -433,3 +435,12 @@ func (s *Server) ServeFormsEmbedJS(c *gin.Context) { c.Header("Cache-Control", "public, max-age=3600") c.Data(http.StatusOK, "application/javascript; charset=utf-8", formsEmbedJS) } + +// formsHost is the hostname of FORMS_DOMAIN, the one host the forms service sends HSTS for. +func formsHost() string { + u, err := url.Parse(config.FormsBaseURL()) + if err != nil { + return "" + } + return u.Hostname() +} diff --git a/site/public/install.sh b/site/public/install.sh index e72578192..55156909d 100644 --- a/site/public/install.sh +++ b/site/public/install.sh @@ -1604,6 +1604,7 @@ render_caddyfile() { header { X-Content-Type-Options "nosniff" Referrer-Policy "strict-origin-when-cross-origin" + -Strict-Transport-Security -Server } } diff --git a/site/public/install.sh.sha256 b/site/public/install.sh.sha256 index 02ecf7c18..ef0b66788 100644 --- a/site/public/install.sh.sha256 +++ b/site/public/install.sh.sha256 @@ -1 +1 @@ -098221eed28b874d84250518a001bbf38e3e74520c00bc48d3cc1328d2107f56 install.sh +d68bebcc66b502d4f78736a1325517aea199e7f3fdc8c517f9d4165ff7985116 install.sh