detached f0b7906

fix(http): derive public redirect URLs from request host

commit
f0b79063e2e446faf8890c62cc28ff629d567378
author
brice · · 2026-04-01 09:30 UTC
parent
aab20ab
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 (
55 "fmt"
66 "net"
77 "net/http"
8+ "strconv"
89 "strings"
910
1011 "github.com/bnema/zerowrap"
@@ -74,7 +75,11 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L
7475 // Otherwise, only non-trusted clients are redirected — trusted proxy IPs and localhost
7576 // pass through since they deliver Cloudflare-proxied traffic.
7677 // 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 {
7883 return func(next http.Handler) http.Handler {
7984 if tlsPort == 0 {
8085 return next
@@ -95,20 +100,7 @@ func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerow
95100 }
96101 }
97102
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)
112104
113105 log.Debug().
114106 Str("target", target).
@@ -119,6 +111,32 @@ func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerow
119111 }
120112 }
121113
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+
122140 // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges.
123141 // This validates the direct network peer (RemoteAddr), not forwarded IPs, to ensure only
124142 // 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 localhost75 // 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 next85 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.Host103+ 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 only141 // 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) {
110110 }
111111 }
112112
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+
113170 func TestProxyCIDRAllowlist(t *testing.T) {
114171 log := testLogger()
115172 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
16411641 middleware.PanicRecovery(log),
16421642 middleware.RequestLogger(log, trustedNets),
16431643 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),
16451645 }
16461646 if proxyCIDRMiddleware != nil {
16471647 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)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close