feat: send HSTS from the forms service only on FORMS_DOMAIN and strip it in the installer's Caddy block for customer-owned domains, so no customer apex is pinned to HTTPS

This commit is contained in:
Matthew Meszaros
2026-10-04 05:19:22 -07:00
parent c178dadf81
commit ab39429e37
4 changed files with 27 additions and 5 deletions
+13 -3
View File
@@ -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
}
+12 -1
View File
@@ -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()
}
+1
View File
@@ -1604,6 +1604,7 @@ render_caddyfile() {
header {
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
-Strict-Transport-Security
-Server
}
}
+1 -1
View File
@@ -1 +1 @@
098221eed28b874d84250518a001bbf38e3e74520c00bc48d3cc1328d2107f56 install.sh
d68bebcc66b502d4f78736a1325517aea199e7f3fdc8c517f9d4165ff7985116 install.sh