detached 9167cd9

fix: harden autoroute env files and host canonicalization

commit
9167cd978cd9c5ee70c952b4e244ffd51724b046
author
brice · · 2026-04-25 09:30 UTC
parent
826b93d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +655 -93
M internal/adapters/in/http/middleware/cidr.go +37 -5
@@ -5,12 +5,14 @@ import (
55 "fmt"
66 "net"
77 "net/http"
8+ "strconv"
89 "strings"
910
1011 "github.com/bnema/zerowrap"
1112
1213 "github.com/bnema/gordon/internal/adapters/dto"
1314 "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
15+ "github.com/bnema/gordon/internal/domain"
1416 )
1517
1618 // cidrAllowlist is the shared implementation for CIDR-based access control middleware.
@@ -67,7 +69,7 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L
6769 // httpPort is the configured HTTP listener port (cfg.Server.Port) so the redirect
6870 // helper can map it to tlsPort. Hosts with no explicit port omit the TLS port from
6971 // the public URL; hosts with an unknown explicit port preserve it as-is.
70-func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool, log zerowrap.Logger) func(http.Handler) http.Handler {
72+func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool, log zerowrap.Logger, isHostAllowed func(string) bool) func(http.Handler) http.Handler {
7173 return func(next http.Handler) http.Handler {
7274 if tlsPort == 0 {
7375 return next
@@ -88,7 +90,11 @@ func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool,
8890 }
8991 }
9092
91- target := httpsRedirectTarget(r.Host, r.RequestURI, httpPort, tlsPort)
93+ target, ok := httpsRedirectTarget(r.Host, r.RequestURI, httpPort, tlsPort, isHostAllowed)
94+ if !ok {
95+ http.Error(w, "Bad Request", http.StatusBadRequest)
96+ return
97+ }
9298
9399 log.Debug().
94100 Str("target", target).
@@ -99,14 +105,40 @@ func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool,
99105 }
100106 }
101107
102-// httpsRedirectTarget derives the HTTPS redirect URL from the request Host.
103-func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int) string {
108+// httpsRedirectTarget derives the HTTPS redirect URL from a validated request Host.
109+func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int, isHostAllowed func(string) bool) (string, bool) {
110+ host = strings.ToLower(strings.TrimSpace(host))
111+ if host == "" {
112+ return "", false
113+ }
114+ hostname, portStr, err := net.SplitHostPort(host)
115+ if err != nil {
116+ if strings.Contains(host, ":") {
117+ return "", false
118+ }
119+ hostname = host
120+ } else if port, err := strconv.Atoi(portStr); err != nil || port < 1 || port > 65535 {
121+ return "", false
122+ }
123+ hostname = strings.TrimSuffix(hostname, ".")
124+ canonicalHost, ok := domain.CanonicalRouteDomain(hostname)
125+ if !ok {
126+ return "", false
127+ }
128+ if isHostAllowed == nil || !isHostAllowed(canonicalHost) {
129+ return "", false
130+ }
131+
104132 path := requestURI
105133 if !strings.HasPrefix(path, "/") {
106134 path = "/"
107135 }
108136
109- return fmt.Sprintf("https://%s%s", httphelper.HTTPSAuthority(host, httpPort, tlsPort), path)
137+ canonicalAuthority := canonicalHost
138+ if portStr != "" {
139+ canonicalAuthority = net.JoinHostPort(canonicalHost, portStr)
140+ }
141+ return fmt.Sprintf("https://%s%s", httphelper.HTTPSAuthority(canonicalAuthority, httpPort, tlsPort), path), true
110142 }
111143
112144 // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges.
@@ -5,12 +5,14 @@ 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"
11 12
12 "github.com/bnema/gordon/internal/adapters/dto"13 "github.com/bnema/gordon/internal/adapters/dto"
13 "github.com/bnema/gordon/internal/adapters/in/http/httphelper"14 "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
15+ "github.com/bnema/gordon/internal/domain"
14 )16 )
15 17
16 // cidrAllowlist is the shared implementation for CIDR-based access control middleware.18 // cidrAllowlist is the shared implementation for CIDR-based access control middleware.
@@ -67,7 +69,7 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L
67 // httpPort is the configured HTTP listener port (cfg.Server.Port) so the redirect69 // httpPort is the configured HTTP listener port (cfg.Server.Port) so the redirect
68 // helper can map it to tlsPort. Hosts with no explicit port omit the TLS port from70 // helper can map it to tlsPort. Hosts with no explicit port omit the TLS port from
69 // the public URL; hosts with an unknown explicit port preserve it as-is.71 // the public URL; hosts with an unknown explicit port preserve it as-is.
70-func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool, log zerowrap.Logger) func(http.Handler) http.Handler {72+func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool, log zerowrap.Logger, isHostAllowed func(string) bool) func(http.Handler) http.Handler {
71 return func(next http.Handler) http.Handler {73 return func(next http.Handler) http.Handler {
72 if tlsPort == 0 {74 if tlsPort == 0 {
73 return next75 return next
@@ -88,7 +90,11 @@ func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool,
88 }90 }
89 }91 }
90 92
91- target := httpsRedirectTarget(r.Host, r.RequestURI, httpPort, tlsPort)93+ target, ok := httpsRedirectTarget(r.Host, r.RequestURI, httpPort, tlsPort, isHostAllowed)
94+ if !ok {
95+ http.Error(w, "Bad Request", http.StatusBadRequest)
96+ return
97+ }
92 98
93 log.Debug().99 log.Debug().
94 Str("target", target).100 Str("target", target).
@@ -99,14 +105,40 @@ func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool,
99 }105 }
100 }106 }
101 107
102-// httpsRedirectTarget derives the HTTPS redirect URL from the request Host.108+// httpsRedirectTarget derives the HTTPS redirect URL from a validated request Host.
103-func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int) string {109+func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int, isHostAllowed func(string) bool) (string, bool) {
110+ host = strings.ToLower(strings.TrimSpace(host))
111+ if host == "" {
112+ return "", false
113+ }
114+ hostname, portStr, err := net.SplitHostPort(host)
115+ if err != nil {
116+ if strings.Contains(host, ":") {
117+ return "", false
118+ }
119+ hostname = host
120+ } else if port, err := strconv.Atoi(portStr); err != nil || port < 1 || port > 65535 {
121+ return "", false
122+ }
123+ hostname = strings.TrimSuffix(hostname, ".")
124+ canonicalHost, ok := domain.CanonicalRouteDomain(hostname)
125+ if !ok {
126+ return "", false
127+ }
128+ if isHostAllowed == nil || !isHostAllowed(canonicalHost) {
129+ return "", false
130+ }
131+
104 path := requestURI132 path := requestURI
105 if !strings.HasPrefix(path, "/") {133 if !strings.HasPrefix(path, "/") {
106 path = "/"134 path = "/"
107 }135 }
108 136
109- return fmt.Sprintf("https://%s%s", httphelper.HTTPSAuthority(host, httpPort, tlsPort), path)137+ canonicalAuthority := canonicalHost
138+ if portStr != "" {
139+ canonicalAuthority = net.JoinHostPort(canonicalHost, portStr)
140+ }
141+ return fmt.Sprintf("https://%s%s", httphelper.HTTPSAuthority(canonicalAuthority, httpPort, tlsPort), path), true
110 }142 }
111 143
112 // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges.144 // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges.
M internal/adapters/in/http/middleware/cidr_test.go +42 -20
@@ -2,7 +2,6 @@ package middleware
22
33 import (
44 "encoding/json"
5- "fmt"
65 "net"
76 "net/http"
87 "net/http/httptest"
@@ -118,7 +117,7 @@ func TestHTTPSRedirect_NoPortHost_OmitsTLSPort(t *testing.T) {
118117 w.WriteHeader(http.StatusOK)
119118 })
120119
121- handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok)
120+ handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "o2.bnema.dev" })(ok)
122121 srv := httptest.NewServer(handler)
123122 defer srv.Close()
124123
@@ -141,7 +140,7 @@ func TestHTTPSRedirect_HTTPListenerPort_MapsToTLSPort(t *testing.T) {
141140 w.WriteHeader(http.StatusOK)
142141 })
143142
144- handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok)
143+ handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "o2.bnema.dev" })(ok)
145144 srv := httptest.NewServer(handler)
146145 defer srv.Close()
147146
@@ -164,7 +163,7 @@ func TestHTTPSRedirect_UnknownExplicitPort_IsPreserved(t *testing.T) {
164163 w.WriteHeader(http.StatusOK)
165164 })
166165
167- handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok)
166+ handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "o2.bnema.dev" })(ok)
168167 srv := httptest.NewServer(handler)
169168 defer srv.Close()
170169
@@ -181,31 +180,54 @@ func TestHTTPSRedirect_UnknownExplicitPort_IsPreserved(t *testing.T) {
181180 assert.Equal(t, "https://o2.bnema.dev:9999/", resp.Header.Get("Location"))
182181 }
183182
184-func TestHTTPSRedirect_InvalidExplicitPort_IsPreserved(t *testing.T) {
183+func TestHTTPSRedirect_TrailingDotHostRedirectsToCanonicalHost(t *testing.T) {
185184 log := testLogger()
186185 ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
187186 w.WriteHeader(http.StatusOK)
188187 })
188+ handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "o2.bnema.dev" })(ok)
189189
190- handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok)
191- srv := httptest.NewServer(handler)
192- defer srv.Close()
190+ req := httptest.NewRequest(http.MethodGet, "http://example.test/path", nil)
191+ req.RequestURI = "/path"
192+ req.Host = "O2.Bnema.Dev.:8088"
193+ rec := httptest.NewRecorder()
193194
194- // Use a raw TCP connection because Go's HTTP client rejects the
195- // invalid port in the Location header before CheckRedirect fires.
196- conn, err := net.Dial("tcp", srv.Listener.Addr().String())
197- require.NoError(t, err)
198- defer conn.Close()
195+ handler.ServeHTTP(rec, req)
199196
200- _, err = fmt.Fprintf(conn, "GET / HTTP/1.1\r\nHost: o2.bnema.dev:abcd\r\nConnection: close\r\n\r\n")
201- require.NoError(t, err)
197+ assert.Equal(t, http.StatusPermanentRedirect, rec.Code)
198+ assert.Equal(t, "https://o2.bnema.dev:8443/path", rec.Header().Get("Location"))
199+}
200+
201+func TestHTTPSRedirect_RejectsInvalidHost(t *testing.T) {
202+ log := testLogger()
203+ ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
204+ w.WriteHeader(http.StatusOK)
205+ })
206+ handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "o2.bnema.dev" })(ok)
202207
203- var buf [4096]byte
204- n, _ := conn.Read(buf[:])
205- raw := string(buf[:n])
208+ tests := []struct {
209+ name string
210+ host string
211+ }{
212+ {name: "invalid port", host: "o2.bnema.dev:abcd"},
213+
214+ {name: "localhost", host: "localhost"},
215+ {name: "ipv6", host: "[::1]:8088"},
216+ {name: "unconfigured public host", host: "evil.example.com"},
217+ }
218+
219+ for _, tt := range tests {
220+ t.Run(tt.name, func(t *testing.T) {
221+ req := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
222+ req.Host = tt.host
223+ rec := httptest.NewRecorder()
224+
225+ handler.ServeHTTP(rec, req)
206226
207- assert.Contains(t, raw, "308")
208- assert.Contains(t, raw, "Location: https://o2.bnema.dev:abcd/")
227+ assert.Equal(t, http.StatusBadRequest, rec.Code)
228+ assert.Empty(t, rec.Header().Get("Location"))
229+ })
230+ }
209231 }
210232
211233 func TestProxyCIDRAllowlist(t *testing.T) {
@@ -2,7 +2,6 @@ package middleware
2 2
3 import (3 import (
4 "encoding/json"4 "encoding/json"
5- "fmt"
6 "net"5 "net"
7 "net/http"6 "net/http"
8 "net/http/httptest"7 "net/http/httptest"
@@ -118,7 +117,7 @@ func TestHTTPSRedirect_NoPortHost_OmitsTLSPort(t *testing.T) {
118 w.WriteHeader(http.StatusOK)117 w.WriteHeader(http.StatusOK)
119 })118 })
120 119
121- handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok)120+ handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "o2.bnema.dev" })(ok)
122 srv := httptest.NewServer(handler)121 srv := httptest.NewServer(handler)
123 defer srv.Close()122 defer srv.Close()
124 123
@@ -141,7 +140,7 @@ func TestHTTPSRedirect_HTTPListenerPort_MapsToTLSPort(t *testing.T) {
141 w.WriteHeader(http.StatusOK)140 w.WriteHeader(http.StatusOK)
142 })141 })
143 142
144- handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok)143+ handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "o2.bnema.dev" })(ok)
145 srv := httptest.NewServer(handler)144 srv := httptest.NewServer(handler)
146 defer srv.Close()145 defer srv.Close()
147 146
@@ -164,7 +163,7 @@ func TestHTTPSRedirect_UnknownExplicitPort_IsPreserved(t *testing.T) {
164 w.WriteHeader(http.StatusOK)163 w.WriteHeader(http.StatusOK)
165 })164 })
166 165
167- handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok)166+ handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "o2.bnema.dev" })(ok)
168 srv := httptest.NewServer(handler)167 srv := httptest.NewServer(handler)
169 defer srv.Close()168 defer srv.Close()
170 169
@@ -181,31 +180,54 @@ func TestHTTPSRedirect_UnknownExplicitPort_IsPreserved(t *testing.T) {
181 assert.Equal(t, "https://o2.bnema.dev:9999/", resp.Header.Get("Location"))180 assert.Equal(t, "https://o2.bnema.dev:9999/", resp.Header.Get("Location"))
182 }181 }
183 182
184-func TestHTTPSRedirect_InvalidExplicitPort_IsPreserved(t *testing.T) {183+func TestHTTPSRedirect_TrailingDotHostRedirectsToCanonicalHost(t *testing.T) {
185 log := testLogger()184 log := testLogger()
186 ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {185 ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
187 w.WriteHeader(http.StatusOK)186 w.WriteHeader(http.StatusOK)
188 })187 })
188+ handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "o2.bnema.dev" })(ok)
189 189
190- handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok)190+ req := httptest.NewRequest(http.MethodGet, "http://example.test/path", nil)
191- srv := httptest.NewServer(handler)191+ req.RequestURI = "/path"
192- defer srv.Close()192+ req.Host = "O2.Bnema.Dev.:8088"
193+ rec := httptest.NewRecorder()
193 194
194- // Use a raw TCP connection because Go's HTTP client rejects the195+ handler.ServeHTTP(rec, req)
195- // invalid port in the Location header before CheckRedirect fires.
196- conn, err := net.Dial("tcp", srv.Listener.Addr().String())
197- require.NoError(t, err)
198- defer conn.Close()
199 196
200- _, err = fmt.Fprintf(conn, "GET / HTTP/1.1\r\nHost: o2.bnema.dev:abcd\r\nConnection: close\r\n\r\n")197+ assert.Equal(t, http.StatusPermanentRedirect, rec.Code)
201- require.NoError(t, err)198+ assert.Equal(t, "https://o2.bnema.dev:8443/path", rec.Header().Get("Location"))
199+}
200+
201+func TestHTTPSRedirect_RejectsInvalidHost(t *testing.T) {
202+ log := testLogger()
203+ ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
204+ w.WriteHeader(http.StatusOK)
205+ })
206+ handler := HTTPSRedirect(nil, 8088, 8443, true, log, func(host string) bool { return host == "o2.bnema.dev" })(ok)
202 207
203- var buf [4096]byte208+ tests := []struct {
204- n, _ := conn.Read(buf[:])209+ name string
205- raw := string(buf[:n])210+ host string
211+ }{
212+ {name: "invalid port", host: "o2.bnema.dev:abcd"},
213+
214+ {name: "localhost", host: "localhost"},
215+ {name: "ipv6", host: "[::1]:8088"},
216+ {name: "unconfigured public host", host: "evil.example.com"},
217+ }
218+
219+ for _, tt := range tests {
220+ t.Run(tt.name, func(t *testing.T) {
221+ req := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
222+ req.Host = tt.host
223+ rec := httptest.NewRecorder()
224+
225+ handler.ServeHTTP(rec, req)
206 226
207- assert.Contains(t, raw, "308")227+ assert.Equal(t, http.StatusBadRequest, rec.Code)
208- assert.Contains(t, raw, "Location: https://o2.bnema.dev:abcd/")228+ assert.Empty(t, rec.Header().Get("Location"))
229+ })
230+ }
209 }231 }
210 232
211 func TestProxyCIDRAllowlist(t *testing.T) {233 func TestProxyCIDRAllowlist(t *testing.T) {
M internal/adapters/in/http/proxy/handler.go +27 -3
@@ -4,6 +4,8 @@ package proxy
44 import (
55 "net"
66 "net/http"
7+ "strconv"
8+ "strings"
79 "sync/atomic"
810
911 "github.com/bnema/zerowrap"
@@ -70,16 +72,22 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
7072 r = r.WithContext(ctx)
7173 log := zerowrap.FromCtx(ctx)
7274
75+ host := normalizeRequestHost(r.Host)
76+ if host == "" {
77+ proxyError(w, "Bad Request", http.StatusBadRequest)
78+ return
79+ }
80+
7381 // Check if this is the registry domain
74- if h.proxySvc.IsRegistryDomain(r.Host) {
82+ if h.proxySvc.IsRegistryDomain(host) {
7583 log.Debug().Msg("routing request to registry")
7684 h.forwardToRegistry(w, r, cfg.RegistryPort)
7785 return
7886 }
7987
8088 // Get target for this domain
81- log.Debug().Str("resolving_target_for", r.Host).Msg("looking up proxy target")
82- target, err := h.proxySvc.GetTarget(ctx, r.Host)
89+ log.Debug().Str("resolving_target_for", host).Msg("looking up proxy target")
90+ target, err := h.proxySvc.GetTarget(ctx, host)
8391 if err != nil {
8492 log.Warn().Err(err).Msg("no route found for domain")
8593 proxyError(w, "404 page not found", http.StatusNotFound)
@@ -94,3 +102,19 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
94102
95103 h.forwardToTarget(w, r, target, cfg.MaxResponseSize)
96104 }
105+
106+func normalizeRequestHost(host string) string {
107+ host = strings.TrimSpace(host)
108+ if h, port, err := net.SplitHostPort(host); err == nil {
109+ portNum, err := strconv.Atoi(port)
110+ if err != nil || portNum < 1 || portNum > 65535 {
111+ return ""
112+ }
113+ host = h
114+ } else if strings.Contains(host, ":") {
115+ return ""
116+ }
117+ host = strings.ToLower(host)
118+ host = strings.TrimSuffix(host, ".")
119+ return host
120+}
@@ -4,6 +4,8 @@ package proxy
4 import (4 import (
5 "net"5 "net"
6 "net/http"6 "net/http"
7+ "strconv"
8+ "strings"
7 "sync/atomic"9 "sync/atomic"
8 10
9 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
@@ -70,16 +72,22 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
70 r = r.WithContext(ctx)72 r = r.WithContext(ctx)
71 log := zerowrap.FromCtx(ctx)73 log := zerowrap.FromCtx(ctx)
72 74
75+ host := normalizeRequestHost(r.Host)
76+ if host == "" {
77+ proxyError(w, "Bad Request", http.StatusBadRequest)
78+ return
79+ }
80+
73 // Check if this is the registry domain81 // Check if this is the registry domain
74- if h.proxySvc.IsRegistryDomain(r.Host) {82+ if h.proxySvc.IsRegistryDomain(host) {
75 log.Debug().Msg("routing request to registry")83 log.Debug().Msg("routing request to registry")
76 h.forwardToRegistry(w, r, cfg.RegistryPort)84 h.forwardToRegistry(w, r, cfg.RegistryPort)
77 return85 return
78 }86 }
79 87
80 // Get target for this domain88 // Get target for this domain
81- log.Debug().Str("resolving_target_for", r.Host).Msg("looking up proxy target")89+ log.Debug().Str("resolving_target_for", host).Msg("looking up proxy target")
82- target, err := h.proxySvc.GetTarget(ctx, r.Host)90+ target, err := h.proxySvc.GetTarget(ctx, host)
83 if err != nil {91 if err != nil {
84 log.Warn().Err(err).Msg("no route found for domain")92 log.Warn().Err(err).Msg("no route found for domain")
85 proxyError(w, "404 page not found", http.StatusNotFound)93 proxyError(w, "404 page not found", http.StatusNotFound)
@@ -94,3 +102,19 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
94 102
95 h.forwardToTarget(w, r, target, cfg.MaxResponseSize)103 h.forwardToTarget(w, r, target, cfg.MaxResponseSize)
96 }104 }
105+
106+func normalizeRequestHost(host string) string {
107+ host = strings.TrimSpace(host)
108+ if h, port, err := net.SplitHostPort(host); err == nil {
109+ portNum, err := strconv.Atoi(port)
110+ if err != nil || portNum < 1 || portNum > 65535 {
111+ return ""
112+ }
113+ host = h
114+ } else if strings.Contains(host, ":") {
115+ return ""
116+ }
117+ host = strings.ToLower(host)
118+ host = strings.TrimSuffix(host, ".")
119+ return host
120+}
M internal/adapters/in/http/proxy/handler_test.go +46 -0
@@ -61,6 +61,52 @@ func TestHandler_RoutesToRegistry(t *testing.T) {
6161 assert.True(t, w.Code == http.StatusBadGateway || w.Code == http.StatusServiceUnavailable)
6262 }
6363
64+func TestHandler_NormalizesRequestHostForLookup(t *testing.T) {
65+ tests := []struct {
66+ name string
67+ host string
68+ wantHost string
69+ }{
70+ {name: "mixed case", host: "App.Example.com", wantHost: "app.example.com"},
71+ {name: "explicit port", host: "App.Example.com:8080", wantHost: "app.example.com"},
72+ {name: "trailing dot", host: "App.Example.com.", wantHost: "app.example.com"},
73+ }
74+
75+ for _, tt := range tests {
76+ t.Run(tt.name, func(t *testing.T) {
77+ proxySvc := inmocks.NewMockProxyService(t)
78+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
79+ proxySvc.EXPECT().IsRegistryDomain(tt.wantHost).Return(false)
80+ proxySvc.EXPECT().GetTarget(mock.Anything, tt.wantHost).Return(nil, domain.ErrNoTargetAvailable)
81+
82+ handler := NewHandler(proxySvc, nil, testLogger())
83+ req := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
84+ req.Host = tt.host
85+ w := httptest.NewRecorder()
86+
87+ handler.ServeHTTP(w, req)
88+
89+ assert.Equal(t, http.StatusNotFound, w.Code)
90+ })
91+ }
92+}
93+
94+func TestHandler_RejectsInvalidRequestHost(t *testing.T) {
95+ proxySvc := inmocks.NewMockProxyService(t)
96+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
97+
98+ handler := NewHandler(proxySvc, nil, testLogger())
99+ for _, host := range []string{"app.example.com:bad", "app.example.com:0", "app.example.com:99999"} {
100+ req := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
101+ req.Host = host
102+ w := httptest.NewRecorder()
103+
104+ handler.ServeHTTP(w, req)
105+
106+ assert.Equal(t, http.StatusBadRequest, w.Code)
107+ }
108+}
109+
64110 func TestHandler_Returns404WhenNoTarget(t *testing.T) {
65111 proxySvc := inmocks.NewMockProxyService(t)
66112
@@ -61,6 +61,52 @@ func TestHandler_RoutesToRegistry(t *testing.T) {
61 assert.True(t, w.Code == http.StatusBadGateway || w.Code == http.StatusServiceUnavailable)61 assert.True(t, w.Code == http.StatusBadGateway || w.Code == http.StatusServiceUnavailable)
62 }62 }
63 63
64+func TestHandler_NormalizesRequestHostForLookup(t *testing.T) {
65+ tests := []struct {
66+ name string
67+ host string
68+ wantHost string
69+ }{
70+ {name: "mixed case", host: "App.Example.com", wantHost: "app.example.com"},
71+ {name: "explicit port", host: "App.Example.com:8080", wantHost: "app.example.com"},
72+ {name: "trailing dot", host: "App.Example.com.", wantHost: "app.example.com"},
73+ }
74+
75+ for _, tt := range tests {
76+ t.Run(tt.name, func(t *testing.T) {
77+ proxySvc := inmocks.NewMockProxyService(t)
78+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
79+ proxySvc.EXPECT().IsRegistryDomain(tt.wantHost).Return(false)
80+ proxySvc.EXPECT().GetTarget(mock.Anything, tt.wantHost).Return(nil, domain.ErrNoTargetAvailable)
81+
82+ handler := NewHandler(proxySvc, nil, testLogger())
83+ req := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
84+ req.Host = tt.host
85+ w := httptest.NewRecorder()
86+
87+ handler.ServeHTTP(w, req)
88+
89+ assert.Equal(t, http.StatusNotFound, w.Code)
90+ })
91+ }
92+}
93+
94+func TestHandler_RejectsInvalidRequestHost(t *testing.T) {
95+ proxySvc := inmocks.NewMockProxyService(t)
96+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
97+
98+ handler := NewHandler(proxySvc, nil, testLogger())
99+ for _, host := range []string{"app.example.com:bad", "app.example.com:0", "app.example.com:99999"} {
100+ req := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
101+ req.Host = host
102+ w := httptest.NewRecorder()
103+
104+ handler.ServeHTTP(w, req)
105+
106+ assert.Equal(t, http.StatusBadRequest, w.Code)
107+ }
108+}
109+
64 func TestHandler_Returns404WhenNoTarget(t *testing.T) {110 func TestHandler_Returns404WhenNoTarget(t *testing.T) {
65 proxySvc := inmocks.NewMockProxyService(t)111 proxySvc := inmocks.NewMockProxyService(t)
66 112
M internal/adapters/out/docker/runtime.go +34 -22
@@ -1503,10 +1503,7 @@ func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath st
15031503 // extractFileFromTar extracts a single file from a tar archive.
15041504 func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, error) {
15051505 tr := tar.NewReader(reader)
1506-
1507- // The path in the tar may be relative or absolute
1508- // We need to match based on the filename
1509- targetName := filepath.Base(targetPath)
1506+ targetName := normalizedTarPath(targetPath)
15101507
15111508 for {
15121509 header, err := tr.Next()
@@ -1517,23 +1514,19 @@ func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser,
15171514 return nil, err
15181515 }
15191516
1520- // Check if this is our target file
1521- if header.Typeflag == tar.TypeReg {
1522- headerName := filepath.Base(header.Name)
1523- if headerName == targetName {
1524- size := header.Size
1525- pr, pw := io.Pipe()
1526- go func() {
1527- defer reader.Close()
1528- _, copyErr := io.CopyN(pw, tr, size)
1529- if copyErr != nil {
1530- _ = pw.CloseWithError(copyErr)
1531- return
1532- }
1533- _ = pw.Close()
1534- }()
1535- return &pipeReadCloser{pr: pr, pw: pw, original: reader}, nil
1536- }
1517+ if header.Typeflag == tar.TypeReg && normalizedTarPath(header.Name) == targetName {
1518+ size := header.Size
1519+ pr, pw := io.Pipe()
1520+ go func() {
1521+ defer reader.Close()
1522+ _, copyErr := io.CopyN(pw, tr, size)
1523+ if copyErr != nil {
1524+ _ = pw.CloseWithError(copyErr)
1525+ return
1526+ }
1527+ _ = pw.Close()
1528+ }()
1529+ return &pipeReadCloser{pr: pr, pw: pw, original: reader}, nil
15371530 }
15381531 }
15391532
@@ -1541,6 +1534,25 @@ func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser,
15411534 return nil, fmt.Errorf("file not found in container: %s", targetPath)
15421535 }
15431536
1537+func normalizedTarPath(path string) string {
1538+ path = filepath.ToSlash(filepath.Clean(path))
1539+ return strings.TrimPrefix(path, "/")
1540+}
1541+
1542+const maxExtractedEnvFileSize = 1 << 20 // 1 MiB
1543+
1544+func readExtractedEnvFile(reader io.Reader) ([]byte, error) {
1545+ limited := io.LimitReader(reader, maxExtractedEnvFileSize+1)
1546+ data, err := io.ReadAll(limited)
1547+ if err != nil {
1548+ return nil, err
1549+ }
1550+ if len(data) > maxExtractedEnvFileSize {
1551+ return nil, fmt.Errorf("extracted env file exceeds %d bytes", maxExtractedEnvFileSize)
1552+ }
1553+ return data, nil
1554+}
1555+
15441556 // ExtractEnvFileFromImage extracts an env file from an image.
15451557 // This creates a temporary container, copies the file, and removes the container.
15461558 func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) {
@@ -1583,7 +1595,7 @@ func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFile
15831595 }
15841596 defer reader.Close()
15851597
1586- data, err := io.ReadAll(reader)
1598+ data, err := readExtractedEnvFile(reader)
15871599 if err != nil {
15881600 return nil, log.WrapErr(err, "failed to read extracted env file")
15891601 }
@@ -1503,10 +1503,7 @@ func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath st
1503 // extractFileFromTar extracts a single file from a tar archive.1503 // extractFileFromTar extracts a single file from a tar archive.
1504 func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, error) {1504 func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, error) {
1505 tr := tar.NewReader(reader)1505 tr := tar.NewReader(reader)
1506-1506+ targetName := normalizedTarPath(targetPath)
1507- // The path in the tar may be relative or absolute
1508- // We need to match based on the filename
1509- targetName := filepath.Base(targetPath)
1510 1507
1511 for {1508 for {
1512 header, err := tr.Next()1509 header, err := tr.Next()
@@ -1517,23 +1514,19 @@ func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser,
1517 return nil, err1514 return nil, err
1518 }1515 }
1519 1516
1520- // Check if this is our target file1517+ if header.Typeflag == tar.TypeReg && normalizedTarPath(header.Name) == targetName {
1521- if header.Typeflag == tar.TypeReg {1518+ size := header.Size
1522- headerName := filepath.Base(header.Name)1519+ pr, pw := io.Pipe()
1523- if headerName == targetName {1520+ go func() {
1524- size := header.Size1521+ defer reader.Close()
1525- pr, pw := io.Pipe()1522+ _, copyErr := io.CopyN(pw, tr, size)
1526- go func() {1523+ if copyErr != nil {
1527- defer reader.Close()1524+ _ = pw.CloseWithError(copyErr)
1528- _, copyErr := io.CopyN(pw, tr, size)1525+ return
1529- if copyErr != nil {1526+ }
1530- _ = pw.CloseWithError(copyErr)1527+ _ = pw.Close()
1531- return1528+ }()
1532- }1529+ return &pipeReadCloser{pr: pr, pw: pw, original: reader}, nil
1533- _ = pw.Close()
1534- }()
1535- return &pipeReadCloser{pr: pr, pw: pw, original: reader}, nil
1536- }
1537 }1530 }
1538 }1531 }
1539 1532
@@ -1541,6 +1534,25 @@ func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser,
1541 return nil, fmt.Errorf("file not found in container: %s", targetPath)1534 return nil, fmt.Errorf("file not found in container: %s", targetPath)
1542 }1535 }
1543 1536
1537+func normalizedTarPath(path string) string {
1538+ path = filepath.ToSlash(filepath.Clean(path))
1539+ return strings.TrimPrefix(path, "/")
1540+}
1541+
1542+const maxExtractedEnvFileSize = 1 << 20 // 1 MiB
1543+
1544+func readExtractedEnvFile(reader io.Reader) ([]byte, error) {
1545+ limited := io.LimitReader(reader, maxExtractedEnvFileSize+1)
1546+ data, err := io.ReadAll(limited)
1547+ if err != nil {
1548+ return nil, err
1549+ }
1550+ if len(data) > maxExtractedEnvFileSize {
1551+ return nil, fmt.Errorf("extracted env file exceeds %d bytes", maxExtractedEnvFileSize)
1552+ }
1553+ return data, nil
1554+}
1555+
1544 // ExtractEnvFileFromImage extracts an env file from an image.1556 // ExtractEnvFileFromImage extracts an env file from an image.
1545 // This creates a temporary container, copies the file, and removes the container.1557 // This creates a temporary container, copies the file, and removes the container.
1546 func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) {1558 func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) {
@@ -1583,7 +1595,7 @@ func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFile
1583 }1595 }
1584 defer reader.Close()1596 defer reader.Close()
1585 1597
1586- data, err := io.ReadAll(reader)1598+ data, err := readExtractedEnvFile(reader)
1587 if err != nil {1599 if err != nil {
1588 return nil, log.WrapErr(err, "failed to read extracted env file")1600 return nil, log.WrapErr(err, "failed to read extracted env file")
1589 }1601 }
M internal/adapters/out/docker/runtime_exec_test.go +30 -0
@@ -1,9 +1,12 @@
11 package docker
22
33 import (
4+ "archive/tar"
45 "bytes"
56 "context"
67 "encoding/binary"
8+ "io"
9+ "strings"
710 "testing"
811
912 "github.com/stretchr/testify/assert"
@@ -47,3 +50,30 @@ func frameDockerStream(streamID byte, payload []byte) []byte {
4750 copy(frame[8:], payload)
4851 return frame
4952 }
53+
54+func TestExtractFileFromTarMatchesFullCleanPath(t *testing.T) {
55+ var buf bytes.Buffer
56+ tw := tar.NewWriter(&buf)
57+ require.NoError(t, tw.WriteHeader(&tar.Header{Name: "other/.env", Typeflag: tar.TypeReg, Size: int64(len("wrong"))}))
58+ _, err := tw.Write([]byte("wrong"))
59+ require.NoError(t, err)
60+ require.NoError(t, tw.WriteHeader(&tar.Header{Name: "app/.env", Typeflag: tar.TypeReg, Size: int64(len("right"))}))
61+ _, err = tw.Write([]byte("right"))
62+ require.NoError(t, err)
63+ require.NoError(t, tw.Close())
64+
65+ rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/app/.env")
66+ require.NoError(t, err)
67+ defer rc.Close()
68+
69+ data, err := io.ReadAll(rc)
70+ require.NoError(t, err)
71+ assert.Equal(t, "right", string(data))
72+}
73+
74+func TestReadExtractedEnvFileRejectsOversizedContent(t *testing.T) {
75+ data, err := readExtractedEnvFile(io.NopCloser(strings.NewReader(strings.Repeat("a", maxExtractedEnvFileSize+1))))
76+ require.Error(t, err)
77+ assert.Nil(t, data)
78+ assert.Contains(t, err.Error(), "exceeds")
79+}
@@ -1,9 +1,12 @@
1 package docker1 package docker
2 2
3 import (3 import (
4+ "archive/tar"
4 "bytes"5 "bytes"
5 "context"6 "context"
6 "encoding/binary"7 "encoding/binary"
8+ "io"
9+ "strings"
7 "testing"10 "testing"
8 11
9 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
@@ -47,3 +50,30 @@ func frameDockerStream(streamID byte, payload []byte) []byte {
47 copy(frame[8:], payload)50 copy(frame[8:], payload)
48 return frame51 return frame
49 }52 }
53+
54+func TestExtractFileFromTarMatchesFullCleanPath(t *testing.T) {
55+ var buf bytes.Buffer
56+ tw := tar.NewWriter(&buf)
57+ require.NoError(t, tw.WriteHeader(&tar.Header{Name: "other/.env", Typeflag: tar.TypeReg, Size: int64(len("wrong"))}))
58+ _, err := tw.Write([]byte("wrong"))
59+ require.NoError(t, err)
60+ require.NoError(t, tw.WriteHeader(&tar.Header{Name: "app/.env", Typeflag: tar.TypeReg, Size: int64(len("right"))}))
61+ _, err = tw.Write([]byte("right"))
62+ require.NoError(t, err)
63+ require.NoError(t, tw.Close())
64+
65+ rc, err := extractFileFromTar(io.NopCloser(bytes.NewReader(buf.Bytes())), "/app/.env")
66+ require.NoError(t, err)
67+ defer rc.Close()
68+
69+ data, err := io.ReadAll(rc)
70+ require.NoError(t, err)
71+ assert.Equal(t, "right", string(data))
72+}
73+
74+func TestReadExtractedEnvFileRejectsOversizedContent(t *testing.T) {
75+ data, err := readExtractedEnvFile(io.NopCloser(strings.NewReader(strings.Repeat("a", maxExtractedEnvFileSize+1))))
76+ require.Error(t, err)
77+ assert.Nil(t, data)
78+ assert.Contains(t, err.Error(), "exceeds")
79+}
M internal/app/run.go +3 -1
@@ -1797,7 +1797,9 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
17971797 middleware.PanicRecovery(log),
17981798 middleware.RequestLogger(log, trustedNets),
17991799 middleware.SecurityHeaders,
1800- middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.Port, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log),
1800+ middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.Port, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log, func(host string) bool {
1801+ return svc.proxySvc.IsKnownHost(context.Background(), host)
1802+ }),
18011803 }
18021804 if proxyCIDRMiddleware != nil {
18031805 httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware)
@@ -1797,7 +1797,9 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
1797 middleware.PanicRecovery(log),1797 middleware.PanicRecovery(log),
1798 middleware.RequestLogger(log, trustedNets),1798 middleware.RequestLogger(log, trustedNets),
1799 middleware.SecurityHeaders,1799 middleware.SecurityHeaders,
1800- middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.Port, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log),1800+ middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.Port, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log, func(host string) bool {
1801+ return svc.proxySvc.IsKnownHost(context.Background(), host)
1802+ }),
1801 }1803 }
1802 if proxyCIDRMiddleware != nil {1804 if proxyCIDRMiddleware != nil {
1803 httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware)1805 httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware)
M internal/boundaries/in/mocks/mock_proxy_service.go +57 -0
@@ -107,6 +107,63 @@ func (_c *MockProxyService_GetTarget_Call) RunAndReturn(run func(ctx context.Con
107107 return _c
108108 }
109109
110+// IsKnownHost provides a mock function for the type MockProxyService
111+func (_mock *MockProxyService) IsKnownHost(ctx context.Context, host string) bool {
112+ ret := _mock.Called(ctx, host)
113+
114+ if len(ret) == 0 {
115+ panic("no return value specified for IsKnownHost")
116+ }
117+
118+ var r0 bool
119+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok {
120+ r0 = returnFunc(ctx, host)
121+ } else {
122+ r0 = ret.Get(0).(bool)
123+ }
124+ return r0
125+}
126+
127+// MockProxyService_IsKnownHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsKnownHost'
128+type MockProxyService_IsKnownHost_Call struct {
129+ *mock.Call
130+}
131+
132+// IsKnownHost is a helper method to define mock.On call
133+// - ctx context.Context
134+// - host string
135+func (_e *MockProxyService_Expecter) IsKnownHost(ctx interface{}, host interface{}) *MockProxyService_IsKnownHost_Call {
136+ return &MockProxyService_IsKnownHost_Call{Call: _e.mock.On("IsKnownHost", ctx, host)}
137+}
138+
139+func (_c *MockProxyService_IsKnownHost_Call) Run(run func(ctx context.Context, host string)) *MockProxyService_IsKnownHost_Call {
140+ _c.Call.Run(func(args mock.Arguments) {
141+ var arg0 context.Context
142+ if args[0] != nil {
143+ arg0 = args[0].(context.Context)
144+ }
145+ var arg1 string
146+ if args[1] != nil {
147+ arg1 = args[1].(string)
148+ }
149+ run(
150+ arg0,
151+ arg1,
152+ )
153+ })
154+ return _c
155+}
156+
157+func (_c *MockProxyService_IsKnownHost_Call) Return(b bool) *MockProxyService_IsKnownHost_Call {
158+ _c.Call.Return(b)
159+ return _c
160+}
161+
162+func (_c *MockProxyService_IsKnownHost_Call) RunAndReturn(run func(ctx context.Context, host string) bool) *MockProxyService_IsKnownHost_Call {
163+ _c.Call.Return(run)
164+ return _c
165+}
166+
110167 // IsRegistryDomain provides a mock function for the type MockProxyService
111168 func (_mock *MockProxyService) IsRegistryDomain(host string) bool {
112169 ret := _mock.Called(host)
@@ -107,6 +107,63 @@ func (_c *MockProxyService_GetTarget_Call) RunAndReturn(run func(ctx context.Con
107 return _c107 return _c
108 }108 }
109 109
110+// IsKnownHost provides a mock function for the type MockProxyService
111+func (_mock *MockProxyService) IsKnownHost(ctx context.Context, host string) bool {
112+ ret := _mock.Called(ctx, host)
113+
114+ if len(ret) == 0 {
115+ panic("no return value specified for IsKnownHost")
116+ }
117+
118+ var r0 bool
119+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok {
120+ r0 = returnFunc(ctx, host)
121+ } else {
122+ r0 = ret.Get(0).(bool)
123+ }
124+ return r0
125+}
126+
127+// MockProxyService_IsKnownHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsKnownHost'
128+type MockProxyService_IsKnownHost_Call struct {
129+ *mock.Call
130+}
131+
132+// IsKnownHost is a helper method to define mock.On call
133+// - ctx context.Context
134+// - host string
135+func (_e *MockProxyService_Expecter) IsKnownHost(ctx interface{}, host interface{}) *MockProxyService_IsKnownHost_Call {
136+ return &MockProxyService_IsKnownHost_Call{Call: _e.mock.On("IsKnownHost", ctx, host)}
137+}
138+
139+func (_c *MockProxyService_IsKnownHost_Call) Run(run func(ctx context.Context, host string)) *MockProxyService_IsKnownHost_Call {
140+ _c.Call.Run(func(args mock.Arguments) {
141+ var arg0 context.Context
142+ if args[0] != nil {
143+ arg0 = args[0].(context.Context)
144+ }
145+ var arg1 string
146+ if args[1] != nil {
147+ arg1 = args[1].(string)
148+ }
149+ run(
150+ arg0,
151+ arg1,
152+ )
153+ })
154+ return _c
155+}
156+
157+func (_c *MockProxyService_IsKnownHost_Call) Return(b bool) *MockProxyService_IsKnownHost_Call {
158+ _c.Call.Return(b)
159+ return _c
160+}
161+
162+func (_c *MockProxyService_IsKnownHost_Call) RunAndReturn(run func(ctx context.Context, host string) bool) *MockProxyService_IsKnownHost_Call {
163+ _c.Call.Return(run)
164+ return _c
165+}
166+
110 // IsRegistryDomain provides a mock function for the type MockProxyService167 // IsRegistryDomain provides a mock function for the type MockProxyService
111 func (_mock *MockProxyService) IsRegistryDomain(host string) bool {168 func (_mock *MockProxyService) IsRegistryDomain(host string) bool {
112 ret := _mock.Called(host)169 ret := _mock.Called(host)
M internal/boundaries/in/proxy.go +3 -0
@@ -24,6 +24,9 @@ type ProxyService interface {
2424 // IsRegistryDomain returns true if the host matches the configured registry domain.
2525 IsRegistryDomain(host string) bool
2626
27+ // IsKnownHost returns true if the host is configured as registry, route, or external route.
28+ IsKnownHost(ctx context.Context, host string) bool
29+
2730 // TrackInFlight records an in-flight request for a container.
2831 // Returns a release function that must be called when the request completes.
2932 TrackInFlight(containerID string) func()
@@ -24,6 +24,9 @@ type ProxyService interface {
24 // IsRegistryDomain returns true if the host matches the configured registry domain.24 // IsRegistryDomain returns true if the host matches the configured registry domain.
25 IsRegistryDomain(host string) bool25 IsRegistryDomain(host string) bool
26 26
27+ // IsKnownHost returns true if the host is configured as registry, route, or external route.
28+ IsKnownHost(ctx context.Context, host string) bool
29+
27 // TrackInFlight records an in-flight request for a container.30 // TrackInFlight records an in-flight request for a container.
28 // Returns a release function that must be called when the request completes.31 // Returns a release function that must be called when the request completes.
29 TrackInFlight(containerID string) func()32 TrackInFlight(containerID string) func()
M internal/domain/validation.go +8 -0
@@ -17,6 +17,14 @@ var hostnamePattern = regexp.MustCompile(`^[a-zA-Z0-9.-]+$`)
1717 //
1818 // Valid: app.example.com, api.site.org, my-app.co.uk
1919 // Invalid: 192.168.1.1, localhost, myapp.local, example.com:8080
20+func CanonicalRouteDomain(domainName string) (string, bool) {
21+ canonical := strings.ToLower(strings.TrimSpace(domainName))
22+ if !IsValidRouteDomain(canonical) {
23+ return "", false
24+ }
25+ return canonical, true
26+}
27+
2028 func IsValidRouteDomain(domain string) bool {
2129 // Reject empty
2230 if domain == "" {
@@ -17,6 +17,14 @@ var hostnamePattern = regexp.MustCompile(`^[a-zA-Z0-9.-]+$`)
17 //17 //
18 // Valid: app.example.com, api.site.org, my-app.co.uk18 // Valid: app.example.com, api.site.org, my-app.co.uk
19 // Invalid: 192.168.1.1, localhost, myapp.local, example.com:808019 // Invalid: 192.168.1.1, localhost, myapp.local, example.com:8080
20+func CanonicalRouteDomain(domainName string) (string, bool) {
21+ canonical := strings.ToLower(strings.TrimSpace(domainName))
22+ if !IsValidRouteDomain(canonical) {
23+ return "", false
24+ }
25+ return canonical, true
26+}
27+
20 func IsValidRouteDomain(domain string) bool {28 func IsValidRouteDomain(domain string) bool {
21 // Reject empty29 // Reject empty
22 if domain == "" {30 if domain == "" {
M internal/usecase/config/service.go +73 -11
@@ -95,7 +95,16 @@ func (s *Service) Load(ctx context.Context) error {
9595 return log.WrapErr(err, "failed to load routes")
9696 }
9797 newConfig.Routes = routes
98- newConfig.ExternalRoutes = loadStringMap(s.viper.Get("external_routes"))
98+ externalRoutes, err := loadExternalRoutes(s.viper.Get("external_routes"))
99+ if err != nil {
100+ return log.WrapErr(err, "failed to load external routes")
101+ }
102+ for domainName := range externalRoutes {
103+ if _, exists := routes[domainName]; exists {
104+ return fmt.Errorf("external route %q conflicts with configured route", domainName)
105+ }
106+ }
107+ newConfig.ExternalRoutes = externalRoutes
99108 newConfig.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups"))
100109 newConfig.Attachments = loadStringArrayMap(s.viper.Get("attachments"))
101110
@@ -208,6 +217,33 @@ func loadStringMap(raw any) map[string]string {
208217 return result
209218 }
210219
220+// loadExternalRoutes loads and canonicalizes external route mappings.
221+func loadExternalRoutes(raw any) (map[string]string, error) {
222+ result := make(map[string]string)
223+ if raw == nil {
224+ return result, nil
225+ }
226+ m, ok := raw.(map[string]any)
227+ if !ok {
228+ return result, nil
229+ }
230+ for k, v := range m {
231+ vs, ok := v.(string)
232+ if !ok {
233+ continue
234+ }
235+ canonicalKey, ok := domain.CanonicalRouteDomain(k)
236+ if !ok {
237+ return nil, fmt.Errorf("invalid external route key %q: %w", k, domain.ErrRouteDomainInvalid)
238+ }
239+ if _, exists := result[canonicalKey]; exists {
240+ return nil, fmt.Errorf("duplicate external route key %q canonicalizes to %q", k, canonicalKey)
241+ }
242+ result[canonicalKey] = vs
243+ }
244+ return result, nil
245+}
246+
211247 // loadStringArrayMap loads a map[string][]string from a viper value.
212248 func loadStringArrayMap(raw any) map[string][]string {
213249 result := make(map[string][]string)
@@ -252,7 +288,11 @@ func loadCanonicalRoutes(raw any) (map[string]routeConfig, error) {
252288 if err != nil {
253289 return nil, err
254290 }
255- result[key] = route
291+ canonicalKey, _ := domain.CanonicalRouteDomain(key)
292+ if _, exists := result[canonicalKey]; exists {
293+ return nil, fmt.Errorf("duplicate route key %q canonicalizes to %q", key, canonicalKey)
294+ }
295+ result[canonicalKey] = route
256296 }
257297
258298 for key, value := range routes {
@@ -261,7 +301,11 @@ func loadCanonicalRoutes(raw any) (map[string]routeConfig, error) {
261301 }
262302
263303 domainName := strings.TrimPrefix(key, "http://")
264- if _, exists := result[domainName]; exists {
304+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
305+ if !ok {
306+ return nil, fmt.Errorf("invalid route key %q: %w", legacyRouteStorageKey(domainName), domain.ErrRouteDomainInvalid)
307+ }
308+ if _, exists := result[canonicalDomain]; exists {
265309 continue
266310 }
267311
@@ -269,16 +313,18 @@ func loadCanonicalRoutes(raw any) (map[string]routeConfig, error) {
269313 if err != nil {
270314 return nil, err
271315 }
272- result[domainName] = route
316+ result[canonicalDomain] = route
273317 }
274318
275319 return result, nil
276320 }
277321
278322 func parseCanonicalRouteEntry(domainName string, raw any) (routeConfig, error) {
279- if !domain.IsValidRouteDomain(domainName) {
323+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
324+ if !ok {
280325 return routeConfig{}, fmt.Errorf("invalid route key %q: %w", domainName, domain.ErrRouteDomainInvalid)
281326 }
327+ domainName = canonicalDomain
282328
283329 switch value := raw.(type) {
284330 case string:
@@ -294,9 +340,11 @@ func parseCanonicalRouteEntry(domainName string, raw any) (routeConfig, error) {
294340 }
295341
296342 func parseLegacyRouteEntry(domainName string, raw any) (routeConfig, error) {
297- if !domain.IsValidRouteDomain(domainName) {
343+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
344+ if !ok {
298345 return routeConfig{}, fmt.Errorf("invalid route key %q: %w", legacyRouteStorageKey(domainName), domain.ErrRouteDomainInvalid)
299346 }
347+ domainName = canonicalDomain
300348
301349 value, ok := raw.(string)
302350 if !ok {
@@ -348,6 +396,10 @@ func (s *Service) GetRoute(_ context.Context, domainName string) (*domain.Route,
348396 s.mu.RLock()
349397 defer s.mu.RUnlock()
350398
399+ domainName, ok := domain.CanonicalRouteDomain(domainName)
400+ if !ok {
401+ return nil, domain.ErrRouteNotFound
402+ }
351403 routeCfg, ok := s.config.Routes[domainName]
352404 if !ok {
353405 return nil, domain.ErrRouteNotFound
@@ -494,9 +546,11 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
494546 if route.Domain == "" {
495547 return domain.ErrRouteDomainEmpty
496548 }
497- if !domain.IsValidRouteDomain(route.Domain) {
549+ canonicalDomain, ok := domain.CanonicalRouteDomain(route.Domain)
550+ if !ok {
498551 return domain.ErrRouteDomainInvalid
499552 }
553+ route.Domain = canonicalDomain
500554 if route.Image == "" {
501555 return domain.ErrRouteImageEmpty
502556 }
@@ -507,6 +561,10 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
507561 if currentConfig.Routes == nil {
508562 currentConfig.Routes = make(map[string]routeConfig)
509563 }
564+ if _, exists := currentConfig.ExternalRoutes[route.Domain]; exists {
565+ s.mu.Unlock()
566+ return fmt.Errorf("route %q conflicts with external route", route.Domain)
567+ }
510568 previousCanonicalRoute, canonicalExisted := currentConfig.Routes[route.Domain]
511569 legacyKey := legacyRouteStorageKey(route.Domain)
512570 _, legacyExisted := currentConfig.Routes[legacyKey]
@@ -550,9 +608,11 @@ func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error {
550608 if route.Domain == "" {
551609 return domain.ErrRouteDomainEmpty
552610 }
553- if !domain.IsValidRouteDomain(route.Domain) {
611+ canonicalDomain, ok := domain.CanonicalRouteDomain(route.Domain)
612+ if !ok {
554613 return domain.ErrRouteDomainInvalid
555614 }
615+ route.Domain = canonicalDomain
556616 if route.Image == "" {
557617 return domain.ErrRouteImageEmpty
558618 }
@@ -601,14 +661,16 @@ func (s *Service) RemoveRoute(ctx context.Context, domainName string) error {
601661 if domainName == "" {
602662 return domain.ErrRouteDomainEmpty
603663 }
604- if !domain.IsValidRouteDomain(domainName) {
664+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
665+ if !ok {
605666 return domain.ErrRouteDomainInvalid
606667 }
668+ domainName = canonicalDomain
607669
608670 // Store previous value for rollback
609671 s.mu.Lock()
610672 currentConfig := s.config
611- _, ok := currentConfig.Routes[domainName]
673+ _, ok = currentConfig.Routes[domainName]
612674 if !ok {
613675 s.mu.Unlock()
614676 return domain.ErrRouteNotFound
@@ -1455,7 +1517,7 @@ func (s *Service) GetExternalRoutes() map[string]string {
14551517 s.mu.RLock()
14561518 defer s.mu.RUnlock()
14571519
1458- result := make(map[string]string)
1520+ result := make(map[string]string, len(s.config.ExternalRoutes))
14591521 for k, v := range s.config.ExternalRoutes {
14601522 result[k] = v
14611523 }
@@ -95,7 +95,16 @@ func (s *Service) Load(ctx context.Context) error {
95 return log.WrapErr(err, "failed to load routes")95 return log.WrapErr(err, "failed to load routes")
96 }96 }
97 newConfig.Routes = routes97 newConfig.Routes = routes
98- newConfig.ExternalRoutes = loadStringMap(s.viper.Get("external_routes"))98+ externalRoutes, err := loadExternalRoutes(s.viper.Get("external_routes"))
99+ if err != nil {
100+ return log.WrapErr(err, "failed to load external routes")
101+ }
102+ for domainName := range externalRoutes {
103+ if _, exists := routes[domainName]; exists {
104+ return fmt.Errorf("external route %q conflicts with configured route", domainName)
105+ }
106+ }
107+ newConfig.ExternalRoutes = externalRoutes
99 newConfig.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups"))108 newConfig.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups"))
100 newConfig.Attachments = loadStringArrayMap(s.viper.Get("attachments"))109 newConfig.Attachments = loadStringArrayMap(s.viper.Get("attachments"))
101 110
@@ -208,6 +217,33 @@ func loadStringMap(raw any) map[string]string {
208 return result217 return result
209 }218 }
210 219
220+// loadExternalRoutes loads and canonicalizes external route mappings.
221+func loadExternalRoutes(raw any) (map[string]string, error) {
222+ result := make(map[string]string)
223+ if raw == nil {
224+ return result, nil
225+ }
226+ m, ok := raw.(map[string]any)
227+ if !ok {
228+ return result, nil
229+ }
230+ for k, v := range m {
231+ vs, ok := v.(string)
232+ if !ok {
233+ continue
234+ }
235+ canonicalKey, ok := domain.CanonicalRouteDomain(k)
236+ if !ok {
237+ return nil, fmt.Errorf("invalid external route key %q: %w", k, domain.ErrRouteDomainInvalid)
238+ }
239+ if _, exists := result[canonicalKey]; exists {
240+ return nil, fmt.Errorf("duplicate external route key %q canonicalizes to %q", k, canonicalKey)
241+ }
242+ result[canonicalKey] = vs
243+ }
244+ return result, nil
245+}
246+
211 // loadStringArrayMap loads a map[string][]string from a viper value.247 // loadStringArrayMap loads a map[string][]string from a viper value.
212 func loadStringArrayMap(raw any) map[string][]string {248 func loadStringArrayMap(raw any) map[string][]string {
213 result := make(map[string][]string)249 result := make(map[string][]string)
@@ -252,7 +288,11 @@ func loadCanonicalRoutes(raw any) (map[string]routeConfig, error) {
252 if err != nil {288 if err != nil {
253 return nil, err289 return nil, err
254 }290 }
255- result[key] = route291+ canonicalKey, _ := domain.CanonicalRouteDomain(key)
292+ if _, exists := result[canonicalKey]; exists {
293+ return nil, fmt.Errorf("duplicate route key %q canonicalizes to %q", key, canonicalKey)
294+ }
295+ result[canonicalKey] = route
256 }296 }
257 297
258 for key, value := range routes {298 for key, value := range routes {
@@ -261,7 +301,11 @@ func loadCanonicalRoutes(raw any) (map[string]routeConfig, error) {
261 }301 }
262 302
263 domainName := strings.TrimPrefix(key, "http://")303 domainName := strings.TrimPrefix(key, "http://")
264- if _, exists := result[domainName]; exists {304+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
305+ if !ok {
306+ return nil, fmt.Errorf("invalid route key %q: %w", legacyRouteStorageKey(domainName), domain.ErrRouteDomainInvalid)
307+ }
308+ if _, exists := result[canonicalDomain]; exists {
265 continue309 continue
266 }310 }
267 311
@@ -269,16 +313,18 @@ func loadCanonicalRoutes(raw any) (map[string]routeConfig, error) {
269 if err != nil {313 if err != nil {
270 return nil, err314 return nil, err
271 }315 }
272- result[domainName] = route316+ result[canonicalDomain] = route
273 }317 }
274 318
275 return result, nil319 return result, nil
276 }320 }
277 321
278 func parseCanonicalRouteEntry(domainName string, raw any) (routeConfig, error) {322 func parseCanonicalRouteEntry(domainName string, raw any) (routeConfig, error) {
279- if !domain.IsValidRouteDomain(domainName) {323+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
324+ if !ok {
280 return routeConfig{}, fmt.Errorf("invalid route key %q: %w", domainName, domain.ErrRouteDomainInvalid)325 return routeConfig{}, fmt.Errorf("invalid route key %q: %w", domainName, domain.ErrRouteDomainInvalid)
281 }326 }
327+ domainName = canonicalDomain
282 328
283 switch value := raw.(type) {329 switch value := raw.(type) {
284 case string:330 case string:
@@ -294,9 +340,11 @@ func parseCanonicalRouteEntry(domainName string, raw any) (routeConfig, error) {
294 }340 }
295 341
296 func parseLegacyRouteEntry(domainName string, raw any) (routeConfig, error) {342 func parseLegacyRouteEntry(domainName string, raw any) (routeConfig, error) {
297- if !domain.IsValidRouteDomain(domainName) {343+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
344+ if !ok {
298 return routeConfig{}, fmt.Errorf("invalid route key %q: %w", legacyRouteStorageKey(domainName), domain.ErrRouteDomainInvalid)345 return routeConfig{}, fmt.Errorf("invalid route key %q: %w", legacyRouteStorageKey(domainName), domain.ErrRouteDomainInvalid)
299 }346 }
347+ domainName = canonicalDomain
300 348
301 value, ok := raw.(string)349 value, ok := raw.(string)
302 if !ok {350 if !ok {
@@ -348,6 +396,10 @@ func (s *Service) GetRoute(_ context.Context, domainName string) (*domain.Route,
348 s.mu.RLock()396 s.mu.RLock()
349 defer s.mu.RUnlock()397 defer s.mu.RUnlock()
350 398
399+ domainName, ok := domain.CanonicalRouteDomain(domainName)
400+ if !ok {
401+ return nil, domain.ErrRouteNotFound
402+ }
351 routeCfg, ok := s.config.Routes[domainName]403 routeCfg, ok := s.config.Routes[domainName]
352 if !ok {404 if !ok {
353 return nil, domain.ErrRouteNotFound405 return nil, domain.ErrRouteNotFound
@@ -494,9 +546,11 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
494 if route.Domain == "" {546 if route.Domain == "" {
495 return domain.ErrRouteDomainEmpty547 return domain.ErrRouteDomainEmpty
496 }548 }
497- if !domain.IsValidRouteDomain(route.Domain) {549+ canonicalDomain, ok := domain.CanonicalRouteDomain(route.Domain)
550+ if !ok {
498 return domain.ErrRouteDomainInvalid551 return domain.ErrRouteDomainInvalid
499 }552 }
553+ route.Domain = canonicalDomain
500 if route.Image == "" {554 if route.Image == "" {
501 return domain.ErrRouteImageEmpty555 return domain.ErrRouteImageEmpty
502 }556 }
@@ -507,6 +561,10 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
507 if currentConfig.Routes == nil {561 if currentConfig.Routes == nil {
508 currentConfig.Routes = make(map[string]routeConfig)562 currentConfig.Routes = make(map[string]routeConfig)
509 }563 }
564+ if _, exists := currentConfig.ExternalRoutes[route.Domain]; exists {
565+ s.mu.Unlock()
566+ return fmt.Errorf("route %q conflicts with external route", route.Domain)
567+ }
510 previousCanonicalRoute, canonicalExisted := currentConfig.Routes[route.Domain]568 previousCanonicalRoute, canonicalExisted := currentConfig.Routes[route.Domain]
511 legacyKey := legacyRouteStorageKey(route.Domain)569 legacyKey := legacyRouteStorageKey(route.Domain)
512 _, legacyExisted := currentConfig.Routes[legacyKey]570 _, legacyExisted := currentConfig.Routes[legacyKey]
@@ -550,9 +608,11 @@ func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error {
550 if route.Domain == "" {608 if route.Domain == "" {
551 return domain.ErrRouteDomainEmpty609 return domain.ErrRouteDomainEmpty
552 }610 }
553- if !domain.IsValidRouteDomain(route.Domain) {611+ canonicalDomain, ok := domain.CanonicalRouteDomain(route.Domain)
612+ if !ok {
554 return domain.ErrRouteDomainInvalid613 return domain.ErrRouteDomainInvalid
555 }614 }
615+ route.Domain = canonicalDomain
556 if route.Image == "" {616 if route.Image == "" {
557 return domain.ErrRouteImageEmpty617 return domain.ErrRouteImageEmpty
558 }618 }
@@ -601,14 +661,16 @@ func (s *Service) RemoveRoute(ctx context.Context, domainName string) error {
601 if domainName == "" {661 if domainName == "" {
602 return domain.ErrRouteDomainEmpty662 return domain.ErrRouteDomainEmpty
603 }663 }
604- if !domain.IsValidRouteDomain(domainName) {664+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
665+ if !ok {
605 return domain.ErrRouteDomainInvalid666 return domain.ErrRouteDomainInvalid
606 }667 }
668+ domainName = canonicalDomain
607 669
608 // Store previous value for rollback670 // Store previous value for rollback
609 s.mu.Lock()671 s.mu.Lock()
610 currentConfig := s.config672 currentConfig := s.config
611- _, ok := currentConfig.Routes[domainName]673+ _, ok = currentConfig.Routes[domainName]
612 if !ok {674 if !ok {
613 s.mu.Unlock()675 s.mu.Unlock()
614 return domain.ErrRouteNotFound676 return domain.ErrRouteNotFound
@@ -1455,7 +1517,7 @@ func (s *Service) GetExternalRoutes() map[string]string {
1455 s.mu.RLock()1517 s.mu.RLock()
1456 defer s.mu.RUnlock()1518 defer s.mu.RUnlock()
1457 1519
1458- result := make(map[string]string)1520+ result := make(map[string]string, len(s.config.ExternalRoutes))
1459 for k, v := range s.config.ExternalRoutes {1521 for k, v := range s.config.ExternalRoutes {
1460 result[k] = v1522 result[k] = v
1461 }1523 }
M internal/usecase/config/service_test.go +86 -1
@@ -458,6 +458,91 @@ func TestService_GetRoute(t *testing.T) {
458458 })
459459 }
460460
461+func TestLoadCanonicalRoutes_RejectsDuplicateCanonicalRouteKeys(t *testing.T) {
462+ _, err := loadCanonicalRoutes(map[string]any{
463+ "App.Example.com": "app:v1",
464+ "app.example.com": "app:v2",
465+ })
466+
467+ require.Error(t, err)
468+ assert.Contains(t, err.Error(), "duplicate route key")
469+}
470+
471+func TestService_Load_RejectsExternalRouteConflictingWithRoute(t *testing.T) {
472+ v := viper.New()
473+ v.Set("routes", map[string]any{
474+ "App.Example.com": "app:latest",
475+ })
476+ v.Set("external_routes", map[string]any{
477+ "app.example.com": "203.0.113.10:5000",
478+ })
479+
480+ svc := NewService(v, mocks.NewMockEventPublisher(t))
481+ err := svc.Load(testContext())
482+
483+ require.Error(t, err)
484+ assert.Contains(t, err.Error(), "conflicts with configured route")
485+}
486+
487+func TestLoadExternalRoutes_RejectsInvalidAndDuplicateKeys(t *testing.T) {
488+ t.Run("invalid", func(t *testing.T) {
489+ _, err := loadExternalRoutes(map[string]any{
490+ "bad.example.com:8080": "203.0.113.10:5000",
491+ })
492+
493+ require.Error(t, err)
494+ assert.Contains(t, err.Error(), "invalid external route key")
495+ })
496+
497+ t.Run("duplicate canonical", func(t *testing.T) {
498+ _, err := loadExternalRoutes(map[string]any{
499+ "Reg.Example.com": "203.0.113.10:5000",
500+ "reg.example.com": "203.0.113.11:5000",
501+ })
502+
503+ require.Error(t, err)
504+ assert.Contains(t, err.Error(), "duplicate external route key")
505+ })
506+}
507+
508+func TestService_AddRoute_RejectsExternalRouteConflict(t *testing.T) {
509+ v := viper.New()
510+ v.Set("external_routes", map[string]any{
511+ "app.example.com": "203.0.113.10:5000",
512+ })
513+
514+ svc := NewService(v, mocks.NewMockEventPublisher(t))
515+ ctx := testContext()
516+ require.NoError(t, svc.Load(ctx))
517+
518+ err := svc.AddRoute(ctx, domain.Route{Domain: "App.Example.com", Image: "app:latest"})
519+
520+ require.Error(t, err)
521+ assert.Contains(t, err.Error(), "conflicts with external route")
522+}
523+
524+func TestService_AddRoute_CanonicalizesDomainCaseAndRejectsInvalidAuthority(t *testing.T) {
525+ configFile := filepath.Join(t.TempDir(), "gordon.toml")
526+ require.NoError(t, os.WriteFile(configFile, []byte("[routes]\n"), 0600))
527+ v := viper.New()
528+ v.SetConfigFile(configFile)
529+ svc := NewService(v, mocks.NewMockEventPublisher(t))
530+ ctx := testContext()
531+ require.NoError(t, svc.Load(ctx))
532+
533+ require.NoError(t, svc.AddRoute(ctx, domain.Route{Domain: "App.Example.com", Image: "app:latest", HTTPS: true}))
534+ route, err := svc.GetRoute(ctx, "app.example.com")
535+ require.NoError(t, err)
536+ assert.Equal(t, "app.example.com", route.Domain)
537+ _, err = svc.GetRoute(ctx, "APP.EXAMPLE.COM")
538+ require.NoError(t, err)
539+
540+ err = svc.AddRoute(ctx, domain.Route{Domain: "bad.example.com:8080", Image: "app:latest"})
541+ assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
542+ err = svc.AddRoute(ctx, domain.Route{Domain: "bad.example.com.", Image: "app:latest"})
543+ assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
544+}
545+
461546 func TestService_AddRoute(t *testing.T) {
462547 t.Run("success", func(t *testing.T) {
463548 // Create temp config file for Save() to work
@@ -1556,7 +1641,7 @@ func TestService_AutoRouteAllowedDomains(t *testing.T) {
15561641 func TestService_GetExternalRoutes(t *testing.T) {
15571642 v := viper.New()
15581643 v.Set("external_routes", map[string]interface{}{
1559- "reg.example.com": "localhost:5000",
1644+ "Reg.Example.com": "localhost:5000",
15601645 "cache.example.com": "127.0.0.1:6379",
15611646 })
15621647
@@ -458,6 +458,91 @@ func TestService_GetRoute(t *testing.T) {
458 })458 })
459 }459 }
460 460
461+func TestLoadCanonicalRoutes_RejectsDuplicateCanonicalRouteKeys(t *testing.T) {
462+ _, err := loadCanonicalRoutes(map[string]any{
463+ "App.Example.com": "app:v1",
464+ "app.example.com": "app:v2",
465+ })
466+
467+ require.Error(t, err)
468+ assert.Contains(t, err.Error(), "duplicate route key")
469+}
470+
471+func TestService_Load_RejectsExternalRouteConflictingWithRoute(t *testing.T) {
472+ v := viper.New()
473+ v.Set("routes", map[string]any{
474+ "App.Example.com": "app:latest",
475+ })
476+ v.Set("external_routes", map[string]any{
477+ "app.example.com": "203.0.113.10:5000",
478+ })
479+
480+ svc := NewService(v, mocks.NewMockEventPublisher(t))
481+ err := svc.Load(testContext())
482+
483+ require.Error(t, err)
484+ assert.Contains(t, err.Error(), "conflicts with configured route")
485+}
486+
487+func TestLoadExternalRoutes_RejectsInvalidAndDuplicateKeys(t *testing.T) {
488+ t.Run("invalid", func(t *testing.T) {
489+ _, err := loadExternalRoutes(map[string]any{
490+ "bad.example.com:8080": "203.0.113.10:5000",
491+ })
492+
493+ require.Error(t, err)
494+ assert.Contains(t, err.Error(), "invalid external route key")
495+ })
496+
497+ t.Run("duplicate canonical", func(t *testing.T) {
498+ _, err := loadExternalRoutes(map[string]any{
499+ "Reg.Example.com": "203.0.113.10:5000",
500+ "reg.example.com": "203.0.113.11:5000",
501+ })
502+
503+ require.Error(t, err)
504+ assert.Contains(t, err.Error(), "duplicate external route key")
505+ })
506+}
507+
508+func TestService_AddRoute_RejectsExternalRouteConflict(t *testing.T) {
509+ v := viper.New()
510+ v.Set("external_routes", map[string]any{
511+ "app.example.com": "203.0.113.10:5000",
512+ })
513+
514+ svc := NewService(v, mocks.NewMockEventPublisher(t))
515+ ctx := testContext()
516+ require.NoError(t, svc.Load(ctx))
517+
518+ err := svc.AddRoute(ctx, domain.Route{Domain: "App.Example.com", Image: "app:latest"})
519+
520+ require.Error(t, err)
521+ assert.Contains(t, err.Error(), "conflicts with external route")
522+}
523+
524+func TestService_AddRoute_CanonicalizesDomainCaseAndRejectsInvalidAuthority(t *testing.T) {
525+ configFile := filepath.Join(t.TempDir(), "gordon.toml")
526+ require.NoError(t, os.WriteFile(configFile, []byte("[routes]\n"), 0600))
527+ v := viper.New()
528+ v.SetConfigFile(configFile)
529+ svc := NewService(v, mocks.NewMockEventPublisher(t))
530+ ctx := testContext()
531+ require.NoError(t, svc.Load(ctx))
532+
533+ require.NoError(t, svc.AddRoute(ctx, domain.Route{Domain: "App.Example.com", Image: "app:latest", HTTPS: true}))
534+ route, err := svc.GetRoute(ctx, "app.example.com")
535+ require.NoError(t, err)
536+ assert.Equal(t, "app.example.com", route.Domain)
537+ _, err = svc.GetRoute(ctx, "APP.EXAMPLE.COM")
538+ require.NoError(t, err)
539+
540+ err = svc.AddRoute(ctx, domain.Route{Domain: "bad.example.com:8080", Image: "app:latest"})
541+ assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
542+ err = svc.AddRoute(ctx, domain.Route{Domain: "bad.example.com.", Image: "app:latest"})
543+ assert.ErrorIs(t, err, domain.ErrRouteDomainInvalid)
544+}
545+
461 func TestService_AddRoute(t *testing.T) {546 func TestService_AddRoute(t *testing.T) {
462 t.Run("success", func(t *testing.T) {547 t.Run("success", func(t *testing.T) {
463 // Create temp config file for Save() to work548 // Create temp config file for Save() to work
@@ -1556,7 +1641,7 @@ func TestService_AutoRouteAllowedDomains(t *testing.T) {
1556 func TestService_GetExternalRoutes(t *testing.T) {1641 func TestService_GetExternalRoutes(t *testing.T) {
1557 v := viper.New()1642 v := viper.New()
1558 v.Set("external_routes", map[string]interface{}{1643 v.Set("external_routes", map[string]interface{}{
1559- "reg.example.com": "localhost:5000",1644+ "Reg.Example.com": "localhost:5000",
1560 "cache.example.com": "127.0.0.1:6379",1645 "cache.example.com": "127.0.0.1:6379",
1561 })1646 })
1562 1647
M internal/usecase/container/autoroute.go +59 -8
@@ -174,6 +174,13 @@ func (h *AutoRouteHandler) processRoutes(ctx context.Context, domains []string,
174174 continue
175175 }
176176
177+ canonicalDomain, ok := domain.CanonicalRouteDomain(routeDomain)
178+ if !ok {
179+ log.Warn().Str("domain", routeDomain).Msg("auto-route rejected invalid domain")
180+ continue
181+ }
182+ routeDomain = canonicalDomain
183+
177184 created := h.createOrUpdateRoute(ctx, routeDomain, imageName, allowedDomains)
178185
179186 if created && labels.EnvFile != "" && h.extractor != nil && h.envDir != "" {
@@ -259,6 +266,38 @@ func (h *AutoRouteHandler) triggerDeploy(ctx context.Context, route domain.Route
259266 }
260267 }
261268
269+func allowedAbsoluteEnvFileRoots() []string {
270+ return []string{"/app", "/workspace", "/usr/src/app"}
271+}
272+
273+func validateAutoRouteEnvFilePath(envFilePath string) (string, error) {
274+ envFilePath = strings.TrimSpace(envFilePath)
275+ if envFilePath == "" {
276+ return "", fmt.Errorf("env file path cannot be empty")
277+ }
278+ if strings.Contains(envFilePath, "\x00") {
279+ return "", fmt.Errorf("env file path contains NUL byte")
280+ }
281+
282+ cleaned := filepath.Clean(envFilePath)
283+ if cleaned == "." || cleaned == string(filepath.Separator) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
284+ return "", fmt.Errorf("unsafe env file path: %s", envFilePath)
285+ }
286+ if filepath.IsAbs(cleaned) && !pathHasAllowedRoot(cleaned, allowedAbsoluteEnvFileRoots()) {
287+ return "", fmt.Errorf("unsafe env file path: %s", envFilePath)
288+ }
289+ return cleaned, nil
290+}
291+
292+func pathHasAllowedRoot(path string, roots []string) bool {
293+ for _, root := range roots {
294+ if path == root || strings.HasPrefix(path, root+string(filepath.Separator)) {
295+ return true
296+ }
297+ }
298+ return false
299+}
300+
262301 // extractAndMergeEnvFile extracts an env file from the image and merges it with existing secrets.
263302 func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef, routeDomain, envFilePath string) error {
264303 log := zerowrap.FromCtx(ctx)
@@ -269,8 +308,13 @@ func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef,
269308 Str("env_file", envFilePath).
270309 Msg("extracting env file from image")
271310
311+ safeEnvFilePath, err := validateAutoRouteEnvFilePath(envFilePath)
312+ if err != nil {
313+ return err
314+ }
315+
272316 // Extract env file from image
273- envData, err := h.extractor.ExtractEnvFileFromImage(ctx, imageRef, envFilePath)
317+ envData, err := h.extractor.ExtractEnvFileFromImage(ctx, imageRef, safeEnvFilePath)
274318 if err != nil {
275319 return fmt.Errorf("failed to extract env file: %w", err)
276320 }
@@ -291,13 +335,8 @@ func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef,
291335 return nil
292336 }
293337
294- // Security check: reject imported env files containing secret references
295- // to prevent attacker-controlled images from persisting ${provider:path}
296- // syntax that would later resolve against host secret providers.
297- for key, value := range imageEnv {
298- if domain.ContainsSecretReference(value) {
299- return fmt.Errorf("env key %q contains secret reference: %w", key, domain.ErrEnvContainsSecretRef)
300- }
338+ if err := rejectSecretReferences(imageEnv); err != nil {
339+ return err
301340 }
302341
303342 // Load existing env file for this domain
@@ -379,6 +418,18 @@ func domainToEnvFileName(domainName string) (string, error) {
379418 return storageKey.FileName(), nil
380419 }
381420
421+func rejectSecretReferences(env map[string]string) error {
422+ // Security check: reject imported env files containing secret references
423+ // to prevent attacker-controlled images from persisting ${provider:path}
424+ // syntax that would later resolve against host secret providers.
425+ for key, value := range env {
426+ if domain.ContainsSecretReference(value) {
427+ return fmt.Errorf("env key %q contains secret reference: %w", key, domain.ErrEnvContainsSecretRef)
428+ }
429+ }
430+ return nil
431+}
432+
382433 // writeEnvFile writes environment variables to a file.
383434 func writeEnvFile(path string, env map[string]string) error {
384435 var buf bytes.Buffer
@@ -174,6 +174,13 @@ func (h *AutoRouteHandler) processRoutes(ctx context.Context, domains []string,
174 continue174 continue
175 }175 }
176 176
177+ canonicalDomain, ok := domain.CanonicalRouteDomain(routeDomain)
178+ if !ok {
179+ log.Warn().Str("domain", routeDomain).Msg("auto-route rejected invalid domain")
180+ continue
181+ }
182+ routeDomain = canonicalDomain
183+
177 created := h.createOrUpdateRoute(ctx, routeDomain, imageName, allowedDomains)184 created := h.createOrUpdateRoute(ctx, routeDomain, imageName, allowedDomains)
178 185
179 if created && labels.EnvFile != "" && h.extractor != nil && h.envDir != "" {186 if created && labels.EnvFile != "" && h.extractor != nil && h.envDir != "" {
@@ -259,6 +266,38 @@ func (h *AutoRouteHandler) triggerDeploy(ctx context.Context, route domain.Route
259 }266 }
260 }267 }
261 268
269+func allowedAbsoluteEnvFileRoots() []string {
270+ return []string{"/app", "/workspace", "/usr/src/app"}
271+}
272+
273+func validateAutoRouteEnvFilePath(envFilePath string) (string, error) {
274+ envFilePath = strings.TrimSpace(envFilePath)
275+ if envFilePath == "" {
276+ return "", fmt.Errorf("env file path cannot be empty")
277+ }
278+ if strings.Contains(envFilePath, "\x00") {
279+ return "", fmt.Errorf("env file path contains NUL byte")
280+ }
281+
282+ cleaned := filepath.Clean(envFilePath)
283+ if cleaned == "." || cleaned == string(filepath.Separator) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
284+ return "", fmt.Errorf("unsafe env file path: %s", envFilePath)
285+ }
286+ if filepath.IsAbs(cleaned) && !pathHasAllowedRoot(cleaned, allowedAbsoluteEnvFileRoots()) {
287+ return "", fmt.Errorf("unsafe env file path: %s", envFilePath)
288+ }
289+ return cleaned, nil
290+}
291+
292+func pathHasAllowedRoot(path string, roots []string) bool {
293+ for _, root := range roots {
294+ if path == root || strings.HasPrefix(path, root+string(filepath.Separator)) {
295+ return true
296+ }
297+ }
298+ return false
299+}
300+
262 // extractAndMergeEnvFile extracts an env file from the image and merges it with existing secrets.301 // extractAndMergeEnvFile extracts an env file from the image and merges it with existing secrets.
263 func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef, routeDomain, envFilePath string) error {302 func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef, routeDomain, envFilePath string) error {
264 log := zerowrap.FromCtx(ctx)303 log := zerowrap.FromCtx(ctx)
@@ -269,8 +308,13 @@ func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef,
269 Str("env_file", envFilePath).308 Str("env_file", envFilePath).
270 Msg("extracting env file from image")309 Msg("extracting env file from image")
271 310
311+ safeEnvFilePath, err := validateAutoRouteEnvFilePath(envFilePath)
312+ if err != nil {
313+ return err
314+ }
315+
272 // Extract env file from image316 // Extract env file from image
273- envData, err := h.extractor.ExtractEnvFileFromImage(ctx, imageRef, envFilePath)317+ envData, err := h.extractor.ExtractEnvFileFromImage(ctx, imageRef, safeEnvFilePath)
274 if err != nil {318 if err != nil {
275 return fmt.Errorf("failed to extract env file: %w", err)319 return fmt.Errorf("failed to extract env file: %w", err)
276 }320 }
@@ -291,13 +335,8 @@ func (h *AutoRouteHandler) extractAndMergeEnvFile(ctx context.Context, imageRef,
291 return nil335 return nil
292 }336 }
293 337
294- // Security check: reject imported env files containing secret references338+ if err := rejectSecretReferences(imageEnv); err != nil {
295- // to prevent attacker-controlled images from persisting ${provider:path}339+ return err
296- // syntax that would later resolve against host secret providers.
297- for key, value := range imageEnv {
298- if domain.ContainsSecretReference(value) {
299- return fmt.Errorf("env key %q contains secret reference: %w", key, domain.ErrEnvContainsSecretRef)
300- }
301 }340 }
302 341
303 // Load existing env file for this domain342 // Load existing env file for this domain
@@ -379,6 +418,18 @@ func domainToEnvFileName(domainName string) (string, error) {
379 return storageKey.FileName(), nil418 return storageKey.FileName(), nil
380 }419 }
381 420
421+func rejectSecretReferences(env map[string]string) error {
422+ // Security check: reject imported env files containing secret references
423+ // to prevent attacker-controlled images from persisting ${provider:path}
424+ // syntax that would later resolve against host secret providers.
425+ for key, value := range env {
426+ if domain.ContainsSecretReference(value) {
427+ return fmt.Errorf("env key %q contains secret reference: %w", key, domain.ErrEnvContainsSecretRef)
428+ }
429+ }
430+ return nil
431+}
432+
382 // writeEnvFile writes environment variables to a file.433 // writeEnvFile writes environment variables to a file.
383 func writeEnvFile(path string, env map[string]string) error {434 func writeEnvFile(path string, env map[string]string) error {
384 var buf bytes.Buffer435 var buf bytes.Buffer
M internal/usecase/container/autoroute_test.go +47 -0
@@ -910,6 +910,53 @@ func (e *testEnvExtractor) ExtractEnvFileFromImage(_ context.Context, imageRef,
910910
911911 // collectDomains tests
912912
913+func TestValidateAutoRouteEnvFilePath(t *testing.T) {
914+ tests := []struct {
915+ name string
916+ path string
917+ want string
918+ wantErr bool
919+ }{
920+ {name: "relative path", path: ".env", want: ".env"},
921+ {name: "absolute app path", path: "/app/.env", want: "/app/.env"},
922+ {name: "absolute workspace path", path: "/workspace/.env", want: "/workspace/.env"},
923+ {name: "cleans safe path", path: "/app/config/../.env", want: "/app/.env"},
924+ {name: "absolute traversal escapes app root", path: "/app/../../etc/passwd", wantErr: true},
925+ {name: "absolute non app path", path: "/etc/passwd", wantErr: true},
926+ {name: "empty", path: "", wantErr: true},
927+ {name: "root", path: "/", wantErr: true},
928+ {name: "traversal", path: "../.env", wantErr: true},
929+ {name: "proc", path: "/proc/self/environ", wantErr: true},
930+ {name: "sys", path: "/sys/kernel", wantErr: true},
931+ {name: "dev", path: "/dev/null", wantErr: true},
932+ {name: "run", path: "/run/secrets", wantErr: true},
933+ {name: "var run", path: "/var/run/docker.sock", wantErr: true},
934+ }
935+
936+ for _, tt := range tests {
937+ t.Run(tt.name, func(t *testing.T) {
938+ got, err := validateAutoRouteEnvFilePath(tt.path)
939+ if tt.wantErr {
940+ assert.Error(t, err)
941+ return
942+ }
943+ require.NoError(t, err)
944+ assert.Equal(t, tt.want, got)
945+ })
946+ }
947+}
948+
949+func TestAutoRouteHandler_ExtractAndMergeEnvFile_UsesCleanSafeEnvFilePath(t *testing.T) {
950+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
951+ extractor := &testEnvExtractor{data: []byte("FOO=bar\n")}
952+ handler := NewAutoRouteHandler(ctx, inmocks.NewMockConfigService(t), inmocks.NewMockContainerService(t), mocks.NewMockBlobStorage(t), "registry.example.com").
953+ WithEnvExtractor(extractor, t.TempDir())
954+
955+ err := handler.extractAndMergeEnvFile(ctx, "myapp:latest", "app.example.com", "/app/config/../.env")
956+ require.NoError(t, err)
957+ assert.Equal(t, "/app/.env", extractor.lastPath)
958+}
959+
913960 func TestAutoRouteHandler_ExtractAndMergeEnvFile_RejectsSecretReferences(t *testing.T) {
914961 tests := []struct {
915962 name string
@@ -910,6 +910,53 @@ func (e *testEnvExtractor) ExtractEnvFileFromImage(_ context.Context, imageRef,
910 910
911 // collectDomains tests911 // collectDomains tests
912 912
913+func TestValidateAutoRouteEnvFilePath(t *testing.T) {
914+ tests := []struct {
915+ name string
916+ path string
917+ want string
918+ wantErr bool
919+ }{
920+ {name: "relative path", path: ".env", want: ".env"},
921+ {name: "absolute app path", path: "/app/.env", want: "/app/.env"},
922+ {name: "absolute workspace path", path: "/workspace/.env", want: "/workspace/.env"},
923+ {name: "cleans safe path", path: "/app/config/../.env", want: "/app/.env"},
924+ {name: "absolute traversal escapes app root", path: "/app/../../etc/passwd", wantErr: true},
925+ {name: "absolute non app path", path: "/etc/passwd", wantErr: true},
926+ {name: "empty", path: "", wantErr: true},
927+ {name: "root", path: "/", wantErr: true},
928+ {name: "traversal", path: "../.env", wantErr: true},
929+ {name: "proc", path: "/proc/self/environ", wantErr: true},
930+ {name: "sys", path: "/sys/kernel", wantErr: true},
931+ {name: "dev", path: "/dev/null", wantErr: true},
932+ {name: "run", path: "/run/secrets", wantErr: true},
933+ {name: "var run", path: "/var/run/docker.sock", wantErr: true},
934+ }
935+
936+ for _, tt := range tests {
937+ t.Run(tt.name, func(t *testing.T) {
938+ got, err := validateAutoRouteEnvFilePath(tt.path)
939+ if tt.wantErr {
940+ assert.Error(t, err)
941+ return
942+ }
943+ require.NoError(t, err)
944+ assert.Equal(t, tt.want, got)
945+ })
946+ }
947+}
948+
949+func TestAutoRouteHandler_ExtractAndMergeEnvFile_UsesCleanSafeEnvFilePath(t *testing.T) {
950+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
951+ extractor := &testEnvExtractor{data: []byte("FOO=bar\n")}
952+ handler := NewAutoRouteHandler(ctx, inmocks.NewMockConfigService(t), inmocks.NewMockContainerService(t), mocks.NewMockBlobStorage(t), "registry.example.com").
953+ WithEnvExtractor(extractor, t.TempDir())
954+
955+ err := handler.extractAndMergeEnvFile(ctx, "myapp:latest", "app.example.com", "/app/config/../.env")
956+ require.NoError(t, err)
957+ assert.Equal(t, "/app/.env", extractor.lastPath)
958+}
959+
913 func TestAutoRouteHandler_ExtractAndMergeEnvFile_RejectsSecretReferences(t *testing.T) {960 func TestAutoRouteHandler_ExtractAndMergeEnvFile_RejectsSecretReferences(t *testing.T) {
914 tests := []struct {961 tests := []struct {
915 name string962 name string
M internal/usecase/proxy/service.go +52 -5
@@ -3,6 +3,7 @@ package proxy
33
44 import (
55 "context"
6+ "fmt"
67 "net"
78 "os"
89 "strconv"
@@ -65,6 +66,12 @@ func NewService(
6566
6667 // GetTarget returns the proxy target for a given domain.
6768 func (s *Service) GetTarget(ctx context.Context, domainName string) (target *domain.ProxyTarget, retErr error) {
69+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
70+ if !ok {
71+ return nil, domain.ErrNoTargetAvailable
72+ }
73+ domainName = canonicalDomain
74+
6875 ctx, span := proxyTracer.Start(ctx, "proxy.get_target",
6976 trace.WithAttributes(attribute.String("domain", domainName)))
7077 defer func() {
@@ -181,6 +188,9 @@ func (s *Service) resolveExternalRoute(_ context.Context, domainName, targetAddr
181188 if err != nil {
182189 return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
183190 }
191+ if port < 1 || port > 65535 {
192+ return nil, fmt.Errorf("invalid port in external route %q: port must be between 1 and 65535", targetAddr)
193+ }
184194
185195 // SECURITY: Resolve DNS and validate that the target is not an internal/blocked
186196 // network. We use the resolved IP as the proxy target to prevent DNS rebinding
@@ -225,29 +235,44 @@ func (s *Service) resolveExternalRoute(_ context.Context, domainName, targetAddr
225235
226236 // RegisterTarget registers a new proxy target for a domain.
227237 func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error {
238+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
239+ if !ok {
240+ return domain.ErrRouteDomainInvalid
241+ }
242+
228243 s.mu.Lock()
229244 defer s.mu.Unlock()
230245
231- s.targets[domainName] = target
246+ s.targets[canonicalDomain] = target
232247 return nil
233248 }
234249
235250 // UnregisterTarget removes a proxy target for a domain.
236251 func (s *Service) UnregisterTarget(_ context.Context, domainName string) error {
252+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
253+ if !ok {
254+ return domain.ErrRouteDomainInvalid
255+ }
256+
237257 s.mu.Lock()
238258 defer s.mu.Unlock()
239259
240- delete(s.targets, domainName)
260+ delete(s.targets, canonicalDomain)
241261 return nil
242262 }
243263
244264 // InvalidateTarget removes a cached proxy target, forcing re-lookup on next request.
245265 // This is used during zero-downtime deployments to switch traffic to a new container.
246266 func (s *Service) InvalidateTarget(_ context.Context, domainName string) {
267+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
268+ if !ok {
269+ return
270+ }
271+
247272 s.mu.Lock()
248273 defer s.mu.Unlock()
249274
250- delete(s.targets, domainName)
275+ delete(s.targets, canonicalDomain)
251276 }
252277
253278 // WaitForNoInFlight waits until no requests are currently proxied to the
@@ -303,9 +328,31 @@ func (s *Service) UpdateConfig(config Config) {
303328
304329 // IsRegistryDomain returns true if the host matches the configured registry domain.
305330 func (s *Service) IsRegistryDomain(host string) bool {
331+ canonicalHost, ok := domain.CanonicalRouteDomain(host)
332+ if !ok {
333+ return false
334+ }
335+
306336 s.mu.RLock()
307- defer s.mu.RUnlock()
308- return s.config.RegistryDomain != "" && host == s.config.RegistryDomain
337+ registryDomain, ok := domain.CanonicalRouteDomain(s.config.RegistryDomain)
338+ s.mu.RUnlock()
339+ return ok && canonicalHost == registryDomain
340+}
341+
342+// IsKnownHost returns true if host is configured as registry, route, or external route.
343+func (s *Service) IsKnownHost(ctx context.Context, host string) bool {
344+ canonicalHost, ok := domain.CanonicalRouteDomain(host)
345+ if !ok {
346+ return false
347+ }
348+ if s.IsRegistryDomain(canonicalHost) {
349+ return true
350+ }
351+ if _, err := s.configSvc.GetRoute(ctx, canonicalHost); err == nil {
352+ return true
353+ }
354+ _, ok = s.configSvc.GetExternalRoutes()[canonicalHost]
355+ return ok
309356 }
310357
311358 // TrackInFlight records an in-flight request for a container.
@@ -3,6 +3,7 @@ package proxy
3 3
4 import (4 import (
5 "context"5 "context"
6+ "fmt"
6 "net"7 "net"
7 "os"8 "os"
8 "strconv"9 "strconv"
@@ -65,6 +66,12 @@ func NewService(
65 66
66 // GetTarget returns the proxy target for a given domain.67 // GetTarget returns the proxy target for a given domain.
67 func (s *Service) GetTarget(ctx context.Context, domainName string) (target *domain.ProxyTarget, retErr error) {68 func (s *Service) GetTarget(ctx context.Context, domainName string) (target *domain.ProxyTarget, retErr error) {
69+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
70+ if !ok {
71+ return nil, domain.ErrNoTargetAvailable
72+ }
73+ domainName = canonicalDomain
74+
68 ctx, span := proxyTracer.Start(ctx, "proxy.get_target",75 ctx, span := proxyTracer.Start(ctx, "proxy.get_target",
69 trace.WithAttributes(attribute.String("domain", domainName)))76 trace.WithAttributes(attribute.String("domain", domainName)))
70 defer func() {77 defer func() {
@@ -181,6 +188,9 @@ func (s *Service) resolveExternalRoute(_ context.Context, domainName, targetAddr
181 if err != nil {188 if err != nil {
182 return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})189 return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
183 }190 }
191+ if port < 1 || port > 65535 {
192+ return nil, fmt.Errorf("invalid port in external route %q: port must be between 1 and 65535", targetAddr)
193+ }
184 194
185 // SECURITY: Resolve DNS and validate that the target is not an internal/blocked195 // SECURITY: Resolve DNS and validate that the target is not an internal/blocked
186 // network. We use the resolved IP as the proxy target to prevent DNS rebinding196 // network. We use the resolved IP as the proxy target to prevent DNS rebinding
@@ -225,29 +235,44 @@ func (s *Service) resolveExternalRoute(_ context.Context, domainName, targetAddr
225 235
226 // RegisterTarget registers a new proxy target for a domain.236 // RegisterTarget registers a new proxy target for a domain.
227 func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error {237 func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error {
238+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
239+ if !ok {
240+ return domain.ErrRouteDomainInvalid
241+ }
242+
228 s.mu.Lock()243 s.mu.Lock()
229 defer s.mu.Unlock()244 defer s.mu.Unlock()
230 245
231- s.targets[domainName] = target246+ s.targets[canonicalDomain] = target
232 return nil247 return nil
233 }248 }
234 249
235 // UnregisterTarget removes a proxy target for a domain.250 // UnregisterTarget removes a proxy target for a domain.
236 func (s *Service) UnregisterTarget(_ context.Context, domainName string) error {251 func (s *Service) UnregisterTarget(_ context.Context, domainName string) error {
252+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
253+ if !ok {
254+ return domain.ErrRouteDomainInvalid
255+ }
256+
237 s.mu.Lock()257 s.mu.Lock()
238 defer s.mu.Unlock()258 defer s.mu.Unlock()
239 259
240- delete(s.targets, domainName)260+ delete(s.targets, canonicalDomain)
241 return nil261 return nil
242 }262 }
243 263
244 // InvalidateTarget removes a cached proxy target, forcing re-lookup on next request.264 // InvalidateTarget removes a cached proxy target, forcing re-lookup on next request.
245 // This is used during zero-downtime deployments to switch traffic to a new container.265 // This is used during zero-downtime deployments to switch traffic to a new container.
246 func (s *Service) InvalidateTarget(_ context.Context, domainName string) {266 func (s *Service) InvalidateTarget(_ context.Context, domainName string) {
267+ canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
268+ if !ok {
269+ return
270+ }
271+
247 s.mu.Lock()272 s.mu.Lock()
248 defer s.mu.Unlock()273 defer s.mu.Unlock()
249 274
250- delete(s.targets, domainName)275+ delete(s.targets, canonicalDomain)
251 }276 }
252 277
253 // WaitForNoInFlight waits until no requests are currently proxied to the278 // WaitForNoInFlight waits until no requests are currently proxied to the
@@ -303,9 +328,31 @@ func (s *Service) UpdateConfig(config Config) {
303 328
304 // IsRegistryDomain returns true if the host matches the configured registry domain.329 // IsRegistryDomain returns true if the host matches the configured registry domain.
305 func (s *Service) IsRegistryDomain(host string) bool {330 func (s *Service) IsRegistryDomain(host string) bool {
331+ canonicalHost, ok := domain.CanonicalRouteDomain(host)
332+ if !ok {
333+ return false
334+ }
335+
306 s.mu.RLock()336 s.mu.RLock()
307- defer s.mu.RUnlock()337+ registryDomain, ok := domain.CanonicalRouteDomain(s.config.RegistryDomain)
308- return s.config.RegistryDomain != "" && host == s.config.RegistryDomain338+ s.mu.RUnlock()
339+ return ok && canonicalHost == registryDomain
340+}
341+
342+// IsKnownHost returns true if host is configured as registry, route, or external route.
343+func (s *Service) IsKnownHost(ctx context.Context, host string) bool {
344+ canonicalHost, ok := domain.CanonicalRouteDomain(host)
345+ if !ok {
346+ return false
347+ }
348+ if s.IsRegistryDomain(canonicalHost) {
349+ return true
350+ }
351+ if _, err := s.configSvc.GetRoute(ctx, canonicalHost); err == nil {
352+ return true
353+ }
354+ _, ok = s.configSvc.GetExternalRoutes()[canonicalHost]
355+ return ok
309 }356 }
310 357
311 // TrackInFlight records an in-flight request for a container.358 // TrackInFlight records an in-flight request for a container.
M internal/usecase/proxy/service_test.go +51 -17
@@ -47,6 +47,28 @@ func TestService_GetTarget_FromCache(t *testing.T) {
4747 assert.Equal(t, cachedTarget, result)
4848 }
4949
50+func TestService_GetTarget_CanonicalizesHostForLookup(t *testing.T) {
51+ runtime := outmocks.NewMockContainerRuntime(t)
52+ containerSvc := inmocks.NewMockContainerService(t)
53+ configSvc := inmocks.NewMockConfigService(t)
54+ svc := NewService(runtime, containerSvc, configSvc, Config{})
55+ ctx := testContext()
56+
57+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
58+ containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
59+
60+ result, err := svc.GetTarget(ctx, "App.Example.com")
61+ assert.ErrorIs(t, err, domain.ErrNoTargetAvailable)
62+ assert.Nil(t, result)
63+}
64+
65+func TestService_GetTarget_RejectsInvalidHostAuthority(t *testing.T) {
66+ svc := NewService(outmocks.NewMockContainerRuntime(t), inmocks.NewMockContainerService(t), inmocks.NewMockConfigService(t), Config{})
67+ result, err := svc.GetTarget(testContext(), "app.example.com:8080")
68+ assert.ErrorIs(t, err, domain.ErrNoTargetAvailable)
69+ assert.Nil(t, result)
70+}
71+
5072 func TestService_GetTarget_ContainerNotFound(t *testing.T) {
5173 runtime := outmocks.NewMockContainerRuntime(t)
5274 containerSvc := inmocks.NewMockContainerService(t)
@@ -169,22 +191,34 @@ func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) {
169191 }
170192
171193 func TestService_GetTarget_ExternalRoute_InvalidPort(t *testing.T) {
172- runtime := outmocks.NewMockContainerRuntime(t)
173- containerSvc := inmocks.NewMockContainerService(t)
174- configSvc := inmocks.NewMockConfigService(t)
194+ tests := []struct {
195+ name string
196+ target string
197+ }{
198+ {name: "not a number", target: "localhost:abc"},
199+ {name: "zero", target: "localhost:0"},
200+ {name: "too high", target: "localhost:65536"},
201+ }
175202
176- svc := NewService(runtime, containerSvc, configSvc, Config{})
177- ctx := testContext()
203+ for _, tt := range tests {
204+ t.Run(tt.name, func(t *testing.T) {
205+ runtime := outmocks.NewMockContainerRuntime(t)
206+ containerSvc := inmocks.NewMockContainerService(t)
207+ configSvc := inmocks.NewMockConfigService(t)
178208
179- // Mock external routes with invalid port
180- configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
181- "invalid.example.com": "localhost:abc",
182- })
209+ svc := NewService(runtime, containerSvc, configSvc, Config{})
210+ ctx := testContext()
183211
184- result, err := svc.GetTarget(ctx, "invalid.example.com")
212+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
213+ "invalid.example.com": tt.target,
214+ })
185215
186- assert.Error(t, err)
187- assert.Nil(t, result)
216+ result, err := svc.GetTarget(ctx, "invalid.example.com")
217+
218+ assert.Error(t, err)
219+ assert.Nil(t, result)
220+ })
221+ }
188222 }
189223
190224 func TestService_RegisterTarget(t *testing.T) {
@@ -202,11 +236,11 @@ func TestService_RegisterTarget(t *testing.T) {
202236 Scheme: "http",
203237 }
204238
205- err := svc.RegisterTarget(ctx, "app.example.com", target)
239+ err := svc.RegisterTarget(ctx, "App.Example.com", target)
206240
207241 assert.NoError(t, err)
208242
209- // Verify target is cached
243+ // Verify target is cached under the canonical domain.
210244 svc.mu.RLock()
211245 cached := svc.targets["app.example.com"]
212246 svc.mu.RUnlock()
@@ -228,7 +262,7 @@ func TestService_UnregisterTarget(t *testing.T) {
228262 Port: 8080,
229263 }
230264
231- err := svc.UnregisterTarget(ctx, "app.example.com")
265+ err := svc.UnregisterTarget(ctx, "App.Example.com")
232266
233267 assert.NoError(t, err)
234268
@@ -313,8 +347,8 @@ func TestService_InvalidateTarget(t *testing.T) {
313347 ContainerID: "old-container",
314348 }
315349
316- // Invalidate the target
317- svc.InvalidateTarget(ctx, "app.example.com")
350+ // Invalidate the target using mixed-case input.
351+ svc.InvalidateTarget(ctx, "App.Example.com")
318352
319353 // Verify target is removed from cache
320354 svc.mu.RLock()
@@ -47,6 +47,28 @@ func TestService_GetTarget_FromCache(t *testing.T) {
47 assert.Equal(t, cachedTarget, result)47 assert.Equal(t, cachedTarget, result)
48 }48 }
49 49
50+func TestService_GetTarget_CanonicalizesHostForLookup(t *testing.T) {
51+ runtime := outmocks.NewMockContainerRuntime(t)
52+ containerSvc := inmocks.NewMockContainerService(t)
53+ configSvc := inmocks.NewMockConfigService(t)
54+ svc := NewService(runtime, containerSvc, configSvc, Config{})
55+ ctx := testContext()
56+
57+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
58+ containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
59+
60+ result, err := svc.GetTarget(ctx, "App.Example.com")
61+ assert.ErrorIs(t, err, domain.ErrNoTargetAvailable)
62+ assert.Nil(t, result)
63+}
64+
65+func TestService_GetTarget_RejectsInvalidHostAuthority(t *testing.T) {
66+ svc := NewService(outmocks.NewMockContainerRuntime(t), inmocks.NewMockContainerService(t), inmocks.NewMockConfigService(t), Config{})
67+ result, err := svc.GetTarget(testContext(), "app.example.com:8080")
68+ assert.ErrorIs(t, err, domain.ErrNoTargetAvailable)
69+ assert.Nil(t, result)
70+}
71+
50 func TestService_GetTarget_ContainerNotFound(t *testing.T) {72 func TestService_GetTarget_ContainerNotFound(t *testing.T) {
51 runtime := outmocks.NewMockContainerRuntime(t)73 runtime := outmocks.NewMockContainerRuntime(t)
52 containerSvc := inmocks.NewMockContainerService(t)74 containerSvc := inmocks.NewMockContainerService(t)
@@ -169,22 +191,34 @@ func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) {
169 }191 }
170 192
171 func TestService_GetTarget_ExternalRoute_InvalidPort(t *testing.T) {193 func TestService_GetTarget_ExternalRoute_InvalidPort(t *testing.T) {
172- runtime := outmocks.NewMockContainerRuntime(t)194+ tests := []struct {
173- containerSvc := inmocks.NewMockContainerService(t)195+ name string
174- configSvc := inmocks.NewMockConfigService(t)196+ target string
197+ }{
198+ {name: "not a number", target: "localhost:abc"},
199+ {name: "zero", target: "localhost:0"},
200+ {name: "too high", target: "localhost:65536"},
201+ }
175 202
176- svc := NewService(runtime, containerSvc, configSvc, Config{})203+ for _, tt := range tests {
177- ctx := testContext()204+ t.Run(tt.name, func(t *testing.T) {
205+ runtime := outmocks.NewMockContainerRuntime(t)
206+ containerSvc := inmocks.NewMockContainerService(t)
207+ configSvc := inmocks.NewMockConfigService(t)
178 208
179- // Mock external routes with invalid port209+ svc := NewService(runtime, containerSvc, configSvc, Config{})
180- configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{210+ ctx := testContext()
181- "invalid.example.com": "localhost:abc",
182- })
183 211
184- result, err := svc.GetTarget(ctx, "invalid.example.com")212+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
213+ "invalid.example.com": tt.target,
214+ })
185 215
186- assert.Error(t, err)216+ result, err := svc.GetTarget(ctx, "invalid.example.com")
187- assert.Nil(t, result)217+
218+ assert.Error(t, err)
219+ assert.Nil(t, result)
220+ })
221+ }
188 }222 }
189 223
190 func TestService_RegisterTarget(t *testing.T) {224 func TestService_RegisterTarget(t *testing.T) {
@@ -202,11 +236,11 @@ func TestService_RegisterTarget(t *testing.T) {
202 Scheme: "http",236 Scheme: "http",
203 }237 }
204 238
205- err := svc.RegisterTarget(ctx, "app.example.com", target)239+ err := svc.RegisterTarget(ctx, "App.Example.com", target)
206 240
207 assert.NoError(t, err)241 assert.NoError(t, err)
208 242
209- // Verify target is cached243+ // Verify target is cached under the canonical domain.
210 svc.mu.RLock()244 svc.mu.RLock()
211 cached := svc.targets["app.example.com"]245 cached := svc.targets["app.example.com"]
212 svc.mu.RUnlock()246 svc.mu.RUnlock()
@@ -228,7 +262,7 @@ func TestService_UnregisterTarget(t *testing.T) {
228 Port: 8080,262 Port: 8080,
229 }263 }
230 264
231- err := svc.UnregisterTarget(ctx, "app.example.com")265+ err := svc.UnregisterTarget(ctx, "App.Example.com")
232 266
233 assert.NoError(t, err)267 assert.NoError(t, err)
234 268
@@ -313,8 +347,8 @@ func TestService_InvalidateTarget(t *testing.T) {
313 ContainerID: "old-container",347 ContainerID: "old-container",
314 }348 }
315 349
316- // Invalidate the target350+ // Invalidate the target using mixed-case input.
317- svc.InvalidateTarget(ctx, "app.example.com")351+ svc.InvalidateTarget(ctx, "App.Example.com")
318 352
319 // Verify target is removed from cache353 // Verify target is removed from cache
320 svc.mu.RLock()354 svc.mu.RLock()

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