detached 441dbcb

Merge pull request #61 from bnema/security/proxy-hardening-top10

security(proxy) harden reverse proxy (top 10 audit items)
commit
441dbcb99bfbd0f7248f65c22b912bee1b113e25
author
bnema · · 2026-02-07 05:56 UTC
committer
GitHub · · 2026-02-07 05:56 UTC
merge of
7e94957 + e3862cb · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
11 files · +497 -101
M internal/adapters/in/cli/remote/client.go +15 -5
@@ -92,10 +92,17 @@ func (c *Client) applyTLSConfig() {
9292 fmt.Fprintf(os.Stderr, "WARNING: TLS certificate verification disabled for %s\n", c.baseURL)
9393
9494 var transport *http.Transport
95- if existing, ok := c.httpClient.Transport.(*http.Transport); ok {
96- transport = existing.Clone()
97- } else {
95+ switch t := c.httpClient.Transport.(type) {
96+ case *http.Transport:
97+ transport = t.Clone()
98+ case nil:
9899 transport = http.DefaultTransport.(*http.Transport).Clone()
100+ default:
101+ // Cannot apply InsecureSkipVerify to a non-*http.Transport.
102+ // Log a warning and leave the transport intact rather than
103+ // silently replacing it (which would drop caller-provided behavior).
104+ fmt.Fprintf(os.Stderr, "WARNING: --insecure requires *http.Transport, got %T; TLS override not applied\n", t)
105+ return
99106 }
100107
101108 if transport.TLSClientConfig == nil {
@@ -741,8 +748,11 @@ func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, er
741748 req.Header.Set("Authorization", "Bearer "+c.token)
742749 }
743750
744- // Use a client without timeout for streaming
745- streamClient := &http.Client{}
751+ // Use the same transport as the main client (honoring TLS config and
752+ // custom transports) but without a timeout so streaming doesn't get cut off.
753+ streamClient := &http.Client{
754+ Transport: c.httpClient.Transport,
755+ }
746756 resp, err := streamClient.Do(req)
747757 if err != nil {
748758 return nil, fmt.Errorf("failed to connect: %w", err)
@@ -92,10 +92,17 @@ func (c *Client) applyTLSConfig() {
92 fmt.Fprintf(os.Stderr, "WARNING: TLS certificate verification disabled for %s\n", c.baseURL)92 fmt.Fprintf(os.Stderr, "WARNING: TLS certificate verification disabled for %s\n", c.baseURL)
93 93
94 var transport *http.Transport94 var transport *http.Transport
95- if existing, ok := c.httpClient.Transport.(*http.Transport); ok {95+ switch t := c.httpClient.Transport.(type) {
96- transport = existing.Clone()96+ case *http.Transport:
97- } else {97+ transport = t.Clone()
98+ case nil:
98 transport = http.DefaultTransport.(*http.Transport).Clone()99 transport = http.DefaultTransport.(*http.Transport).Clone()
100+ default:
101+ // Cannot apply InsecureSkipVerify to a non-*http.Transport.
102+ // Log a warning and leave the transport intact rather than
103+ // silently replacing it (which would drop caller-provided behavior).
104+ fmt.Fprintf(os.Stderr, "WARNING: --insecure requires *http.Transport, got %T; TLS override not applied\n", t)
105+ return
99 }106 }
100 107
101 if transport.TLSClientConfig == nil {108 if transport.TLSClientConfig == nil {
@@ -741,8 +748,11 @@ func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, er
741 req.Header.Set("Authorization", "Bearer "+c.token)748 req.Header.Set("Authorization", "Bearer "+c.token)
742 }749 }
743 750
744- // Use a client without timeout for streaming751+ // Use the same transport as the main client (honoring TLS config and
745- streamClient := &http.Client{}752+ // custom transports) but without a timeout so streaming doesn't get cut off.
753+ streamClient := &http.Client{
754+ Transport: c.httpClient.Transport,
755+ }
746 resp, err := streamClient.Do(req)756 resp, err := streamClient.Do(req)
747 if err != nil {757 if err != nil {
748 return nil, fmt.Errorf("failed to connect: %w", err)758 return nil, fmt.Errorf("failed to connect: %w", err)
M internal/adapters/in/http/middleware/logging.go +25 -0
@@ -2,9 +2,13 @@
22 package middleware
33
44 import (
5+ "crypto/rand"
6+ "encoding/hex"
57 "encoding/json"
8+ "fmt"
69 "net"
710 "net/http"
11+ "sync/atomic"
812 "time"
913
1014 "github.com/bnema/zerowrap"
@@ -75,6 +79,13 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H
7579 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
7680 start := time.Now()
7781
82+ // Generate or reuse X-Request-ID for request tracing
83+ requestID := r.Header.Get("X-Request-ID")
84+ if requestID == "" {
85+ requestID = generateRequestID()
86+ }
87+ w.Header().Set("X-Request-ID", requestID)
88+
7889 // Wrap the response writer to capture status and bytes
7990 rw := NewResponseWriter(w)
8091
@@ -96,6 +107,7 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H
96107 log.Info().
97108 Str(zerowrap.FieldLayer, "adapter").
98109 Str(zerowrap.FieldAdapter, "http").
110+ Str("request_id", requestID).
99111 Str(zerowrap.FieldMethod, r.Method).
100112 Str(zerowrap.FieldPath, r.URL.Path).
101113 Str("query", r.URL.RawQuery).
@@ -112,6 +124,19 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H
112124 }
113125 }
114126
127+// fallbackCounter ensures uniqueness when crypto/rand is unavailable.
128+var fallbackCounter atomic.Uint64
129+
130+// generateRequestID creates a random 16-byte hex-encoded request ID.
131+func generateRequestID() string {
132+ b := make([]byte, 16)
133+ if _, err := rand.Read(b); err != nil {
134+ // Fallback to timestamp + monotonic counter if RNG is unavailable
135+ return fmt.Sprintf("%x-%x", time.Now().UnixNano(), fallbackCounter.Add(1))
136+ }
137+ return hex.EncodeToString(b)
138+}
139+
115140 // PanicRecovery middleware recovers from panics and logs them.
116141 func PanicRecovery(log zerowrap.Logger) func(http.Handler) http.Handler {
117142 return func(next http.Handler) http.Handler {
@@ -2,9 +2,13 @@
2 package middleware2 package middleware
3 3
4 import (4 import (
5+ "crypto/rand"
6+ "encoding/hex"
5 "encoding/json"7 "encoding/json"
8+ "fmt"
6 "net"9 "net"
7 "net/http"10 "net/http"
11+ "sync/atomic"
8 "time"12 "time"
9 13
10 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
@@ -75,6 +79,13 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H
75 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {79 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
76 start := time.Now()80 start := time.Now()
77 81
82+ // Generate or reuse X-Request-ID for request tracing
83+ requestID := r.Header.Get("X-Request-ID")
84+ if requestID == "" {
85+ requestID = generateRequestID()
86+ }
87+ w.Header().Set("X-Request-ID", requestID)
88+
78 // Wrap the response writer to capture status and bytes89 // Wrap the response writer to capture status and bytes
79 rw := NewResponseWriter(w)90 rw := NewResponseWriter(w)
80 91
@@ -96,6 +107,7 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H
96 log.Info().107 log.Info().
97 Str(zerowrap.FieldLayer, "adapter").108 Str(zerowrap.FieldLayer, "adapter").
98 Str(zerowrap.FieldAdapter, "http").109 Str(zerowrap.FieldAdapter, "http").
110+ Str("request_id", requestID).
99 Str(zerowrap.FieldMethod, r.Method).111 Str(zerowrap.FieldMethod, r.Method).
100 Str(zerowrap.FieldPath, r.URL.Path).112 Str(zerowrap.FieldPath, r.URL.Path).
101 Str("query", r.URL.RawQuery).113 Str("query", r.URL.RawQuery).
@@ -112,6 +124,19 @@ func RequestLogger(log zerowrap.Logger, trustedNets ...[]*net.IPNet) func(http.H
112 }124 }
113 }125 }
114 126
127+// fallbackCounter ensures uniqueness when crypto/rand is unavailable.
128+var fallbackCounter atomic.Uint64
129+
130+// generateRequestID creates a random 16-byte hex-encoded request ID.
131+func generateRequestID() string {
132+ b := make([]byte, 16)
133+ if _, err := rand.Read(b); err != nil {
134+ // Fallback to timestamp + monotonic counter if RNG is unavailable
135+ return fmt.Sprintf("%x-%x", time.Now().UnixNano(), fallbackCounter.Add(1))
136+ }
137+ return hex.EncodeToString(b)
138+}
139+
115 // PanicRecovery middleware recovers from panics and logs them.140 // PanicRecovery middleware recovers from panics and logs them.
116 func PanicRecovery(log zerowrap.Logger) func(http.Handler) http.Handler {141 func PanicRecovery(log zerowrap.Logger) func(http.Handler) http.Handler {
117 return func(next http.Handler) http.Handler {142 return func(next http.Handler) http.Handler {
M internal/adapters/in/http/middleware/security.go +13 -0
@@ -23,6 +23,19 @@ func SecurityHeaders(next http.Handler) http.Handler {
2323 // Restrict browser features
2424 w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
2525
26+ // HSTS: Enforce HTTPS-only for browsers. Only set when the request
27+ // arrived over a real TLS connection. We do NOT trust X-Forwarded-Proto
28+ // here because it is client-spoofable unless the request came from a
29+ // trusted proxy, and we have no trusted-proxy list at this layer.
30+ if r.TLS != nil {
31+ w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
32+ }
33+
34+ // NOTE: CSP is intentionally NOT set here. Gordon proxies arbitrary web
35+ // apps, so a blanket "default-src 'none'" would break all proxied sites.
36+ // Instead, CSP is set directly on proxy-generated error responses in the
37+ // proxy error handlers (service.go).
38+
2639 next.ServeHTTP(w, r)
2740 })
2841 }
@@ -23,6 +23,19 @@ func SecurityHeaders(next http.Handler) http.Handler {
23 // Restrict browser features23 // Restrict browser features
24 w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")24 w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
25 25
26+ // HSTS: Enforce HTTPS-only for browsers. Only set when the request
27+ // arrived over a real TLS connection. We do NOT trust X-Forwarded-Proto
28+ // here because it is client-spoofable unless the request came from a
29+ // trusted proxy, and we have no trusted-proxy list at this layer.
30+ if r.TLS != nil {
31+ w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
32+ }
33+
34+ // NOTE: CSP is intentionally NOT set here. Gordon proxies arbitrary web
35+ // apps, so a blanket "default-src 'none'" would break all proxied sites.
36+ // Instead, CSP is set directly on proxy-generated error responses in the
37+ // proxy error handlers (service.go).
38+
26 next.ServeHTTP(w, r)39 next.ServeHTTP(w, r)
27 })40 })
28 }41 }
M internal/adapters/in/http/middleware/security_test.go +38 -0
@@ -1,6 +1,7 @@
11 package middleware
22
33 import (
4+ "crypto/tls"
45 "net/http"
56 "net/http/httptest"
67 "testing"
@@ -27,6 +28,43 @@ func TestSecurityHeaders(t *testing.T) {
2728 assert.Equal(t, "1; mode=block", rec.Header().Get("X-XSS-Protection"))
2829 assert.Equal(t, "strict-origin-when-cross-origin", rec.Header().Get("Referrer-Policy"))
2930 assert.Equal(t, "geolocation=(), microphone=(), camera=()", rec.Header().Get("Permissions-Policy"))
31+
32+ // CSP should NOT be set globally (would break proxied sites)
33+ assert.Empty(t, rec.Header().Get("Content-Security-Policy"))
34+
35+ // HSTS should NOT be set on plain HTTP
36+ assert.Empty(t, rec.Header().Get("Strict-Transport-Security"))
37+}
38+
39+func TestSecurityHeaders_HSTS_WithTLS(t *testing.T) {
40+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
41+ w.WriteHeader(http.StatusOK)
42+ })
43+
44+ wrapped := SecurityHeaders(handler)
45+
46+ req := httptest.NewRequest(http.MethodGet, "/", nil)
47+ req.TLS = &tls.ConnectionState{} // Simulate TLS connection
48+ rec := httptest.NewRecorder()
49+ wrapped.ServeHTTP(rec, req)
50+
51+ assert.Equal(t, "max-age=31536000; includeSubDomains", rec.Header().Get("Strict-Transport-Security"))
52+}
53+
54+func TestSecurityHeaders_HSTS_WithForwardedProto_NotTrusted(t *testing.T) {
55+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
56+ w.WriteHeader(http.StatusOK)
57+ })
58+
59+ wrapped := SecurityHeaders(handler)
60+
61+ // X-Forwarded-Proto is client-spoofable and should NOT trigger HSTS
62+ req := httptest.NewRequest(http.MethodGet, "/", nil)
63+ req.Header.Set("X-Forwarded-Proto", "https")
64+ rec := httptest.NewRecorder()
65+ wrapped.ServeHTTP(rec, req)
66+
67+ assert.Empty(t, rec.Header().Get("Strict-Transport-Security"), "HSTS should not be set based on spoofable X-Forwarded-Proto")
3068 }
3169
3270 func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) {
@@ -1,6 +1,7 @@
1 package middleware1 package middleware
2 2
3 import (3 import (
4+ "crypto/tls"
4 "net/http"5 "net/http"
5 "net/http/httptest"6 "net/http/httptest"
6 "testing"7 "testing"
@@ -27,6 +28,43 @@ func TestSecurityHeaders(t *testing.T) {
27 assert.Equal(t, "1; mode=block", rec.Header().Get("X-XSS-Protection"))28 assert.Equal(t, "1; mode=block", rec.Header().Get("X-XSS-Protection"))
28 assert.Equal(t, "strict-origin-when-cross-origin", rec.Header().Get("Referrer-Policy"))29 assert.Equal(t, "strict-origin-when-cross-origin", rec.Header().Get("Referrer-Policy"))
29 assert.Equal(t, "geolocation=(), microphone=(), camera=()", rec.Header().Get("Permissions-Policy"))30 assert.Equal(t, "geolocation=(), microphone=(), camera=()", rec.Header().Get("Permissions-Policy"))
31+
32+ // CSP should NOT be set globally (would break proxied sites)
33+ assert.Empty(t, rec.Header().Get("Content-Security-Policy"))
34+
35+ // HSTS should NOT be set on plain HTTP
36+ assert.Empty(t, rec.Header().Get("Strict-Transport-Security"))
37+}
38+
39+func TestSecurityHeaders_HSTS_WithTLS(t *testing.T) {
40+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
41+ w.WriteHeader(http.StatusOK)
42+ })
43+
44+ wrapped := SecurityHeaders(handler)
45+
46+ req := httptest.NewRequest(http.MethodGet, "/", nil)
47+ req.TLS = &tls.ConnectionState{} // Simulate TLS connection
48+ rec := httptest.NewRecorder()
49+ wrapped.ServeHTTP(rec, req)
50+
51+ assert.Equal(t, "max-age=31536000; includeSubDomains", rec.Header().Get("Strict-Transport-Security"))
52+}
53+
54+func TestSecurityHeaders_HSTS_WithForwardedProto_NotTrusted(t *testing.T) {
55+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
56+ w.WriteHeader(http.StatusOK)
57+ })
58+
59+ wrapped := SecurityHeaders(handler)
60+
61+ // X-Forwarded-Proto is client-spoofable and should NOT trigger HSTS
62+ req := httptest.NewRequest(http.MethodGet, "/", nil)
63+ req.Header.Set("X-Forwarded-Proto", "https")
64+ rec := httptest.NewRecorder()
65+ wrapped.ServeHTTP(rec, req)
66+
67+ assert.Empty(t, rec.Header().Get("Strict-Transport-Security"), "HSTS should not be set based on spoofable X-Forwarded-Proto")
30 }68 }
31 69
32 func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) {70 func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) {
M internal/adapters/in/http/proxy/handler.go +3 -1
@@ -71,6 +71,8 @@ func (h *Handler) Start(ctx context.Context, handler http.Handler) error {
7171 Str(zerowrap.FieldLayer, "adapter").
7272 Str(zerowrap.FieldAdapter, "http").
7373 Msg("proxy server shutting down")
74- return server.Shutdown(context.Background())
74+ shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
75+ defer shutdownCancel()
76+ return server.Shutdown(shutdownCtx)
7577 }
7678 }
@@ -71,6 +71,8 @@ func (h *Handler) Start(ctx context.Context, handler http.Handler) error {
71 Str(zerowrap.FieldLayer, "adapter").71 Str(zerowrap.FieldLayer, "adapter").
72 Str(zerowrap.FieldAdapter, "http").72 Str(zerowrap.FieldAdapter, "http").
73 Msg("proxy server shutting down")73 Msg("proxy server shutting down")
74- return server.Shutdown(context.Background())74+ shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
75+ defer shutdownCancel()
76+ return server.Shutdown(shutdownCtx)
75 }77 }
76 }78 }
M internal/app/run.go +122 -62
@@ -70,17 +70,19 @@ import (
7070 // Config holds the application configuration.
7171 type Config struct {
7272 Server struct {
73- Port int `mapstructure:"port"`
74- RegistryPort int `mapstructure:"registry_port"`
75- GordonDomain string `mapstructure:"gordon_domain"`
76- RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain
77- TLSEnabled bool `mapstructure:"tls_enabled"`
78- TLSPort int `mapstructure:"tls_port"`
79- TLSCertFile string `mapstructure:"tls_cert_file"`
80- TLSKeyFile string `mapstructure:"tls_key_file"`
81- DataDir string `mapstructure:"data_dir"`
82- MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"
83- MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"
73+ Port int `mapstructure:"port"`
74+ RegistryPort int `mapstructure:"registry_port"`
75+ GordonDomain string `mapstructure:"gordon_domain"`
76+ RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain
77+ TLSEnabled bool `mapstructure:"tls_enabled"`
78+ TLSPort int `mapstructure:"tls_port"`
79+ TLSCertFile string `mapstructure:"tls_cert_file"`
80+ TLSKeyFile string `mapstructure:"tls_key_file"`
81+ DataDir string `mapstructure:"data_dir"`
82+ MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"
83+ MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"
84+ MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit
85+ MaxConcurrentConns int `mapstructure:"max_concurrent_connections"`
8486 } `mapstructure:"server"`
8587
8688 Logging struct {
@@ -431,33 +433,12 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
431433 }
432434 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
433435
434- // Parse max_proxy_body_size config (default: 512MB)
435- maxProxyBodySize := int64(512 << 20) // 512MB default
436- if cfg.Server.MaxProxyBodySize != "" {
437- parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyBodySize)
438- if err != nil {
439- return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_body_size configuration", map[string]any{"value": cfg.Server.MaxProxyBodySize})
440- }
441- maxProxyBodySize = parsedSize
442- }
443-
444- // Parse max_blob_chunk_size config (default: 95MB)
445- maxBlobChunkSize := int64(registry.DefaultMaxBlobChunkSize)
446- if cfg.Server.MaxBlobChunkSize != "" {
447- parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobChunkSize)
448- if err != nil {
449- return nil, log.WrapErrWithFields(err, "invalid server.max_blob_chunk_size configuration", map[string]any{"value": cfg.Server.MaxBlobChunkSize})
450- }
451- maxBlobChunkSize = parsedSize
436+ proxyCfg, err := buildProxyConfig(cfg, log)
437+ if err != nil {
438+ return nil, err
452439 }
453-
454- svc.maxBlobChunkSize = maxBlobChunkSize
455-
456- svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{
457- RegistryDomain: cfg.Server.RegistryDomain,
458- RegistryPort: cfg.Server.RegistryPort,
459- MaxBodySize: maxProxyBodySize,
460- })
440+ svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize
441+ svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxyCfg.proxyConfig)
461442
462443 // Create token handler for registry token endpoint
463444 if svc.authSvc != nil {
@@ -983,6 +964,59 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi
983964 }
984965 }
985966
967+// proxyConfigResult holds parsed proxy and blob chunk size config.
968+type proxyConfigResult struct {
969+ proxyConfig proxy.Config
970+ maxBlobChunkSize int64
971+}
972+
973+// buildProxyConfig parses size-related config fields and builds the proxy config.
974+func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, error) {
975+ maxProxyBodySize := int64(512 << 20) // 512MB default
976+ if cfg.Server.MaxProxyBodySize != "" {
977+ parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyBodySize)
978+ if err != nil {
979+ return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_body_size configuration", map[string]any{"value": cfg.Server.MaxProxyBodySize})
980+ }
981+ maxProxyBodySize = parsedSize
982+ }
983+
984+ maxBlobChunkSize := int64(registry.DefaultMaxBlobChunkSize)
985+ if cfg.Server.MaxBlobChunkSize != "" {
986+ parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobChunkSize)
987+ if err != nil {
988+ return nil, log.WrapErrWithFields(err, "invalid server.max_blob_chunk_size configuration", map[string]any{"value": cfg.Server.MaxBlobChunkSize})
989+ }
990+ maxBlobChunkSize = parsedSize
991+ }
992+
993+ maxProxyResponseSize := int64(1 << 30) // 1GB default
994+ if cfg.Server.MaxProxyResponseSize != "" {
995+ parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyResponseSize)
996+ if err != nil {
997+ return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_response_size configuration", map[string]any{"value": cfg.Server.MaxProxyResponseSize})
998+ }
999+ maxProxyResponseSize = parsedSize
1000+ }
1001+
1002+ maxConcurrentConns := cfg.Server.MaxConcurrentConns
1003+ if maxConcurrentConns < 0 {
1004+ maxConcurrentConns = 10000 // default when explicitly set to -1
1005+ }
1006+ // 0 means no limit (as documented in proxy.Config)
1007+
1008+ return &proxyConfigResult{
1009+ proxyConfig: proxy.Config{
1010+ RegistryDomain: cfg.Server.RegistryDomain,
1011+ RegistryPort: cfg.Server.RegistryPort,
1012+ MaxBodySize: maxProxyBodySize,
1013+ MaxResponseSize: maxProxyResponseSize,
1014+ MaxConcurrentConns: maxConcurrentConns,
1015+ },
1016+ maxBlobChunkSize: maxBlobChunkSize,
1017+ }, nil
1018+}
1019+
9861020 // createContainerService creates the container service with configuration.
9871021 func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
9881022 containerConfig := container.Config{
@@ -1083,11 +1117,21 @@ func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, lo
10831117 return
10841118 }
10851119
1086- // Update proxy config from viper (reads directly from viper, not memory)
1087- svc.proxySvc.UpdateConfig(proxy.Config{
1088- RegistryDomain: v.GetString("server.registry_domain"),
1089- RegistryPort: v.GetInt("server.registry_port"),
1090- })
1120+ // Re-unmarshal config and rebuild proxy config to pick up all changes
1121+ var reloadCfg Config
1122+ if err := v.Unmarshal(&reloadCfg); err != nil {
1123+ log.Error().Err(err).Msg("failed to unmarshal config on reload")
1124+ return
1125+ }
1126+ if reloadCfg.Server.GordonDomain != "" {
1127+ reloadCfg.Server.RegistryDomain = reloadCfg.Server.GordonDomain
1128+ }
1129+ reloadedProxy, err := buildProxyConfig(reloadCfg, log)
1130+ if err != nil {
1131+ log.Error().Err(err).Msg("failed to parse proxy config on reload")
1132+ return
1133+ }
1134+ svc.proxySvc.UpdateConfig(reloadedProxy.proxyConfig)
10911135
10921136 // Clear proxy target cache to pick up external route changes
10931137 if err := svc.proxySvc.RefreshTargets(ctx); err != nil {
@@ -1309,39 +1353,54 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
13091353 }
13101354
13111355 shutdown:
1356+ gracefulShutdown(registrySrv, proxySrv, containerSvc, log)
1357+ return nil
1358+}
1359+
1360+// gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down
1361+// the container service and cleans up runtime files.
1362+func gracefulShutdown(registrySrv, proxySrv *http.Server, containerSvc *container.Service, log zerowrap.Logger) {
13121363 log.Info().Msg("shutting down Gordon...")
13131364
1314- if err := containerSvc.Shutdown(ctx); err != nil {
1365+ shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
1366+ defer shutdownCancel()
1367+
1368+ if err := registrySrv.Shutdown(shutdownCtx); err != nil {
1369+ log.Warn().Err(err).Msg("registry server shutdown error")
1370+ }
1371+ if err := proxySrv.Shutdown(shutdownCtx); err != nil {
1372+ log.Warn().Err(err).Msg("proxy server shutdown error")
1373+ }
1374+
1375+ if err := containerSvc.Shutdown(shutdownCtx); err != nil {
13151376 log.Warn().Err(err).Msg("error during container shutdown")
13161377 }
13171378
1318- // Clean up internal credentials file
13191379 cleanupInternalCredentials()
1320-
13211380 log.Info().Msg("Gordon stopped")
1322- return nil
13231381 }
13241382
1325-// startServer starts an HTTP server, returning a channel that closes once the
1326-// listening socket is bound. This lets callers wait for the port to be ready
1327-// before taking actions that depend on it (e.g. auto-start pulling from the
1328-// local registry).
1329-func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) <-chan struct{} {
1383+// startServer starts an HTTP server, returning the server instance and a channel
1384+// that closes once the listening socket is bound. This lets callers wait for the
1385+// port to be ready before taking actions that depend on it (e.g. auto-start
1386+// pulling from the local registry). The returned *http.Server can be used for
1387+// graceful shutdown.
1388+func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) {
13301389 ready := make(chan struct{})
13311390
1391+ server := &http.Server{
1392+ Addr: addr,
1393+ Handler: handler,
1394+ ReadHeaderTimeout: 10 * time.Second,
1395+ ReadTimeout: 5 * time.Minute,
1396+ WriteTimeout: 5 * time.Minute,
1397+ IdleTimeout: 120 * time.Second,
1398+ MaxHeaderBytes: 1 << 20,
1399+ }
1400+
13321401 go func() {
13331402 log.Info().Str("address", addr).Msgf("%s server starting", name)
13341403
1335- server := &http.Server{
1336- Addr: addr,
1337- Handler: handler,
1338- ReadHeaderTimeout: 10 * time.Second,
1339- ReadTimeout: 5 * time.Minute,
1340- WriteTimeout: 5 * time.Minute,
1341- IdleTimeout: 120 * time.Second,
1342- MaxHeaderBytes: 1 << 20,
1343- }
1344-
13451404 ln, err := net.Listen("tcp", addr)
13461405 if err != nil {
13471406 errChan <- fmt.Errorf("%s server error: %w", name, err)
@@ -1354,7 +1413,7 @@ func startServer(addr string, handler http.Handler, name string, errChan chan<-
13541413 }
13551414 }()
13561415
1357- return ready
1416+ return server, ready
13581417 }
13591418
13601419 // startTLSServer starts an HTTPS server with the provided certificate and key.
@@ -1680,6 +1739,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
16801739 v.SetDefault("volumes.prefix", "gordon")
16811740 v.SetDefault("volumes.preserve", true)
16821741 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
1742+ v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
16831743 v.SetDefault("deploy.readiness_delay", "5s")
16841744
16851745 ConfigureViper(v, configPath)
@@ -70,17 +70,19 @@ import (
70 // Config holds the application configuration.70 // Config holds the application configuration.
71 type Config struct {71 type Config struct {
72 Server struct {72 Server struct {
73- Port int `mapstructure:"port"`73+ Port int `mapstructure:"port"`
74- RegistryPort int `mapstructure:"registry_port"`74+ RegistryPort int `mapstructure:"registry_port"`
75- GordonDomain string `mapstructure:"gordon_domain"`75+ GordonDomain string `mapstructure:"gordon_domain"`
76- RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain76+ RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain
77- TLSEnabled bool `mapstructure:"tls_enabled"`77+ TLSEnabled bool `mapstructure:"tls_enabled"`
78- TLSPort int `mapstructure:"tls_port"`78+ TLSPort int `mapstructure:"tls_port"`
79- TLSCertFile string `mapstructure:"tls_cert_file"`79+ TLSCertFile string `mapstructure:"tls_cert_file"`
80- TLSKeyFile string `mapstructure:"tls_key_file"`80+ TLSKeyFile string `mapstructure:"tls_key_file"`
81- DataDir string `mapstructure:"data_dir"`81+ DataDir string `mapstructure:"data_dir"`
82- MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"82+ MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"
83- MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"83+ MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"
84+ MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit
85+ MaxConcurrentConns int `mapstructure:"max_concurrent_connections"`
84 } `mapstructure:"server"`86 } `mapstructure:"server"`
85 87
86 Logging struct {88 Logging struct {
@@ -431,33 +433,12 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
431 }433 }
432 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)434 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
433 435
434- // Parse max_proxy_body_size config (default: 512MB)436+ proxyCfg, err := buildProxyConfig(cfg, log)
435- maxProxyBodySize := int64(512 << 20) // 512MB default437+ if err != nil {
436- if cfg.Server.MaxProxyBodySize != "" {438+ return nil, err
437- parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyBodySize)
438- if err != nil {
439- return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_body_size configuration", map[string]any{"value": cfg.Server.MaxProxyBodySize})
440- }
441- maxProxyBodySize = parsedSize
442- }
443-
444- // Parse max_blob_chunk_size config (default: 95MB)
445- maxBlobChunkSize := int64(registry.DefaultMaxBlobChunkSize)
446- if cfg.Server.MaxBlobChunkSize != "" {
447- parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobChunkSize)
448- if err != nil {
449- return nil, log.WrapErrWithFields(err, "invalid server.max_blob_chunk_size configuration", map[string]any{"value": cfg.Server.MaxBlobChunkSize})
450- }
451- maxBlobChunkSize = parsedSize
452 }439 }
453-440+ svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize
454- svc.maxBlobChunkSize = maxBlobChunkSize441+ svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxyCfg.proxyConfig)
455-
456- svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{
457- RegistryDomain: cfg.Server.RegistryDomain,
458- RegistryPort: cfg.Server.RegistryPort,
459- MaxBodySize: maxProxyBodySize,
460- })
461 442
462 // Create token handler for registry token endpoint443 // Create token handler for registry token endpoint
463 if svc.authSvc != nil {444 if svc.authSvc != nil {
@@ -983,6 +964,59 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi
983 }964 }
984 }965 }
985 966
967+// proxyConfigResult holds parsed proxy and blob chunk size config.
968+type proxyConfigResult struct {
969+ proxyConfig proxy.Config
970+ maxBlobChunkSize int64
971+}
972+
973+// buildProxyConfig parses size-related config fields and builds the proxy config.
974+func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, error) {
975+ maxProxyBodySize := int64(512 << 20) // 512MB default
976+ if cfg.Server.MaxProxyBodySize != "" {
977+ parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyBodySize)
978+ if err != nil {
979+ return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_body_size configuration", map[string]any{"value": cfg.Server.MaxProxyBodySize})
980+ }
981+ maxProxyBodySize = parsedSize
982+ }
983+
984+ maxBlobChunkSize := int64(registry.DefaultMaxBlobChunkSize)
985+ if cfg.Server.MaxBlobChunkSize != "" {
986+ parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobChunkSize)
987+ if err != nil {
988+ return nil, log.WrapErrWithFields(err, "invalid server.max_blob_chunk_size configuration", map[string]any{"value": cfg.Server.MaxBlobChunkSize})
989+ }
990+ maxBlobChunkSize = parsedSize
991+ }
992+
993+ maxProxyResponseSize := int64(1 << 30) // 1GB default
994+ if cfg.Server.MaxProxyResponseSize != "" {
995+ parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyResponseSize)
996+ if err != nil {
997+ return nil, log.WrapErrWithFields(err, "invalid server.max_proxy_response_size configuration", map[string]any{"value": cfg.Server.MaxProxyResponseSize})
998+ }
999+ maxProxyResponseSize = parsedSize
1000+ }
1001+
1002+ maxConcurrentConns := cfg.Server.MaxConcurrentConns
1003+ if maxConcurrentConns < 0 {
1004+ maxConcurrentConns = 10000 // default when explicitly set to -1
1005+ }
1006+ // 0 means no limit (as documented in proxy.Config)
1007+
1008+ return &proxyConfigResult{
1009+ proxyConfig: proxy.Config{
1010+ RegistryDomain: cfg.Server.RegistryDomain,
1011+ RegistryPort: cfg.Server.RegistryPort,
1012+ MaxBodySize: maxProxyBodySize,
1013+ MaxResponseSize: maxProxyResponseSize,
1014+ MaxConcurrentConns: maxConcurrentConns,
1015+ },
1016+ maxBlobChunkSize: maxBlobChunkSize,
1017+ }, nil
1018+}
1019+
986 // createContainerService creates the container service with configuration.1020 // createContainerService creates the container service with configuration.
987 func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {1021 func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
988 containerConfig := container.Config{1022 containerConfig := container.Config{
@@ -1083,11 +1117,21 @@ func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, lo
1083 return1117 return
1084 }1118 }
1085 1119
1086- // Update proxy config from viper (reads directly from viper, not memory)1120+ // Re-unmarshal config and rebuild proxy config to pick up all changes
1087- svc.proxySvc.UpdateConfig(proxy.Config{1121+ var reloadCfg Config
1088- RegistryDomain: v.GetString("server.registry_domain"),1122+ if err := v.Unmarshal(&reloadCfg); err != nil {
1089- RegistryPort: v.GetInt("server.registry_port"),1123+ log.Error().Err(err).Msg("failed to unmarshal config on reload")
1090- })1124+ return
1125+ }
1126+ if reloadCfg.Server.GordonDomain != "" {
1127+ reloadCfg.Server.RegistryDomain = reloadCfg.Server.GordonDomain
1128+ }
1129+ reloadedProxy, err := buildProxyConfig(reloadCfg, log)
1130+ if err != nil {
1131+ log.Error().Err(err).Msg("failed to parse proxy config on reload")
1132+ return
1133+ }
1134+ svc.proxySvc.UpdateConfig(reloadedProxy.proxyConfig)
1091 1135
1092 // Clear proxy target cache to pick up external route changes1136 // Clear proxy target cache to pick up external route changes
1093 if err := svc.proxySvc.RefreshTargets(ctx); err != nil {1137 if err := svc.proxySvc.RefreshTargets(ctx); err != nil {
@@ -1309,39 +1353,54 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
1309 }1353 }
1310 1354
1311 shutdown:1355 shutdown:
1356+ gracefulShutdown(registrySrv, proxySrv, containerSvc, log)
1357+ return nil
1358+}
1359+
1360+// gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down
1361+// the container service and cleans up runtime files.
1362+func gracefulShutdown(registrySrv, proxySrv *http.Server, containerSvc *container.Service, log zerowrap.Logger) {
1312 log.Info().Msg("shutting down Gordon...")1363 log.Info().Msg("shutting down Gordon...")
1313 1364
1314- if err := containerSvc.Shutdown(ctx); err != nil {1365+ shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
1366+ defer shutdownCancel()
1367+
1368+ if err := registrySrv.Shutdown(shutdownCtx); err != nil {
1369+ log.Warn().Err(err).Msg("registry server shutdown error")
1370+ }
1371+ if err := proxySrv.Shutdown(shutdownCtx); err != nil {
1372+ log.Warn().Err(err).Msg("proxy server shutdown error")
1373+ }
1374+
1375+ if err := containerSvc.Shutdown(shutdownCtx); err != nil {
1315 log.Warn().Err(err).Msg("error during container shutdown")1376 log.Warn().Err(err).Msg("error during container shutdown")
1316 }1377 }
1317 1378
1318- // Clean up internal credentials file
1319 cleanupInternalCredentials()1379 cleanupInternalCredentials()
1320-
1321 log.Info().Msg("Gordon stopped")1380 log.Info().Msg("Gordon stopped")
1322- return nil
1323 }1381 }
1324 1382
1325-// startServer starts an HTTP server, returning a channel that closes once the1383+// startServer starts an HTTP server, returning the server instance and a channel
1326-// listening socket is bound. This lets callers wait for the port to be ready1384+// that closes once the listening socket is bound. This lets callers wait for the
1327-// before taking actions that depend on it (e.g. auto-start pulling from the1385+// port to be ready before taking actions that depend on it (e.g. auto-start
1328-// local registry).1386+// pulling from the local registry). The returned *http.Server can be used for
1329-func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) <-chan struct{} {1387+// graceful shutdown.
1388+func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}) {
1330 ready := make(chan struct{})1389 ready := make(chan struct{})
1331 1390
1391+ server := &http.Server{
1392+ Addr: addr,
1393+ Handler: handler,
1394+ ReadHeaderTimeout: 10 * time.Second,
1395+ ReadTimeout: 5 * time.Minute,
1396+ WriteTimeout: 5 * time.Minute,
1397+ IdleTimeout: 120 * time.Second,
1398+ MaxHeaderBytes: 1 << 20,
1399+ }
1400+
1332 go func() {1401 go func() {
1333 log.Info().Str("address", addr).Msgf("%s server starting", name)1402 log.Info().Str("address", addr).Msgf("%s server starting", name)
1334 1403
1335- server := &http.Server{
1336- Addr: addr,
1337- Handler: handler,
1338- ReadHeaderTimeout: 10 * time.Second,
1339- ReadTimeout: 5 * time.Minute,
1340- WriteTimeout: 5 * time.Minute,
1341- IdleTimeout: 120 * time.Second,
1342- MaxHeaderBytes: 1 << 20,
1343- }
1344-
1345 ln, err := net.Listen("tcp", addr)1404 ln, err := net.Listen("tcp", addr)
1346 if err != nil {1405 if err != nil {
1347 errChan <- fmt.Errorf("%s server error: %w", name, err)1406 errChan <- fmt.Errorf("%s server error: %w", name, err)
@@ -1354,7 +1413,7 @@ func startServer(addr string, handler http.Handler, name string, errChan chan<-
1354 }1413 }
1355 }()1414 }()
1356 1415
1357- return ready1416+ return server, ready
1358 }1417 }
1359 1418
1360 // startTLSServer starts an HTTPS server with the provided certificate and key.1419 // startTLSServer starts an HTTPS server with the provided certificate and key.
@@ -1680,6 +1739,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
1680 v.SetDefault("volumes.prefix", "gordon")1739 v.SetDefault("volumes.prefix", "gordon")
1681 v.SetDefault("volumes.preserve", true)1740 v.SetDefault("volumes.preserve", true)
1682 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)1741 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
1742+ v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
1683 v.SetDefault("deploy.readiness_delay", "5s")1743 v.SetDefault("deploy.readiness_delay", "5s")
1684 1744
1685 ConfigureViper(v, configPath)1745 ConfigureViper(v, configPath)
M internal/domain/route.go +5 -4
@@ -9,10 +9,11 @@ type Route struct {
99
1010 // ProxyTarget represents the destination for proxying requests.
1111 type ProxyTarget struct {
12- Host string
13- Port int
14- ContainerID string
15- Scheme string // "http" or "https"
12+ Host string
13+ Port int
14+ ContainerID string
15+ Scheme string // "http" or "https"
16+ OriginalHost string // Original hostname before DNS resolution (for Host header)
1617 }
1718
1819 // RouteMatch represents the result of matching a request to a route.
@@ -9,10 +9,11 @@ type Route struct {
9 9
10 // ProxyTarget represents the destination for proxying requests.10 // ProxyTarget represents the destination for proxying requests.
11 type ProxyTarget struct {11 type ProxyTarget struct {
12- Host string12+ Host string
13- Port int13+ Port int
14- ContainerID string14+ ContainerID string
15- Scheme string // "http" or "https"15+ Scheme string // "http" or "https"
16+ OriginalHost string // Original hostname before DNS resolution (for Host header)
16 }17 }
17 18
18 // RouteMatch represents the result of matching a request to a route.19 // RouteMatch represents the result of matching a request to a route.
M internal/usecase/proxy/service.go +153 -29
@@ -3,7 +3,9 @@ package proxy
33
44 import (
55 "context"
6+ "errors"
67 "fmt"
8+ "io"
79 "net"
810 "net/http"
911 "net/http/httputil"
@@ -12,6 +14,7 @@ import (
1214 "strconv"
1315 "strings"
1416 "sync"
17+ "sync/atomic"
1518 "time"
1619
1720 "github.com/bnema/zerowrap"
@@ -37,9 +40,11 @@ var proxyTransport = &http.Transport{
3740
3841 // Config holds configuration needed by the proxy service.
3942 type Config struct {
40- RegistryDomain string
41- RegistryPort int
42- MaxBodySize int64 // Maximum request body size in bytes (0 = no limit)
43+ RegistryDomain string
44+ RegistryPort int
45+ MaxBodySize int64 // Maximum request body size in bytes (0 = no limit)
46+ MaxResponseSize int64 // Maximum response body size in bytes (0 = no limit)
47+ MaxConcurrentConns int // Maximum concurrent proxy connections (0 = no limit)
4348 }
4449
4550 // Service implements the ProxyService interface.
@@ -50,6 +55,7 @@ type Service struct {
5055 config Config
5156 targets map[string]*domain.ProxyTarget
5257 mu sync.RWMutex
58+ activeConns atomic.Int64 // Atomic counter for concurrent connection limiting
5359 }
5460
5561 // NewService creates a new proxy service.
@@ -75,6 +81,19 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
7581 cfg := s.config
7682 s.mu.RUnlock()
7783
84+ // SECURITY: Limit concurrent connections to prevent resource exhaustion.
85+ // Uses an atomic counter so config reloads don't create a race between
86+ // an old and new semaphore — the counter is shared across reloads.
87+ if cfg.MaxConcurrentConns > 0 {
88+ current := s.activeConns.Add(1)
89+ if current > int64(cfg.MaxConcurrentConns) {
90+ s.activeConns.Add(-1)
91+ proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
92+ return
93+ }
94+ defer s.activeConns.Add(-1)
95+ }
96+
7897 // SECURITY: Limit request body size to prevent resource exhaustion.
7998 if cfg.MaxBodySize > 0 {
8099 r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodySize)
@@ -104,7 +123,7 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
104123 target, err := s.GetTarget(ctx, r.Host)
105124 if err != nil {
106125 log.Warn().Err(err).Msg("no route found for domain")
107- http.NotFound(w, r)
126+ proxyError(w, "404 page not found", http.StatusNotFound)
108127 return
109128 }
110129
@@ -151,8 +170,12 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
151170 return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
152171 }
153172
154- // SECURITY: Validate target is not an internal/blocked network (SSRF protection)
155- if err := ValidateExternalRouteTarget(host); err != nil {
173+ // SECURITY: Resolve DNS and validate that the target is not an internal/blocked
174+ // network. We use the resolved IP as the proxy target to prevent DNS rebinding
175+ // (TOCTOU) attacks where a hostname resolves to a public IP during validation
176+ // but to a private IP when the proxy connects.
177+ resolvedIP, err := ResolveAndValidateHost(host)
178+ if err != nil {
156179 log.Warn().
157180 Err(err).
158181 Str("host", host).
@@ -161,11 +184,19 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
161184 return nil, err
162185 }
163186
187+ // Preserve the original hostname for the Host header so virtual-hosted
188+ // upstreams work correctly. The resolved IP is used for dialing only.
189+ var originalHost string
190+ if resolvedIP != host {
191+ originalHost = host
192+ }
193+
164194 target := &domain.ProxyTarget{
165- Host: host,
166- Port: port,
167- ContainerID: "", // Not a container
168- Scheme: "http",
195+ Host: resolvedIP,
196+ Port: port,
197+ ContainerID: "", // Not a container
198+ Scheme: "http",
199+ OriginalHost: originalHost,
169200 }
170201
171202 // Cache external route target
@@ -285,19 +316,93 @@ func (s *Service) RefreshTargets(ctx context.Context) error {
285316 }
286317
287318 // UpdateConfig updates the service configuration.
319+// Connection limiting uses an atomic counter checked against config, so
320+// changing MaxConcurrentConns takes effect immediately without any race.
288321 func (s *Service) UpdateConfig(config Config) {
289322 s.mu.Lock()
290323 s.config = config
291324 s.mu.Unlock()
292325 }
293326
327+// modifyResponse returns a function that modifies backend responses.
328+// It adds proxy headers and enforces the response size limit if configured.
329+func (s *Service) modifyResponse(cfg Config) func(*http.Response) error {
330+ return func(resp *http.Response) error {
331+ resp.Header.Set("X-Proxied-By", "Gordon")
332+
333+ // SECURITY: Enforce response size limit to prevent memory exhaustion
334+ // from malicious or buggy backends streaming unbounded data.
335+ if cfg.MaxResponseSize > 0 {
336+ if resp.ContentLength > cfg.MaxResponseSize {
337+ resp.Body.Close()
338+ const msg = "Response Too Large"
339+ resp.StatusCode = http.StatusBadGateway
340+ resp.Body = io.NopCloser(strings.NewReader(msg))
341+ resp.ContentLength = int64(len(msg))
342+ resp.Header.Set("Content-Type", "text/plain")
343+ resp.Header.Set("Cache-Control", "no-store")
344+ resp.Header.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
345+ // Ensure framing/encoding headers match the substituted body
346+ resp.Header.Del("Transfer-Encoding")
347+ resp.Header.Del("Content-Encoding")
348+ resp.Header.Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
349+ return nil
350+ }
351+ // For chunked/streaming responses where Content-Length is unknown,
352+ // wrap with LimitReader to enforce the cap.
353+ if resp.ContentLength < 0 {
354+ resp.Body = &limitedReadCloser{
355+ ReadCloser: resp.Body,
356+ remaining: cfg.MaxResponseSize,
357+ }
358+ }
359+ }
360+
361+ return nil
362+ }
363+}
364+
365+// limitedReadCloser wraps an io.ReadCloser with a byte limit.
366+// When the limit is exceeded, subsequent reads return an error.
367+type limitedReadCloser struct {
368+ io.ReadCloser
369+ remaining int64
370+}
371+
372+func (l *limitedReadCloser) Read(p []byte) (int, error) {
373+ if l.remaining <= 0 {
374+ // Close the underlying body to release upstream connection/resources
375+ l.Close()
376+ return 0, fmt.Errorf("response body exceeded maximum size limit")
377+ }
378+ if int64(len(p)) > l.remaining {
379+ p = p[:l.remaining]
380+ }
381+ n, err := l.ReadCloser.Read(p)
382+ l.remaining -= int64(n)
383+ return n, err
384+}
385+
386+// proxyError writes an error response with security headers appropriate for
387+// proxy-generated error pages. CSP is set here (not in global middleware)
388+// because Gordon proxies arbitrary web apps and a blanket CSP would break them.
389+func proxyError(w http.ResponseWriter, msg string, code int) {
390+ w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
391+ w.Header().Set("Cache-Control", "no-store")
392+ http.Error(w, msg, code)
393+}
394+
294395 // Helper methods
295396
296397 // newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
297398 // hop-by-hop header attacks. A malicious client could send "Connection: Authorization"
298399 // to strip the Authorization header when using the default Director. Rewrite processes
299400 // headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
300-func newReverseProxy(targetURL *url.URL, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResponse func(*http.Response) error) *httputil.ReverseProxy {
401+//
402+// originalHost, when non-empty, overrides the Host header sent to the backend.
403+// This is used for DNS-pinned targets where the proxy dials an IP but needs to
404+// send the original hostname for virtual-hosted upstreams.
405+func newReverseProxy(targetURL *url.URL, originalHost string, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResponse func(*http.Response) error) *httputil.ReverseProxy {
301406 return &httputil.ReverseProxy{
302407 Rewrite: func(pr *httputil.ProxyRequest) {
303408 pr.SetURL(targetURL)
@@ -307,7 +412,13 @@ func newReverseProxy(targetURL *url.URL, errorHandler func(http.ResponseWriter,
307412 // proxy. An attacker could spoof X-Forwarded-Proto: https on an HTTP connection
308413 // to trick backends into thinking the connection is secure.
309414 pr.SetXForwarded()
310- pr.Out.Host = targetURL.Host
415+ if originalHost != "" {
416+ // DNS pinning: dial the resolved IP but send the original hostname
417+ // so virtual-hosted upstreams route the request correctly.
418+ pr.Out.Host = net.JoinHostPort(originalHost, targetURL.Port())
419+ } else {
420+ pr.Out.Host = targetURL.Host
421+ }
311422 },
312423 Transport: proxyTransport,
313424 ErrorHandler: errorHandler,
@@ -316,32 +427,38 @@ func newReverseProxy(targetURL *url.URL, errorHandler func(http.ResponseWriter,
316427 }
317428
318429 func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) {
430+ s.mu.RLock()
431+ cfg := s.config
432+ s.mu.RUnlock()
433+
319434 log := zerowrap.FromCtx(r.Context())
320435
321- targetURL, err := url.Parse(fmt.Sprintf("%s://%s:%d", target.Scheme, target.Host, target.Port))
436+ // Use net.JoinHostPort for IPv6-safe URL construction (brackets are added automatically)
437+ targetURL, err := url.Parse(fmt.Sprintf("%s://%s", target.Scheme, net.JoinHostPort(target.Host, strconv.Itoa(target.Port))))
322438 if err != nil {
323439 log.WrapErr(err, "failed to parse target URL")
324- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
440+ proxyError(w, "Internal Server Error", http.StatusInternalServerError)
325441 return
326442 }
327443
328444 log.Debug().Str("target_url", targetURL.String()).Msg("proxying to target")
329445
330- proxy := newReverseProxy(targetURL,
446+ // If DNS pinning resolved hostname to IP, preserve the original hostname
447+ // for the Host header so virtual-hosted upstreams work correctly.
448+ originalHost := target.OriginalHost
449+
450+ proxy := newReverseProxy(targetURL, originalHost,
331451 func(w http.ResponseWriter, _ *http.Request, err error) {
332- // Check if this is a MaxBytesError (request body too large)
333- if err.Error() == "http: request body too large" {
452+ var maxBytesErr *http.MaxBytesError
453+ if errors.As(err, &maxBytesErr) {
334454 log.Warn().Err(err).Str("target", targetURL.String()).Msg("proxy error: request body too large")
335- http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
455+ proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
336456 return
337457 }
338458 log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed")
339- http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
340- },
341- func(resp *http.Response) error {
342- resp.Header.Set("X-Proxied-By", "Gordon")
343- return nil
459+ proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
344460 },
461+ s.modifyResponse(cfg),
345462 )
346463
347464 log.Debug().
@@ -352,6 +469,11 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *
352469 proxy.ServeHTTP(w, r)
353470 }
354471
472+// proxyToRegistry forwards requests to the local registry HTTP server.
473+// SECURITY: This uses http://localhost:{port} which is safe because the registry
474+// runs on the same host and traffic never leaves the loopback interface. If Gordon
475+// ever supports running the registry on a separate host, this must use TLS with
476+// certificate verification.
355477 func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
356478 // Copy config under RLock to avoid data race with UpdateConfig
357479 s.mu.RLock()
@@ -363,20 +485,20 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
363485 targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort))
364486 if err != nil {
365487 log.WrapErr(err, "failed to parse registry target URL")
366- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
488+ proxyError(w, "Internal Server Error", http.StatusInternalServerError)
367489 return
368490 }
369491
370- proxy := newReverseProxy(targetURL,
492+ proxy := newReverseProxy(targetURL, "",
371493 func(w http.ResponseWriter, _ *http.Request, err error) {
372- // Check if this is a MaxBytesError (request body too large)
373- if err.Error() == "http: request body too large" {
494+ var maxBytesErr *http.MaxBytesError
495+ if errors.As(err, &maxBytesErr) {
374496 log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large")
375- http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
497+ proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
376498 return
377499 }
378500 log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")
379- http.Error(w, "Registry Unavailable", http.StatusServiceUnavailable)
501+ proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable)
380502 },
381503 func(resp *http.Response) error {
382504 resp.Header.Set("X-Proxied-By", "Gordon")
@@ -390,6 +512,8 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
390512 resp.Header.Del("X-XSS-Protection")
391513 resp.Header.Del("Referrer-Policy")
392514 resp.Header.Del("Permissions-Policy")
515+ resp.Header.Del("Content-Security-Policy")
516+ resp.Header.Del("Strict-Transport-Security")
393517
394518 return nil
395519 },
@@ -3,7 +3,9 @@ package proxy
3 3
4 import (4 import (
5 "context"5 "context"
6+ "errors"
6 "fmt"7 "fmt"
8+ "io"
7 "net"9 "net"
8 "net/http"10 "net/http"
9 "net/http/httputil"11 "net/http/httputil"
@@ -12,6 +14,7 @@ import (
12 "strconv"14 "strconv"
13 "strings"15 "strings"
14 "sync"16 "sync"
17+ "sync/atomic"
15 "time"18 "time"
16 19
17 "github.com/bnema/zerowrap"20 "github.com/bnema/zerowrap"
@@ -37,9 +40,11 @@ var proxyTransport = &http.Transport{
37 40
38 // Config holds configuration needed by the proxy service.41 // Config holds configuration needed by the proxy service.
39 type Config struct {42 type Config struct {
40- RegistryDomain string43+ RegistryDomain string
41- RegistryPort int44+ RegistryPort int
42- MaxBodySize int64 // Maximum request body size in bytes (0 = no limit)45+ MaxBodySize int64 // Maximum request body size in bytes (0 = no limit)
46+ MaxResponseSize int64 // Maximum response body size in bytes (0 = no limit)
47+ MaxConcurrentConns int // Maximum concurrent proxy connections (0 = no limit)
43 }48 }
44 49
45 // Service implements the ProxyService interface.50 // Service implements the ProxyService interface.
@@ -50,6 +55,7 @@ type Service struct {
50 config Config55 config Config
51 targets map[string]*domain.ProxyTarget56 targets map[string]*domain.ProxyTarget
52 mu sync.RWMutex57 mu sync.RWMutex
58+ activeConns atomic.Int64 // Atomic counter for concurrent connection limiting
53 }59 }
54 60
55 // NewService creates a new proxy service.61 // NewService creates a new proxy service.
@@ -75,6 +81,19 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
75 cfg := s.config81 cfg := s.config
76 s.mu.RUnlock()82 s.mu.RUnlock()
77 83
84+ // SECURITY: Limit concurrent connections to prevent resource exhaustion.
85+ // Uses an atomic counter so config reloads don't create a race between
86+ // an old and new semaphore — the counter is shared across reloads.
87+ if cfg.MaxConcurrentConns > 0 {
88+ current := s.activeConns.Add(1)
89+ if current > int64(cfg.MaxConcurrentConns) {
90+ s.activeConns.Add(-1)
91+ proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
92+ return
93+ }
94+ defer s.activeConns.Add(-1)
95+ }
96+
78 // SECURITY: Limit request body size to prevent resource exhaustion.97 // SECURITY: Limit request body size to prevent resource exhaustion.
79 if cfg.MaxBodySize > 0 {98 if cfg.MaxBodySize > 0 {
80 r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodySize)99 r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodySize)
@@ -104,7 +123,7 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
104 target, err := s.GetTarget(ctx, r.Host)123 target, err := s.GetTarget(ctx, r.Host)
105 if err != nil {124 if err != nil {
106 log.Warn().Err(err).Msg("no route found for domain")125 log.Warn().Err(err).Msg("no route found for domain")
107- http.NotFound(w, r)126+ proxyError(w, "404 page not found", http.StatusNotFound)
108 return127 return
109 }128 }
110 129
@@ -151,8 +170,12 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
151 return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})170 return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
152 }171 }
153 172
154- // SECURITY: Validate target is not an internal/blocked network (SSRF protection)173+ // SECURITY: Resolve DNS and validate that the target is not an internal/blocked
155- if err := ValidateExternalRouteTarget(host); err != nil {174+ // network. We use the resolved IP as the proxy target to prevent DNS rebinding
175+ // (TOCTOU) attacks where a hostname resolves to a public IP during validation
176+ // but to a private IP when the proxy connects.
177+ resolvedIP, err := ResolveAndValidateHost(host)
178+ if err != nil {
156 log.Warn().179 log.Warn().
157 Err(err).180 Err(err).
158 Str("host", host).181 Str("host", host).
@@ -161,11 +184,19 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
161 return nil, err184 return nil, err
162 }185 }
163 186
187+ // Preserve the original hostname for the Host header so virtual-hosted
188+ // upstreams work correctly. The resolved IP is used for dialing only.
189+ var originalHost string
190+ if resolvedIP != host {
191+ originalHost = host
192+ }
193+
164 target := &domain.ProxyTarget{194 target := &domain.ProxyTarget{
165- Host: host,195+ Host: resolvedIP,
166- Port: port,196+ Port: port,
167- ContainerID: "", // Not a container197+ ContainerID: "", // Not a container
168- Scheme: "http",198+ Scheme: "http",
199+ OriginalHost: originalHost,
169 }200 }
170 201
171 // Cache external route target202 // Cache external route target
@@ -285,19 +316,93 @@ func (s *Service) RefreshTargets(ctx context.Context) error {
285 }316 }
286 317
287 // UpdateConfig updates the service configuration.318 // UpdateConfig updates the service configuration.
319+// Connection limiting uses an atomic counter checked against config, so
320+// changing MaxConcurrentConns takes effect immediately without any race.
288 func (s *Service) UpdateConfig(config Config) {321 func (s *Service) UpdateConfig(config Config) {
289 s.mu.Lock()322 s.mu.Lock()
290 s.config = config323 s.config = config
291 s.mu.Unlock()324 s.mu.Unlock()
292 }325 }
293 326
327+// modifyResponse returns a function that modifies backend responses.
328+// It adds proxy headers and enforces the response size limit if configured.
329+func (s *Service) modifyResponse(cfg Config) func(*http.Response) error {
330+ return func(resp *http.Response) error {
331+ resp.Header.Set("X-Proxied-By", "Gordon")
332+
333+ // SECURITY: Enforce response size limit to prevent memory exhaustion
334+ // from malicious or buggy backends streaming unbounded data.
335+ if cfg.MaxResponseSize > 0 {
336+ if resp.ContentLength > cfg.MaxResponseSize {
337+ resp.Body.Close()
338+ const msg = "Response Too Large"
339+ resp.StatusCode = http.StatusBadGateway
340+ resp.Body = io.NopCloser(strings.NewReader(msg))
341+ resp.ContentLength = int64(len(msg))
342+ resp.Header.Set("Content-Type", "text/plain")
343+ resp.Header.Set("Cache-Control", "no-store")
344+ resp.Header.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
345+ // Ensure framing/encoding headers match the substituted body
346+ resp.Header.Del("Transfer-Encoding")
347+ resp.Header.Del("Content-Encoding")
348+ resp.Header.Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
349+ return nil
350+ }
351+ // For chunked/streaming responses where Content-Length is unknown,
352+ // wrap with LimitReader to enforce the cap.
353+ if resp.ContentLength < 0 {
354+ resp.Body = &limitedReadCloser{
355+ ReadCloser: resp.Body,
356+ remaining: cfg.MaxResponseSize,
357+ }
358+ }
359+ }
360+
361+ return nil
362+ }
363+}
364+
365+// limitedReadCloser wraps an io.ReadCloser with a byte limit.
366+// When the limit is exceeded, subsequent reads return an error.
367+type limitedReadCloser struct {
368+ io.ReadCloser
369+ remaining int64
370+}
371+
372+func (l *limitedReadCloser) Read(p []byte) (int, error) {
373+ if l.remaining <= 0 {
374+ // Close the underlying body to release upstream connection/resources
375+ l.Close()
376+ return 0, fmt.Errorf("response body exceeded maximum size limit")
377+ }
378+ if int64(len(p)) > l.remaining {
379+ p = p[:l.remaining]
380+ }
381+ n, err := l.ReadCloser.Read(p)
382+ l.remaining -= int64(n)
383+ return n, err
384+}
385+
386+// proxyError writes an error response with security headers appropriate for
387+// proxy-generated error pages. CSP is set here (not in global middleware)
388+// because Gordon proxies arbitrary web apps and a blanket CSP would break them.
389+func proxyError(w http.ResponseWriter, msg string, code int) {
390+ w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
391+ w.Header().Set("Cache-Control", "no-store")
392+ http.Error(w, msg, code)
393+}
394+
294 // Helper methods395 // Helper methods
295 396
296 // newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent397 // newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
297 // hop-by-hop header attacks. A malicious client could send "Connection: Authorization"398 // hop-by-hop header attacks. A malicious client could send "Connection: Authorization"
298 // to strip the Authorization header when using the default Director. Rewrite processes399 // to strip the Authorization header when using the default Director. Rewrite processes
299 // headers after hop-by-hop removal, ensuring headers like Authorization are preserved.400 // headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
300-func newReverseProxy(targetURL *url.URL, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResponse func(*http.Response) error) *httputil.ReverseProxy {401+//
402+// originalHost, when non-empty, overrides the Host header sent to the backend.
403+// This is used for DNS-pinned targets where the proxy dials an IP but needs to
404+// send the original hostname for virtual-hosted upstreams.
405+func newReverseProxy(targetURL *url.URL, originalHost string, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResponse func(*http.Response) error) *httputil.ReverseProxy {
301 return &httputil.ReverseProxy{406 return &httputil.ReverseProxy{
302 Rewrite: func(pr *httputil.ProxyRequest) {407 Rewrite: func(pr *httputil.ProxyRequest) {
303 pr.SetURL(targetURL)408 pr.SetURL(targetURL)
@@ -307,7 +412,13 @@ func newReverseProxy(targetURL *url.URL, errorHandler func(http.ResponseWriter,
307 // proxy. An attacker could spoof X-Forwarded-Proto: https on an HTTP connection412 // proxy. An attacker could spoof X-Forwarded-Proto: https on an HTTP connection
308 // to trick backends into thinking the connection is secure.413 // to trick backends into thinking the connection is secure.
309 pr.SetXForwarded()414 pr.SetXForwarded()
310- pr.Out.Host = targetURL.Host415+ if originalHost != "" {
416+ // DNS pinning: dial the resolved IP but send the original hostname
417+ // so virtual-hosted upstreams route the request correctly.
418+ pr.Out.Host = net.JoinHostPort(originalHost, targetURL.Port())
419+ } else {
420+ pr.Out.Host = targetURL.Host
421+ }
311 },422 },
312 Transport: proxyTransport,423 Transport: proxyTransport,
313 ErrorHandler: errorHandler,424 ErrorHandler: errorHandler,
@@ -316,32 +427,38 @@ func newReverseProxy(targetURL *url.URL, errorHandler func(http.ResponseWriter,
316 }427 }
317 428
318 func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) {429 func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) {
430+ s.mu.RLock()
431+ cfg := s.config
432+ s.mu.RUnlock()
433+
319 log := zerowrap.FromCtx(r.Context())434 log := zerowrap.FromCtx(r.Context())
320 435
321- targetURL, err := url.Parse(fmt.Sprintf("%s://%s:%d", target.Scheme, target.Host, target.Port))436+ // Use net.JoinHostPort for IPv6-safe URL construction (brackets are added automatically)
437+ targetURL, err := url.Parse(fmt.Sprintf("%s://%s", target.Scheme, net.JoinHostPort(target.Host, strconv.Itoa(target.Port))))
322 if err != nil {438 if err != nil {
323 log.WrapErr(err, "failed to parse target URL")439 log.WrapErr(err, "failed to parse target URL")
324- http.Error(w, "Internal Server Error", http.StatusInternalServerError)440+ proxyError(w, "Internal Server Error", http.StatusInternalServerError)
325 return441 return
326 }442 }
327 443
328 log.Debug().Str("target_url", targetURL.String()).Msg("proxying to target")444 log.Debug().Str("target_url", targetURL.String()).Msg("proxying to target")
329 445
330- proxy := newReverseProxy(targetURL,446+ // If DNS pinning resolved hostname to IP, preserve the original hostname
447+ // for the Host header so virtual-hosted upstreams work correctly.
448+ originalHost := target.OriginalHost
449+
450+ proxy := newReverseProxy(targetURL, originalHost,
331 func(w http.ResponseWriter, _ *http.Request, err error) {451 func(w http.ResponseWriter, _ *http.Request, err error) {
332- // Check if this is a MaxBytesError (request body too large)452+ var maxBytesErr *http.MaxBytesError
333- if err.Error() == "http: request body too large" {453+ if errors.As(err, &maxBytesErr) {
334 log.Warn().Err(err).Str("target", targetURL.String()).Msg("proxy error: request body too large")454 log.Warn().Err(err).Str("target", targetURL.String()).Msg("proxy error: request body too large")
335- http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)455+ proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
336 return456 return
337 }457 }
338 log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed")458 log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed")
339- http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)459+ proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
340- },
341- func(resp *http.Response) error {
342- resp.Header.Set("X-Proxied-By", "Gordon")
343- return nil
344 },460 },
461+ s.modifyResponse(cfg),
345 )462 )
346 463
347 log.Debug().464 log.Debug().
@@ -352,6 +469,11 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *
352 proxy.ServeHTTP(w, r)469 proxy.ServeHTTP(w, r)
353 }470 }
354 471
472+// proxyToRegistry forwards requests to the local registry HTTP server.
473+// SECURITY: This uses http://localhost:{port} which is safe because the registry
474+// runs on the same host and traffic never leaves the loopback interface. If Gordon
475+// ever supports running the registry on a separate host, this must use TLS with
476+// certificate verification.
355 func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {477 func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
356 // Copy config under RLock to avoid data race with UpdateConfig478 // Copy config under RLock to avoid data race with UpdateConfig
357 s.mu.RLock()479 s.mu.RLock()
@@ -363,20 +485,20 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
363 targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort))485 targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort))
364 if err != nil {486 if err != nil {
365 log.WrapErr(err, "failed to parse registry target URL")487 log.WrapErr(err, "failed to parse registry target URL")
366- http.Error(w, "Internal Server Error", http.StatusInternalServerError)488+ proxyError(w, "Internal Server Error", http.StatusInternalServerError)
367 return489 return
368 }490 }
369 491
370- proxy := newReverseProxy(targetURL,492+ proxy := newReverseProxy(targetURL, "",
371 func(w http.ResponseWriter, _ *http.Request, err error) {493 func(w http.ResponseWriter, _ *http.Request, err error) {
372- // Check if this is a MaxBytesError (request body too large)494+ var maxBytesErr *http.MaxBytesError
373- if err.Error() == "http: request body too large" {495+ if errors.As(err, &maxBytesErr) {
374 log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large")496 log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large")
375- http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)497+ proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
376 return498 return
377 }499 }
378 log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")500 log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")
379- http.Error(w, "Registry Unavailable", http.StatusServiceUnavailable)501+ proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable)
380 },502 },
381 func(resp *http.Response) error {503 func(resp *http.Response) error {
382 resp.Header.Set("X-Proxied-By", "Gordon")504 resp.Header.Set("X-Proxied-By", "Gordon")
@@ -390,6 +512,8 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
390 resp.Header.Del("X-XSS-Protection")512 resp.Header.Del("X-XSS-Protection")
391 resp.Header.Del("Referrer-Policy")513 resp.Header.Del("Referrer-Policy")
392 resp.Header.Del("Permissions-Policy")514 resp.Header.Del("Permissions-Policy")
515+ resp.Header.Del("Content-Security-Policy")
516+ resp.Header.Del("Strict-Transport-Security")
393 517
394 return nil518 return nil
395 },519 },
M internal/usecase/proxy/service_test.go +47 -0
@@ -397,6 +397,53 @@ func TestContainerDeployedHandler_Handle_NoDomain(t *testing.T) {
397397 assert.NoError(t, err)
398398 }
399399
400+func TestService_ConcurrentConnectionLimit(t *testing.T) {
401+ runtime := outmocks.NewMockContainerRuntime(t)
402+ containerSvc := inmocks.NewMockContainerService(t)
403+ configSvc := inmocks.NewMockConfigService(t)
404+
405+ // Allow only 1 concurrent connection
406+ svc := NewService(runtime, containerSvc, configSvc, Config{
407+ MaxConcurrentConns: 1,
408+ })
409+
410+ assert.Equal(t, 1, svc.config.MaxConcurrentConns)
411+ assert.Equal(t, int64(0), svc.activeConns.Load(), "active connections should start at 0")
412+}
413+
414+func TestService_ConcurrentConnectionLimit_Disabled(t *testing.T) {
415+ runtime := outmocks.NewMockContainerRuntime(t)
416+ containerSvc := inmocks.NewMockContainerService(t)
417+ configSvc := inmocks.NewMockConfigService(t)
418+
419+ svc := NewService(runtime, containerSvc, configSvc, Config{
420+ MaxConcurrentConns: 0,
421+ })
422+
423+ assert.Equal(t, 0, svc.config.MaxConcurrentConns, "0 means no limit")
424+}
425+
426+func TestService_ConcurrentConnectionLimit_503WhenFull(t *testing.T) {
427+ runtime := outmocks.NewMockContainerRuntime(t)
428+ containerSvc := inmocks.NewMockContainerService(t)
429+ configSvc := inmocks.NewMockConfigService(t)
430+
431+ svc := NewService(runtime, containerSvc, configSvc, Config{
432+ MaxConcurrentConns: 1,
433+ })
434+
435+ // Simulate an in-flight connection by incrementing the atomic counter
436+ svc.activeConns.Add(1)
437+ defer svc.activeConns.Add(-1)
438+
439+ // Next request should get 503
440+ req := httptest.NewRequest("GET", "http://example.com/test", nil)
441+ w := httptest.NewRecorder()
442+ svc.ServeHTTP(w, req)
443+
444+ assert.Equal(t, 503, w.Code)
445+}
446+
400447 func TestServeHTTP_MaxBodySize(t *testing.T) {
401448 tests := []struct {
402449 name string
@@ -397,6 +397,53 @@ func TestContainerDeployedHandler_Handle_NoDomain(t *testing.T) {
397 assert.NoError(t, err)397 assert.NoError(t, err)
398 }398 }
399 399
400+func TestService_ConcurrentConnectionLimit(t *testing.T) {
401+ runtime := outmocks.NewMockContainerRuntime(t)
402+ containerSvc := inmocks.NewMockContainerService(t)
403+ configSvc := inmocks.NewMockConfigService(t)
404+
405+ // Allow only 1 concurrent connection
406+ svc := NewService(runtime, containerSvc, configSvc, Config{
407+ MaxConcurrentConns: 1,
408+ })
409+
410+ assert.Equal(t, 1, svc.config.MaxConcurrentConns)
411+ assert.Equal(t, int64(0), svc.activeConns.Load(), "active connections should start at 0")
412+}
413+
414+func TestService_ConcurrentConnectionLimit_Disabled(t *testing.T) {
415+ runtime := outmocks.NewMockContainerRuntime(t)
416+ containerSvc := inmocks.NewMockContainerService(t)
417+ configSvc := inmocks.NewMockConfigService(t)
418+
419+ svc := NewService(runtime, containerSvc, configSvc, Config{
420+ MaxConcurrentConns: 0,
421+ })
422+
423+ assert.Equal(t, 0, svc.config.MaxConcurrentConns, "0 means no limit")
424+}
425+
426+func TestService_ConcurrentConnectionLimit_503WhenFull(t *testing.T) {
427+ runtime := outmocks.NewMockContainerRuntime(t)
428+ containerSvc := inmocks.NewMockContainerService(t)
429+ configSvc := inmocks.NewMockConfigService(t)
430+
431+ svc := NewService(runtime, containerSvc, configSvc, Config{
432+ MaxConcurrentConns: 1,
433+ })
434+
435+ // Simulate an in-flight connection by incrementing the atomic counter
436+ svc.activeConns.Add(1)
437+ defer svc.activeConns.Add(-1)
438+
439+ // Next request should get 503
440+ req := httptest.NewRequest("GET", "http://example.com/test", nil)
441+ w := httptest.NewRecorder()
442+ svc.ServeHTTP(w, req)
443+
444+ assert.Equal(t, 503, w.Code)
445+}
446+
400 func TestServeHTTP_MaxBodySize(t *testing.T) {447 func TestServeHTTP_MaxBodySize(t *testing.T) {
401 tests := []struct {448 tests := []struct {
402 name string449 name string
M internal/usecase/proxy/ssrf.go +38 -0
@@ -115,3 +115,41 @@ func ValidateExternalRouteTarget(host string) error {
115115 }
116116 return nil
117117 }
118+
119+// ResolveAndValidateHost resolves DNS for a hostname and returns the first
120+// resolved IP that is not in a blocked CIDR range. If all resolved IPs are
121+// blocked, returns ErrSSRFBlocked. For raw IP addresses, validates and returns
122+// them directly.
123+//
124+// SECURITY: This function pins DNS resolution to prevent TOCTOU/DNS-rebinding
125+// attacks where a hostname resolves to a public IP during validation but to a
126+// private IP when the proxy actually connects.
127+func ResolveAndValidateHost(host string) (string, error) {
128+ initBlockedCIDRs()
129+
130+ // If already an IP address, validate and return directly
131+ if ip := net.ParseIP(host); ip != nil {
132+ if isBlockedIP(ip) {
133+ return "", domain.ErrSSRFBlocked
134+ }
135+ return host, nil
136+ }
137+
138+ // Resolve DNS
139+ ips, err := net.LookupIP(host)
140+ if err != nil {
141+ return "", fmt.Errorf("%w: DNS resolution failed for %s: %v", domain.ErrSSRFBlocked, host, err)
142+ }
143+ if len(ips) == 0 {
144+ return "", fmt.Errorf("%w: no IP addresses found for %s", domain.ErrSSRFBlocked, host)
145+ }
146+
147+ // Find the first non-blocked IP
148+ for _, ip := range ips {
149+ if !isBlockedIP(ip) {
150+ return ip.String(), nil
151+ }
152+ }
153+
154+ return "", domain.ErrSSRFBlocked
155+}
@@ -115,3 +115,41 @@ func ValidateExternalRouteTarget(host string) error {
115 }115 }
116 return nil116 return nil
117 }117 }
118+
119+// ResolveAndValidateHost resolves DNS for a hostname and returns the first
120+// resolved IP that is not in a blocked CIDR range. If all resolved IPs are
121+// blocked, returns ErrSSRFBlocked. For raw IP addresses, validates and returns
122+// them directly.
123+//
124+// SECURITY: This function pins DNS resolution to prevent TOCTOU/DNS-rebinding
125+// attacks where a hostname resolves to a public IP during validation but to a
126+// private IP when the proxy actually connects.
127+func ResolveAndValidateHost(host string) (string, error) {
128+ initBlockedCIDRs()
129+
130+ // If already an IP address, validate and return directly
131+ if ip := net.ParseIP(host); ip != nil {
132+ if isBlockedIP(ip) {
133+ return "", domain.ErrSSRFBlocked
134+ }
135+ return host, nil
136+ }
137+
138+ // Resolve DNS
139+ ips, err := net.LookupIP(host)
140+ if err != nil {
141+ return "", fmt.Errorf("%w: DNS resolution failed for %s: %v", domain.ErrSSRFBlocked, host, err)
142+ }
143+ if len(ips) == 0 {
144+ return "", fmt.Errorf("%w: no IP addresses found for %s", domain.ErrSSRFBlocked, host)
145+ }
146+
147+ // Find the first non-blocked IP
148+ for _, ip := range ips {
149+ if !isBlockedIP(ip) {
150+ return ip.String(), nil
151+ }
152+ }
153+
154+ return "", domain.ErrSSRFBlocked
155+}
M internal/usecase/proxy/ssrf_test.go +38 -0
@@ -160,6 +160,44 @@ func TestValidateExternalRouteTarget(t *testing.T) {
160160 }
161161 }
162162
163+func TestResolveAndValidateHost_IPAddresses(t *testing.T) {
164+ tests := []struct {
165+ name string
166+ host string
167+ wantIP string
168+ wantErr bool
169+ }{
170+ {"public IP returned as-is", "203.0.113.1", "203.0.113.1", false},
171+ {"blocked loopback IP", "127.0.0.1", "", true},
172+ {"blocked private IP", "10.0.0.1", "", true},
173+ {"blocked metadata IP", "169.254.169.254", "", true},
174+ }
175+ for _, tt := range tests {
176+ t.Run(tt.name, func(t *testing.T) {
177+ ip, err := ResolveAndValidateHost(tt.host)
178+ if tt.wantErr {
179+ assert.Error(t, err)
180+ assert.True(t, errors.Is(err, domain.ErrSSRFBlocked))
181+ } else {
182+ assert.NoError(t, err)
183+ assert.Equal(t, tt.wantIP, ip)
184+ }
185+ })
186+ }
187+}
188+
189+func TestResolveAndValidateHost_Hostname(t *testing.T) {
190+ // localhost should resolve to a blocked IP
191+ _, err := ResolveAndValidateHost("localhost")
192+ assert.Error(t, err)
193+ assert.True(t, errors.Is(err, domain.ErrSSRFBlocked))
194+
195+ // Non-existent domain should error
196+ _, err = ResolveAndValidateHost("this-domain-definitely-does-not-exist-12345.invalid")
197+ assert.Error(t, err)
198+ assert.True(t, errors.Is(err, domain.ErrSSRFBlocked))
199+}
200+
163201 func TestIsBlockedIP(t *testing.T) {
164202 tests := []struct {
165203 ip string
@@ -160,6 +160,44 @@ func TestValidateExternalRouteTarget(t *testing.T) {
160 }160 }
161 }161 }
162 162
163+func TestResolveAndValidateHost_IPAddresses(t *testing.T) {
164+ tests := []struct {
165+ name string
166+ host string
167+ wantIP string
168+ wantErr bool
169+ }{
170+ {"public IP returned as-is", "203.0.113.1", "203.0.113.1", false},
171+ {"blocked loopback IP", "127.0.0.1", "", true},
172+ {"blocked private IP", "10.0.0.1", "", true},
173+ {"blocked metadata IP", "169.254.169.254", "", true},
174+ }
175+ for _, tt := range tests {
176+ t.Run(tt.name, func(t *testing.T) {
177+ ip, err := ResolveAndValidateHost(tt.host)
178+ if tt.wantErr {
179+ assert.Error(t, err)
180+ assert.True(t, errors.Is(err, domain.ErrSSRFBlocked))
181+ } else {
182+ assert.NoError(t, err)
183+ assert.Equal(t, tt.wantIP, ip)
184+ }
185+ })
186+ }
187+}
188+
189+func TestResolveAndValidateHost_Hostname(t *testing.T) {
190+ // localhost should resolve to a blocked IP
191+ _, err := ResolveAndValidateHost("localhost")
192+ assert.Error(t, err)
193+ assert.True(t, errors.Is(err, domain.ErrSSRFBlocked))
194+
195+ // Non-existent domain should error
196+ _, err = ResolveAndValidateHost("this-domain-definitely-does-not-exist-12345.invalid")
197+ assert.Error(t, err)
198+ assert.True(t, errors.Is(err, domain.ErrSSRFBlocked))
199+}
200+
163 func TestIsBlockedIP(t *testing.T) {201 func TestIsBlockedIP(t *testing.T) {
164 tests := []struct {202 tests := []struct {
165 ip string203 ip string

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