fix(http): derive public redirect URLs from request host
- commit
- f0b79063e2e446faf8890c62cc28ff629d567378
- author
- brice · · 2026-04-01 09:30 UTC
- parent
- aab20ab
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +91 -16
M internal/adapters/in/http/middleware/cidr.go +33 -15
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "net" | |
| 7 | 7 | "net/http" | |
| 8 | + | "strconv" | |
| 8 | 9 | "strings" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/zerowrap" | |
| @@ -74,7 +75,11 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L | |||
| 74 | 75 | // Otherwise, only non-trusted clients are redirected — trusted proxy IPs and localhost | |
| 75 | 76 | // pass through since they deliver Cloudflare-proxied traffic. | |
| 76 | 77 | // When tlsPort is 0, this is always a no-op. | |
| 77 | - | func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 78 | + | // | |
| 79 | + | // httpPort is the configured HTTP listener port (cfg.Server.Port) so the redirect | |
| 80 | + | // helper can map it to tlsPort. Hosts with no explicit port omit the TLS port from | |
| 81 | + | // the public URL; hosts with an unknown explicit port preserve it as-is. | |
| 82 | + | func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 78 | 83 | return func(next http.Handler) http.Handler { | |
| 79 | 84 | if tlsPort == 0 { | |
| 80 | 85 | return next | |
| @@ -95,20 +100,7 @@ func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerow | |||
| 95 | 100 | } | |
| 96 | 101 | } | |
| 97 | 102 | ||
| 98 | - | host := r.Host | |
| 99 | - | if h, _, err := net.SplitHostPort(host); err == nil { | |
| 100 | - | host = h | |
| 101 | - | } | |
| 102 | - | path := r.RequestURI | |
| 103 | - | if !strings.HasPrefix(path, "/") { | |
| 104 | - | path = "/" | |
| 105 | - | } | |
| 106 | - | var target string | |
| 107 | - | if tlsPort == 443 { | |
| 108 | - | target = fmt.Sprintf("https://%s%s", host, path) | |
| 109 | - | } else { | |
| 110 | - | target = fmt.Sprintf("https://%s:%d%s", host, tlsPort, path) | |
| 111 | - | } | |
| 103 | + | target := httpsRedirectTarget(r.Host, r.RequestURI, httpPort, tlsPort) | |
| 112 | 104 | ||
| 113 | 105 | log.Debug(). | |
| 114 | 106 | Str("target", target). | |
| @@ -119,6 +111,32 @@ func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerow | |||
| 119 | 111 | } | |
| 120 | 112 | } | |
| 121 | 113 | ||
| 114 | + | // httpsRedirectTarget derives the HTTPS redirect URL from the request Host. | |
| 115 | + | // | |
| 116 | + | // - No port in Host → omit TLS port (public reverse-proxy assumed on :443). | |
| 117 | + | // - Host port == httpPort → map to tlsPort. | |
| 118 | + | // - Any other explicit port → preserve it. | |
| 119 | + | func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int) string { | |
| 120 | + | path := requestURI | |
| 121 | + | if !strings.HasPrefix(path, "/") { | |
| 122 | + | path = "/" | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | hostname, portStr, err := net.SplitHostPort(host) | |
| 126 | + | if err != nil { | |
| 127 | + | // No port in Host header — omit TLS port from URL. | |
| 128 | + | return fmt.Sprintf("https://%s%s", host, path) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | port, _ := strconv.Atoi(portStr) | |
| 132 | + | if port == httpPort { | |
| 133 | + | return fmt.Sprintf("https://%s:%d%s", hostname, tlsPort, path) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // Unknown explicit port — preserve it. | |
| 137 | + | return fmt.Sprintf("https://%s:%s%s", hostname, portStr, path) | |
| 138 | + | } | |
| 139 | + | ||
| 122 | 140 | // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges. | |
| 123 | 141 | // This validates the direct network peer (RemoteAddr), not forwarded IPs, to ensure only | |
| 124 | 142 | // trusted sources (e.g. Cloudflare edge IPs) can reach the proxy server. | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "net" | 6 | "net" | ||
| 7 | "net/http" | 7 | "net/http" | ||
| 8 | + | "strconv" | |||
| 8 | "strings" | 9 | "strings" | ||
| 9 | 10 | ||||
| 10 | "github.com/bnema/zerowrap" | 11 | "github.com/bnema/zerowrap" | ||
| @@ -74,7 +75,11 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L | |||||
| 74 | // Otherwise, only non-trusted clients are redirected — trusted proxy IPs and localhost | 75 | // Otherwise, only non-trusted clients are redirected — trusted proxy IPs and localhost | ||
| 75 | // pass through since they deliver Cloudflare-proxied traffic. | 76 | // pass through since they deliver Cloudflare-proxied traffic. | ||
| 76 | // When tlsPort is 0, this is always a no-op. | 77 | // When tlsPort is 0, this is always a no-op. | ||
| 77 | - | func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerowrap.Logger) func(http.Handler) http.Handler { | 78 | + | // |
| 79 | + | // httpPort is the configured HTTP listener port (cfg.Server.Port) so the redirect | |||
| 80 | + | // helper can map it to tlsPort. Hosts with no explicit port omit the TLS port from | |||
| 81 | + | // the public URL; hosts with an unknown explicit port preserve it as-is. | |||
| 82 | + | func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool, log zerowrap.Logger) func(http.Handler) http.Handler { | |||
| 78 | return func(next http.Handler) http.Handler { | 83 | return func(next http.Handler) http.Handler { | ||
| 79 | if tlsPort == 0 { | 84 | if tlsPort == 0 { | ||
| 80 | return next | 85 | return next | ||
| @@ -95,20 +100,7 @@ func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerow | |||||
| 95 | } | 100 | } | ||
| 96 | } | 101 | } | ||
| 97 | 102 | ||||
| 98 | - | host := r.Host | 103 | + | target := httpsRedirectTarget(r.Host, r.RequestURI, httpPort, tlsPort) |
| 99 | - | if h, _, err := net.SplitHostPort(host); err == nil { | |||
| 100 | - | host = h | |||
| 101 | - | } | |||
| 102 | - | path := r.RequestURI | |||
| 103 | - | if !strings.HasPrefix(path, "/") { | |||
| 104 | - | path = "/" | |||
| 105 | - | } | |||
| 106 | - | var target string | |||
| 107 | - | if tlsPort == 443 { | |||
| 108 | - | target = fmt.Sprintf("https://%s%s", host, path) | |||
| 109 | - | } else { | |||
| 110 | - | target = fmt.Sprintf("https://%s:%d%s", host, tlsPort, path) | |||
| 111 | - | } | |||
| 112 | 104 | ||||
| 113 | log.Debug(). | 105 | log.Debug(). | ||
| 114 | Str("target", target). | 106 | Str("target", target). | ||
| @@ -119,6 +111,32 @@ func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerow | |||||
| 119 | } | 111 | } | ||
| 120 | } | 112 | } | ||
| 121 | 113 | ||||
| 114 | + | // httpsRedirectTarget derives the HTTPS redirect URL from the request Host. | |||
| 115 | + | // | |||
| 116 | + | // - No port in Host → omit TLS port (public reverse-proxy assumed on :443). | |||
| 117 | + | // - Host port == httpPort → map to tlsPort. | |||
| 118 | + | // - Any other explicit port → preserve it. | |||
| 119 | + | func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int) string { | |||
| 120 | + | path := requestURI | |||
| 121 | + | if !strings.HasPrefix(path, "/") { | |||
| 122 | + | path = "/" | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | hostname, portStr, err := net.SplitHostPort(host) | |||
| 126 | + | if err != nil { | |||
| 127 | + | // No port in Host header — omit TLS port from URL. | |||
| 128 | + | return fmt.Sprintf("https://%s%s", host, path) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | port, _ := strconv.Atoi(portStr) | |||
| 132 | + | if port == httpPort { | |||
| 133 | + | return fmt.Sprintf("https://%s:%d%s", hostname, tlsPort, path) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // Unknown explicit port — preserve it. | |||
| 137 | + | return fmt.Sprintf("https://%s:%s%s", hostname, portStr, path) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 122 | // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges. | 140 | // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges. | ||
| 123 | // This validates the direct network peer (RemoteAddr), not forwarded IPs, to ensure only | 141 | // This validates the direct network peer (RemoteAddr), not forwarded IPs, to ensure only | ||
| 124 | // trusted sources (e.g. Cloudflare edge IPs) can reach the proxy server. | 142 | // trusted sources (e.g. Cloudflare edge IPs) can reach the proxy server. | ||
M internal/adapters/in/http/middleware/cidr_test.go +57 -0
| @@ -110,6 +110,63 @@ func TestRegistryCIDRAllowlist(t *testing.T) { | |||
| 110 | 110 | } | |
| 111 | 111 | } | |
| 112 | 112 | ||
| 113 | + | func TestHTTPSRedirect_NoPortHost_OmitsTLSPort(t *testing.T) { | |
| 114 | + | log := testLogger() | |
| 115 | + | ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 116 | + | w.WriteHeader(http.StatusOK) | |
| 117 | + | }) | |
| 118 | + | ||
| 119 | + | handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok) | |
| 120 | + | ||
| 121 | + | req := httptest.NewRequest(http.MethodGet, "http://o2.bnema.dev/", nil) | |
| 122 | + | req.Host = "o2.bnema.dev" | |
| 123 | + | req.RemoteAddr = "203.0.113.10:12345" | |
| 124 | + | rr := httptest.NewRecorder() | |
| 125 | + | ||
| 126 | + | handler.ServeHTTP(rr, req) | |
| 127 | + | ||
| 128 | + | assert.Equal(t, http.StatusPermanentRedirect, rr.Code) | |
| 129 | + | assert.Equal(t, "https://o2.bnema.dev/", rr.Header().Get("Location")) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestHTTPSRedirect_HTTPListenerPort_MapsToTLSPort(t *testing.T) { | |
| 133 | + | log := testLogger() | |
| 134 | + | ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 135 | + | w.WriteHeader(http.StatusOK) | |
| 136 | + | }) | |
| 137 | + | ||
| 138 | + | handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok) | |
| 139 | + | ||
| 140 | + | req := httptest.NewRequest(http.MethodGet, "http://o2.bnema.dev:8088/", nil) | |
| 141 | + | req.Host = "o2.bnema.dev:8088" | |
| 142 | + | req.RemoteAddr = "203.0.113.10:12345" | |
| 143 | + | rr := httptest.NewRecorder() | |
| 144 | + | ||
| 145 | + | handler.ServeHTTP(rr, req) | |
| 146 | + | ||
| 147 | + | assert.Equal(t, http.StatusPermanentRedirect, rr.Code) | |
| 148 | + | assert.Equal(t, "https://o2.bnema.dev:8443/", rr.Header().Get("Location")) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestHTTPSRedirect_UnknownExplicitPort_IsPreserved(t *testing.T) { | |
| 152 | + | log := testLogger() | |
| 153 | + | ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 154 | + | w.WriteHeader(http.StatusOK) | |
| 155 | + | }) | |
| 156 | + | ||
| 157 | + | handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok) | |
| 158 | + | ||
| 159 | + | req := httptest.NewRequest(http.MethodGet, "http://o2.bnema.dev:9999/", nil) | |
| 160 | + | req.Host = "o2.bnema.dev:9999" | |
| 161 | + | req.RemoteAddr = "203.0.113.10:12345" | |
| 162 | + | rr := httptest.NewRecorder() | |
| 163 | + | ||
| 164 | + | handler.ServeHTTP(rr, req) | |
| 165 | + | ||
| 166 | + | assert.Equal(t, http.StatusPermanentRedirect, rr.Code) | |
| 167 | + | assert.Equal(t, "https://o2.bnema.dev:9999/", rr.Header().Get("Location")) | |
| 168 | + | } | |
| 169 | + | ||
| 113 | 170 | func TestProxyCIDRAllowlist(t *testing.T) { | |
| 114 | 171 | log := testLogger() | |
| 115 | 172 | redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| @@ -110,6 +110,63 @@ func TestRegistryCIDRAllowlist(t *testing.T) { | |||||
| 110 | } | 110 | } | ||
| 111 | } | 111 | } | ||
| 112 | 112 | ||||
| 113 | + | func TestHTTPSRedirect_NoPortHost_OmitsTLSPort(t *testing.T) { | |||
| 114 | + | log := testLogger() | |||
| 115 | + | ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 116 | + | w.WriteHeader(http.StatusOK) | |||
| 117 | + | }) | |||
| 118 | + | ||||
| 119 | + | handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok) | |||
| 120 | + | ||||
| 121 | + | req := httptest.NewRequest(http.MethodGet, "http://o2.bnema.dev/", nil) | |||
| 122 | + | req.Host = "o2.bnema.dev" | |||
| 123 | + | req.RemoteAddr = "203.0.113.10:12345" | |||
| 124 | + | rr := httptest.NewRecorder() | |||
| 125 | + | ||||
| 126 | + | handler.ServeHTTP(rr, req) | |||
| 127 | + | ||||
| 128 | + | assert.Equal(t, http.StatusPermanentRedirect, rr.Code) | |||
| 129 | + | assert.Equal(t, "https://o2.bnema.dev/", rr.Header().Get("Location")) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func TestHTTPSRedirect_HTTPListenerPort_MapsToTLSPort(t *testing.T) { | |||
| 133 | + | log := testLogger() | |||
| 134 | + | ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 135 | + | w.WriteHeader(http.StatusOK) | |||
| 136 | + | }) | |||
| 137 | + | ||||
| 138 | + | handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok) | |||
| 139 | + | ||||
| 140 | + | req := httptest.NewRequest(http.MethodGet, "http://o2.bnema.dev:8088/", nil) | |||
| 141 | + | req.Host = "o2.bnema.dev:8088" | |||
| 142 | + | req.RemoteAddr = "203.0.113.10:12345" | |||
| 143 | + | rr := httptest.NewRecorder() | |||
| 144 | + | ||||
| 145 | + | handler.ServeHTTP(rr, req) | |||
| 146 | + | ||||
| 147 | + | assert.Equal(t, http.StatusPermanentRedirect, rr.Code) | |||
| 148 | + | assert.Equal(t, "https://o2.bnema.dev:8443/", rr.Header().Get("Location")) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestHTTPSRedirect_UnknownExplicitPort_IsPreserved(t *testing.T) { | |||
| 152 | + | log := testLogger() | |||
| 153 | + | ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 154 | + | w.WriteHeader(http.StatusOK) | |||
| 155 | + | }) | |||
| 156 | + | ||||
| 157 | + | handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok) | |||
| 158 | + | ||||
| 159 | + | req := httptest.NewRequest(http.MethodGet, "http://o2.bnema.dev:9999/", nil) | |||
| 160 | + | req.Host = "o2.bnema.dev:9999" | |||
| 161 | + | req.RemoteAddr = "203.0.113.10:12345" | |||
| 162 | + | rr := httptest.NewRecorder() | |||
| 163 | + | ||||
| 164 | + | handler.ServeHTTP(rr, req) | |||
| 165 | + | ||||
| 166 | + | assert.Equal(t, http.StatusPermanentRedirect, rr.Code) | |||
| 167 | + | assert.Equal(t, "https://o2.bnema.dev:9999/", rr.Header().Get("Location")) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 113 | func TestProxyCIDRAllowlist(t *testing.T) { | 170 | func TestProxyCIDRAllowlist(t *testing.T) { | ||
| 114 | log := testLogger() | 171 | log := testLogger() | ||
| 115 | redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 172 | redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
M internal/app/run.go +1 -1
| @@ -1641,7 +1641,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1641 | 1641 | middleware.PanicRecovery(log), | |
| 1642 | 1642 | middleware.RequestLogger(log, trustedNets), | |
| 1643 | 1643 | middleware.SecurityHeaders, | |
| 1644 | - | middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log), | |
| 1644 | + | middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.Port, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log), | |
| 1645 | 1645 | } | |
| 1646 | 1646 | if proxyCIDRMiddleware != nil { | |
| 1647 | 1647 | httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware) | |
| @@ -1641,7 +1641,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 1641 | middleware.PanicRecovery(log), | 1641 | middleware.PanicRecovery(log), | ||
| 1642 | middleware.RequestLogger(log, trustedNets), | 1642 | middleware.RequestLogger(log, trustedNets), | ||
| 1643 | middleware.SecurityHeaders, | 1643 | middleware.SecurityHeaders, | ||
| 1644 | - | middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log), | 1644 | + | middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.Port, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log), |
| 1645 | } | 1645 | } | ||
| 1646 | if proxyCIDRMiddleware != nil { | 1646 | if proxyCIDRMiddleware != nil { | ||
| 1647 | httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware) | 1647 | httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware) | ||