detached 5018b1d

fix(proxy) pin DNS resolution to prevent SSRF TOCTOU rebinding attacks

commit
5018b1d493a131547823ef08b01dd9a68cdc41db
author
bnema · · 2026-02-06 21:28 UTC
parent
4a08931
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +225 -15
M internal/usecase/proxy/service.go +101 -15
@@ -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"
@@ -37,9 +39,11 @@ var proxyTransport = &http.Transport{
3739
3840 // Config holds configuration needed by the proxy service.
3941 type Config struct {
40- RegistryDomain string
41- RegistryPort int
42- MaxBodySize int64 // Maximum request body size in bytes (0 = no limit)
42+ RegistryDomain string
43+ RegistryPort int
44+ MaxBodySize int64 // Maximum request body size in bytes (0 = no limit)
45+ MaxResponseSize int64 // Maximum response body size in bytes (0 = no limit)
46+ MaxConcurrentConns int // Maximum concurrent proxy connections (0 = no limit)
4347 }
4448
4549 // Service implements the ProxyService interface.
@@ -50,6 +54,7 @@ type Service struct {
5054 config Config
5155 targets map[string]*domain.ProxyTarget
5256 mu sync.RWMutex
57+ connSem chan struct{} // Semaphore for concurrent connection limiting
5358 }
5459
5560 // NewService creates a new proxy service.
@@ -59,17 +64,32 @@ func NewService(
5964 configSvc in.ConfigService,
6065 config Config,
6166 ) *Service {
62- return &Service{
67+ svc := &Service{
6368 runtime: runtime,
6469 containerSvc: containerSvc,
6570 configSvc: configSvc,
6671 config: config,
6772 targets: make(map[string]*domain.ProxyTarget),
6873 }
74+ if config.MaxConcurrentConns > 0 {
75+ svc.connSem = make(chan struct{}, config.MaxConcurrentConns)
76+ }
77+ return svc
6978 }
7079
7180 // ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.
7281 func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
82+ // SECURITY: Limit concurrent connections to prevent resource exhaustion.
83+ if s.connSem != nil {
84+ select {
85+ case s.connSem <- struct{}{}:
86+ defer func() { <-s.connSem }()
87+ default:
88+ http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
89+ return
90+ }
91+ }
92+
7393 // Copy config under RLock to avoid data race with UpdateConfig
7494 s.mu.RLock()
7595 cfg := s.config
@@ -104,6 +124,7 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
104124 target, err := s.GetTarget(ctx, r.Host)
105125 if err != nil {
106126 log.Warn().Err(err).Msg("no route found for domain")
127+ w.Header().Set("Cache-Control", "no-store")
107128 http.NotFound(w, r)
108129 return
109130 }
@@ -151,8 +172,12 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
151172 return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
152173 }
153174
154- // SECURITY: Validate target is not an internal/blocked network (SSRF protection)
155- if err := ValidateExternalRouteTarget(host); err != nil {
175+ // SECURITY: Resolve DNS and validate that the target is not an internal/blocked
176+ // network. We use the resolved IP as the proxy target to prevent DNS rebinding
177+ // (TOCTOU) attacks where a hostname resolves to a public IP during validation
178+ // but to a private IP when the proxy connects.
179+ resolvedIP, err := ResolveAndValidateHost(host)
180+ if err != nil {
156181 log.Warn().
157182 Err(err).
158183 Str("host", host).
@@ -162,7 +187,7 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
162187 }
163188
164189 target := &domain.ProxyTarget{
165- Host: host,
190+ Host: resolvedIP,
166191 Port: port,
167192 ContainerID: "", // Not a container
168193 Scheme: "http",
@@ -291,6 +316,57 @@ func (s *Service) UpdateConfig(config Config) {
291316 s.mu.Unlock()
292317 }
293318
319+// modifyResponse returns a function that modifies backend responses.
320+// It adds proxy headers and enforces the response size limit if configured.
321+func (s *Service) modifyResponse(cfg Config) func(*http.Response) error {
322+ return func(resp *http.Response) error {
323+ resp.Header.Set("X-Proxied-By", "Gordon")
324+
325+ // SECURITY: Enforce response size limit to prevent memory exhaustion
326+ // from malicious or buggy backends streaming unbounded data.
327+ if cfg.MaxResponseSize > 0 {
328+ if resp.ContentLength > cfg.MaxResponseSize {
329+ resp.Body.Close()
330+ resp.StatusCode = http.StatusBadGateway
331+ resp.Body = io.NopCloser(strings.NewReader("Response Too Large"))
332+ resp.ContentLength = int64(len("Response Too Large"))
333+ resp.Header.Set("Content-Type", "text/plain")
334+ resp.Header.Set("Cache-Control", "no-store")
335+ return nil
336+ }
337+ // For chunked/streaming responses where Content-Length is unknown,
338+ // wrap with LimitReader to enforce the cap.
339+ if resp.ContentLength < 0 {
340+ resp.Body = &limitedReadCloser{
341+ ReadCloser: resp.Body,
342+ remaining: cfg.MaxResponseSize,
343+ }
344+ }
345+ }
346+
347+ return nil
348+ }
349+}
350+
351+// limitedReadCloser wraps an io.ReadCloser with a byte limit.
352+// When the limit is exceeded, subsequent reads return an error.
353+type limitedReadCloser struct {
354+ io.ReadCloser
355+ remaining int64
356+}
357+
358+func (l *limitedReadCloser) Read(p []byte) (int, error) {
359+ if l.remaining <= 0 {
360+ return 0, fmt.Errorf("response body exceeded maximum size limit")
361+ }
362+ if int64(len(p)) > l.remaining {
363+ p = p[:l.remaining]
364+ }
365+ n, err := l.ReadCloser.Read(p)
366+ l.remaining -= int64(n)
367+ return n, err
368+}
369+
294370 // Helper methods
295371
296372 // newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
@@ -316,6 +392,10 @@ func newReverseProxy(targetURL *url.URL, errorHandler func(http.ResponseWriter,
316392 }
317393
318394 func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) {
395+ s.mu.RLock()
396+ cfg := s.config
397+ s.mu.RUnlock()
398+
319399 log := zerowrap.FromCtx(r.Context())
320400
321401 targetURL, err := url.Parse(fmt.Sprintf("%s://%s:%d", target.Scheme, target.Host, target.Port))
@@ -329,19 +409,18 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *
329409
330410 proxy := newReverseProxy(targetURL,
331411 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" {
412+ var maxBytesErr *http.MaxBytesError
413+ if errors.As(err, &maxBytesErr) {
334414 log.Warn().Err(err).Str("target", targetURL.String()).Msg("proxy error: request body too large")
415+ w.Header().Set("Cache-Control", "no-store")
335416 http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
336417 return
337418 }
338419 log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed")
420+ w.Header().Set("Cache-Control", "no-store")
339421 http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
340422 },
341- func(resp *http.Response) error {
342- resp.Header.Set("X-Proxied-By", "Gordon")
343- return nil
344- },
423+ s.modifyResponse(cfg),
345424 )
346425
347426 log.Debug().
@@ -352,6 +431,11 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *
352431 proxy.ServeHTTP(w, r)
353432 }
354433
434+// proxyToRegistry forwards requests to the local registry HTTP server.
435+// SECURITY: This uses http://localhost:{port} which is safe because the registry
436+// runs on the same host and traffic never leaves the loopback interface. If Gordon
437+// ever supports running the registry on a separate host, this must use TLS with
438+// certificate verification.
355439 func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
356440 // Copy config under RLock to avoid data race with UpdateConfig
357441 s.mu.RLock()
@@ -369,13 +453,15 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
369453
370454 proxy := newReverseProxy(targetURL,
371455 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" {
456+ var maxBytesErr *http.MaxBytesError
457+ if errors.As(err, &maxBytesErr) {
374458 log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large")
459+ w.Header().Set("Cache-Control", "no-store")
375460 http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
376461 return
377462 }
378463 log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")
464+ w.Header().Set("Cache-Control", "no-store")
379465 http.Error(w, "Registry Unavailable", http.StatusServiceUnavailable)
380466 },
381467 func(resp *http.Response) error {
@@ -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"
@@ -37,9 +39,11 @@ var proxyTransport = &http.Transport{
37 39
38 // Config holds configuration needed by the proxy service.40 // Config holds configuration needed by the proxy service.
39 type Config struct {41 type Config struct {
40- RegistryDomain string42+ RegistryDomain string
41- RegistryPort int43+ RegistryPort int
42- MaxBodySize int64 // Maximum request body size in bytes (0 = no limit)44+ MaxBodySize int64 // Maximum request body size in bytes (0 = no limit)
45+ MaxResponseSize int64 // Maximum response body size in bytes (0 = no limit)
46+ MaxConcurrentConns int // Maximum concurrent proxy connections (0 = no limit)
43 }47 }
44 48
45 // Service implements the ProxyService interface.49 // Service implements the ProxyService interface.
@@ -50,6 +54,7 @@ type Service struct {
50 config Config54 config Config
51 targets map[string]*domain.ProxyTarget55 targets map[string]*domain.ProxyTarget
52 mu sync.RWMutex56 mu sync.RWMutex
57+ connSem chan struct{} // Semaphore for concurrent connection limiting
53 }58 }
54 59
55 // NewService creates a new proxy service.60 // NewService creates a new proxy service.
@@ -59,17 +64,32 @@ func NewService(
59 configSvc in.ConfigService,64 configSvc in.ConfigService,
60 config Config,65 config Config,
61 ) *Service {66 ) *Service {
62- return &Service{67+ svc := &Service{
63 runtime: runtime,68 runtime: runtime,
64 containerSvc: containerSvc,69 containerSvc: containerSvc,
65 configSvc: configSvc,70 configSvc: configSvc,
66 config: config,71 config: config,
67 targets: make(map[string]*domain.ProxyTarget),72 targets: make(map[string]*domain.ProxyTarget),
68 }73 }
74+ if config.MaxConcurrentConns > 0 {
75+ svc.connSem = make(chan struct{}, config.MaxConcurrentConns)
76+ }
77+ return svc
69 }78 }
70 79
71 // ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.80 // ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.
72 func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {81 func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
82+ // SECURITY: Limit concurrent connections to prevent resource exhaustion.
83+ if s.connSem != nil {
84+ select {
85+ case s.connSem <- struct{}{}:
86+ defer func() { <-s.connSem }()
87+ default:
88+ http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
89+ return
90+ }
91+ }
92+
73 // Copy config under RLock to avoid data race with UpdateConfig93 // Copy config under RLock to avoid data race with UpdateConfig
74 s.mu.RLock()94 s.mu.RLock()
75 cfg := s.config95 cfg := s.config
@@ -104,6 +124,7 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
104 target, err := s.GetTarget(ctx, r.Host)124 target, err := s.GetTarget(ctx, r.Host)
105 if err != nil {125 if err != nil {
106 log.Warn().Err(err).Msg("no route found for domain")126 log.Warn().Err(err).Msg("no route found for domain")
127+ w.Header().Set("Cache-Control", "no-store")
107 http.NotFound(w, r)128 http.NotFound(w, r)
108 return129 return
109 }130 }
@@ -151,8 +172,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})172 return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
152 }173 }
153 174
154- // SECURITY: Validate target is not an internal/blocked network (SSRF protection)175+ // SECURITY: Resolve DNS and validate that the target is not an internal/blocked
155- if err := ValidateExternalRouteTarget(host); err != nil {176+ // network. We use the resolved IP as the proxy target to prevent DNS rebinding
177+ // (TOCTOU) attacks where a hostname resolves to a public IP during validation
178+ // but to a private IP when the proxy connects.
179+ resolvedIP, err := ResolveAndValidateHost(host)
180+ if err != nil {
156 log.Warn().181 log.Warn().
157 Err(err).182 Err(err).
158 Str("host", host).183 Str("host", host).
@@ -162,7 +187,7 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
162 }187 }
163 188
164 target := &domain.ProxyTarget{189 target := &domain.ProxyTarget{
165- Host: host,190+ Host: resolvedIP,
166 Port: port,191 Port: port,
167 ContainerID: "", // Not a container192 ContainerID: "", // Not a container
168 Scheme: "http",193 Scheme: "http",
@@ -291,6 +316,57 @@ func (s *Service) UpdateConfig(config Config) {
291 s.mu.Unlock()316 s.mu.Unlock()
292 }317 }
293 318
319+// modifyResponse returns a function that modifies backend responses.
320+// It adds proxy headers and enforces the response size limit if configured.
321+func (s *Service) modifyResponse(cfg Config) func(*http.Response) error {
322+ return func(resp *http.Response) error {
323+ resp.Header.Set("X-Proxied-By", "Gordon")
324+
325+ // SECURITY: Enforce response size limit to prevent memory exhaustion
326+ // from malicious or buggy backends streaming unbounded data.
327+ if cfg.MaxResponseSize > 0 {
328+ if resp.ContentLength > cfg.MaxResponseSize {
329+ resp.Body.Close()
330+ resp.StatusCode = http.StatusBadGateway
331+ resp.Body = io.NopCloser(strings.NewReader("Response Too Large"))
332+ resp.ContentLength = int64(len("Response Too Large"))
333+ resp.Header.Set("Content-Type", "text/plain")
334+ resp.Header.Set("Cache-Control", "no-store")
335+ return nil
336+ }
337+ // For chunked/streaming responses where Content-Length is unknown,
338+ // wrap with LimitReader to enforce the cap.
339+ if resp.ContentLength < 0 {
340+ resp.Body = &limitedReadCloser{
341+ ReadCloser: resp.Body,
342+ remaining: cfg.MaxResponseSize,
343+ }
344+ }
345+ }
346+
347+ return nil
348+ }
349+}
350+
351+// limitedReadCloser wraps an io.ReadCloser with a byte limit.
352+// When the limit is exceeded, subsequent reads return an error.
353+type limitedReadCloser struct {
354+ io.ReadCloser
355+ remaining int64
356+}
357+
358+func (l *limitedReadCloser) Read(p []byte) (int, error) {
359+ if l.remaining <= 0 {
360+ return 0, fmt.Errorf("response body exceeded maximum size limit")
361+ }
362+ if int64(len(p)) > l.remaining {
363+ p = p[:l.remaining]
364+ }
365+ n, err := l.ReadCloser.Read(p)
366+ l.remaining -= int64(n)
367+ return n, err
368+}
369+
294 // Helper methods370 // Helper methods
295 371
296 // newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent372 // newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
@@ -316,6 +392,10 @@ func newReverseProxy(targetURL *url.URL, errorHandler func(http.ResponseWriter,
316 }392 }
317 393
318 func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) {394 func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) {
395+ s.mu.RLock()
396+ cfg := s.config
397+ s.mu.RUnlock()
398+
319 log := zerowrap.FromCtx(r.Context())399 log := zerowrap.FromCtx(r.Context())
320 400
321 targetURL, err := url.Parse(fmt.Sprintf("%s://%s:%d", target.Scheme, target.Host, target.Port))401 targetURL, err := url.Parse(fmt.Sprintf("%s://%s:%d", target.Scheme, target.Host, target.Port))
@@ -329,19 +409,18 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *
329 409
330 proxy := newReverseProxy(targetURL,410 proxy := newReverseProxy(targetURL,
331 func(w http.ResponseWriter, _ *http.Request, err error) {411 func(w http.ResponseWriter, _ *http.Request, err error) {
332- // Check if this is a MaxBytesError (request body too large)412+ var maxBytesErr *http.MaxBytesError
333- if err.Error() == "http: request body too large" {413+ if errors.As(err, &maxBytesErr) {
334 log.Warn().Err(err).Str("target", targetURL.String()).Msg("proxy error: request body too large")414 log.Warn().Err(err).Str("target", targetURL.String()).Msg("proxy error: request body too large")
415+ w.Header().Set("Cache-Control", "no-store")
335 http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)416 http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
336 return417 return
337 }418 }
338 log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed")419 log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed")
420+ w.Header().Set("Cache-Control", "no-store")
339 http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)421 http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
340 },422 },
341- func(resp *http.Response) error {423+ s.modifyResponse(cfg),
342- resp.Header.Set("X-Proxied-By", "Gordon")
343- return nil
344- },
345 )424 )
346 425
347 log.Debug().426 log.Debug().
@@ -352,6 +431,11 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *
352 proxy.ServeHTTP(w, r)431 proxy.ServeHTTP(w, r)
353 }432 }
354 433
434+// proxyToRegistry forwards requests to the local registry HTTP server.
435+// SECURITY: This uses http://localhost:{port} which is safe because the registry
436+// runs on the same host and traffic never leaves the loopback interface. If Gordon
437+// ever supports running the registry on a separate host, this must use TLS with
438+// certificate verification.
355 func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {439 func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
356 // Copy config under RLock to avoid data race with UpdateConfig440 // Copy config under RLock to avoid data race with UpdateConfig
357 s.mu.RLock()441 s.mu.RLock()
@@ -369,13 +453,15 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
369 453
370 proxy := newReverseProxy(targetURL,454 proxy := newReverseProxy(targetURL,
371 func(w http.ResponseWriter, _ *http.Request, err error) {455 func(w http.ResponseWriter, _ *http.Request, err error) {
372- // Check if this is a MaxBytesError (request body too large)456+ var maxBytesErr *http.MaxBytesError
373- if err.Error() == "http: request body too large" {457+ if errors.As(err, &maxBytesErr) {
374 log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large")458 log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large")
459+ w.Header().Set("Cache-Control", "no-store")
375 http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)460 http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
376 return461 return
377 }462 }
378 log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")463 log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")
464+ w.Header().Set("Cache-Control", "no-store")
379 http.Error(w, "Registry Unavailable", http.StatusServiceUnavailable)465 http.Error(w, "Registry Unavailable", http.StatusServiceUnavailable)
380 },466 },
381 func(resp *http.Response) error {467 func(resp *http.Response) error {
M internal/usecase/proxy/service_test.go +49 -0
@@ -397,6 +397,55 @@ 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.NotNil(t, svc.connSem, "semaphore should be initialized when MaxConcurrentConns > 0")
411+ assert.Equal(t, 1, cap(svc.connSem))
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.Nil(t, svc.connSem, "semaphore should be nil when MaxConcurrentConns is 0")
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+ // Fill the semaphore
436+ svc.connSem <- struct{}{}
437+
438+ // Next request should get 503
439+ req := httptest.NewRequest("GET", "http://example.com/test", nil)
440+ w := httptest.NewRecorder()
441+ svc.ServeHTTP(w, req)
442+
443+ assert.Equal(t, 503, w.Code)
444+
445+ // Drain semaphore
446+ <-svc.connSem
447+}
448+
400449 func TestServeHTTP_MaxBodySize(t *testing.T) {
401450 tests := []struct {
402451 name string
@@ -397,6 +397,55 @@ 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.NotNil(t, svc.connSem, "semaphore should be initialized when MaxConcurrentConns > 0")
411+ assert.Equal(t, 1, cap(svc.connSem))
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.Nil(t, svc.connSem, "semaphore should be nil when MaxConcurrentConns is 0")
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+ // Fill the semaphore
436+ svc.connSem <- struct{}{}
437+
438+ // Next request should get 503
439+ req := httptest.NewRequest("GET", "http://example.com/test", nil)
440+ w := httptest.NewRecorder()
441+ svc.ServeHTTP(w, req)
442+
443+ assert.Equal(t, 503, w.Code)
444+
445+ // Drain semaphore
446+ <-svc.connSem
447+}
448+
400 func TestServeHTTP_MaxBodySize(t *testing.T) {449 func TestServeHTTP_MaxBodySize(t *testing.T) {
401 tests := []struct {450 tests := []struct {
402 name string451 name string
M internal/usecase/proxy/ssrf.go +37 -0
@@ -115,3 +115,40 @@ func ValidateExternalRouteTarget(host string) error {
115115 }
116116 return nil
117117 }
118+
119+// ResolveAndValidateHost resolves DNS for a hostname and validates that all
120+// resolved IPs are safe (not in blocked CIDR ranges). Returns the first safe
121+// resolved IP address. For raw IP addresses, validates and returns them directly.
122+//
123+// SECURITY: This function pins DNS resolution to prevent TOCTOU/DNS-rebinding
124+// attacks where a hostname resolves to a public IP during validation but to a
125+// private IP when the proxy actually connects.
126+func ResolveAndValidateHost(host string) (string, error) {
127+ initBlockedCIDRs()
128+
129+ // If already an IP address, validate and return directly
130+ if ip := net.ParseIP(host); ip != nil {
131+ if isBlockedIP(ip) {
132+ return "", domain.ErrSSRFBlocked
133+ }
134+ return host, nil
135+ }
136+
137+ // Resolve DNS
138+ ips, err := net.LookupIP(host)
139+ if err != nil {
140+ return "", fmt.Errorf("%w: DNS resolution failed for %s: %v", domain.ErrSSRFBlocked, host, err)
141+ }
142+ if len(ips) == 0 {
143+ return "", fmt.Errorf("%w: no IP addresses found for %s", domain.ErrSSRFBlocked, host)
144+ }
145+
146+ // Find the first non-blocked IP
147+ for _, ip := range ips {
148+ if !isBlockedIP(ip) {
149+ return ip.String(), nil
150+ }
151+ }
152+
153+ return "", domain.ErrSSRFBlocked
154+}
@@ -115,3 +115,40 @@ func ValidateExternalRouteTarget(host string) error {
115 }115 }
116 return nil116 return nil
117 }117 }
118+
119+// ResolveAndValidateHost resolves DNS for a hostname and validates that all
120+// resolved IPs are safe (not in blocked CIDR ranges). Returns the first safe
121+// resolved IP address. For raw IP addresses, validates and returns them directly.
122+//
123+// SECURITY: This function pins DNS resolution to prevent TOCTOU/DNS-rebinding
124+// attacks where a hostname resolves to a public IP during validation but to a
125+// private IP when the proxy actually connects.
126+func ResolveAndValidateHost(host string) (string, error) {
127+ initBlockedCIDRs()
128+
129+ // If already an IP address, validate and return directly
130+ if ip := net.ParseIP(host); ip != nil {
131+ if isBlockedIP(ip) {
132+ return "", domain.ErrSSRFBlocked
133+ }
134+ return host, nil
135+ }
136+
137+ // Resolve DNS
138+ ips, err := net.LookupIP(host)
139+ if err != nil {
140+ return "", fmt.Errorf("%w: DNS resolution failed for %s: %v", domain.ErrSSRFBlocked, host, err)
141+ }
142+ if len(ips) == 0 {
143+ return "", fmt.Errorf("%w: no IP addresses found for %s", domain.ErrSSRFBlocked, host)
144+ }
145+
146+ // Find the first non-blocked IP
147+ for _, ip := range ips {
148+ if !isBlockedIP(ip) {
149+ return ip.String(), nil
150+ }
151+ }
152+
153+ return "", domain.ErrSSRFBlocked
154+}
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 8

Tags 106

↑↓ moveenter openesc close