refactor: deduplicate CIDR allowlist code and clean up
- Extract shared cidrAllowlist helper replacing duplicate RegistryCIDRAllowlist/ProxyCIDRAllowlist implementations - Extract parseCIDRAllowlist/denyAllHandler replacing duplicate buildRegistryCIDRAllowlistMiddleware/buildProxyCIDRAllowlistMiddleware - Remove unused trustedNets param from ProxyCIDRAllowlist - Remove redundant units import alias in runtime.go - Remove redundant TrimSpace in normalizeIP - Clean up unnecessary "what" comments in security headers middleware - Consolidate verbose registry proxy ModifyResponse comments
- commit
- 552aaca0c26fcc16b2e2cac24564a4245dfc4b05
- author
- bnema · · 2026-03-27 22:24 UTC
- parent
- 66e4d1b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +75 -123
M internal/adapters/in/http/middleware/cidr.go +24 -48
| @@ -13,18 +13,17 @@ import ( | |||
| 13 | 13 | // localhostNets contains IPv4 and IPv6 loopback ranges that are always allowed. | |
| 14 | 14 | var localhostNets = ParseTrustedProxies([]string{"127.0.0.0/8", "::1"}) | |
| 15 | 15 | ||
| 16 | - | // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges. | |
| 17 | - | // Localhost is always allowed so Gordon can pull from its own registry. | |
| 18 | - | // An empty allowedNets slice is a no-op (all traffic passes through). | |
| 19 | - | func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 16 | + | // cidrAllowlist is the shared implementation for CIDR-based access control middleware. | |
| 17 | + | // ipExtractor determines how the client IP is obtained from the request. | |
| 18 | + | // logLabel is used in the deny log message (e.g. "registry", "proxy origin"). | |
| 19 | + | func cidrAllowlist(allowedNets []*net.IPNet, ipExtractor func(*http.Request) string, logLabel string, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 20 | 20 | return func(next http.Handler) http.Handler { | |
| 21 | 21 | if len(allowedNets) == 0 { | |
| 22 | 22 | return next | |
| 23 | 23 | } | |
| 24 | 24 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 25 | - | clientIP := GetClientIP(r, trustedNets) | |
| 25 | + | clientIP := ipExtractor(r) | |
| 26 | 26 | ||
| 27 | - | // Always allow localhost — Gordon must reach its own registry. | |
| 28 | 27 | if IsTrustedProxy(clientIP, localhostNets) { | |
| 29 | 28 | next.ServeHTTP(w, r) | |
| 30 | 29 | return | |
| @@ -39,9 +38,8 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L | |||
| 39 | 38 | Str(zerowrap.FieldLayer, "adapter"). | |
| 40 | 39 | Str(zerowrap.FieldAdapter, "http"). | |
| 41 | 40 | Str(zerowrap.FieldMethod, r.Method). | |
| 42 | - | Str(zerowrap.FieldPath, r.URL.Path). | |
| 43 | 41 | Str(zerowrap.FieldClientIP, clientIP). | |
| 44 | - | Msg("registry access denied by CIDR allowlist") | |
| 42 | + | Msgf("%s access denied by CIDR allowlist", logLabel) | |
| 45 | 43 | ||
| 46 | 44 | w.Header().Set("Content-Type", "application/json") | |
| 47 | 45 | w.WriteHeader(http.StatusForbidden) | |
| @@ -50,47 +48,25 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L | |||
| 50 | 48 | } | |
| 51 | 49 | } | |
| 52 | 50 | ||
| 51 | + | // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges. | |
| 52 | + | // Localhost is always allowed so Gordon can pull from its own registry. | |
| 53 | + | // An empty allowedNets slice is a no-op (all traffic passes through). | |
| 54 | + | func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 55 | + | return cidrAllowlist(allowedNets, func(r *http.Request) string { | |
| 56 | + | return GetClientIP(r, trustedNets) | |
| 57 | + | }, "registry", log) | |
| 58 | + | } | |
| 59 | + | ||
| 53 | 60 | // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges. | |
| 54 | - | // This is used to ensure only trusted sources (e.g. Cloudflare edge IPs) can reach the | |
| 55 | - | // proxy server, preventing direct-to-origin attacks that bypass CDN protections. | |
| 61 | + | // This validates the direct network peer (RemoteAddr), not forwarded IPs, to ensure only | |
| 62 | + | // trusted sources (e.g. Cloudflare edge IPs) can reach the proxy server. | |
| 56 | 63 | // An empty allowedNets slice is a no-op (all traffic passes through). | |
| 57 | - | // Note: the second parameter is accepted for signature consistency with RegistryCIDRAllowlist | |
| 58 | - | // but is intentionally unused — this middleware validates the direct network peer, not forwarded IPs. | |
| 59 | - | func ProxyCIDRAllowlist(allowedNets, _ []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 60 | - | return func(next http.Handler) http.Handler { | |
| 61 | - | if len(allowedNets) == 0 { | |
| 62 | - | return next | |
| 64 | + | func ProxyCIDRAllowlist(allowedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 65 | + | return cidrAllowlist(allowedNets, func(r *http.Request) string { | |
| 66 | + | remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) | |
| 67 | + | if err != nil { | |
| 68 | + | return r.RemoteAddr | |
| 63 | 69 | } | |
| 64 | - | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 65 | - | // For proxy allowlisting we check the direct connection IP (RemoteAddr), | |
| 66 | - | // not the client IP from headers, because we're validating the network | |
| 67 | - | // peer (e.g. Cloudflare edge), not the end-user. | |
| 68 | - | remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) | |
| 69 | - | if err != nil { | |
| 70 | - | remoteIP = r.RemoteAddr | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | if IsTrustedProxy(remoteIP, localhostNets) { | |
| 74 | - | next.ServeHTTP(w, r) | |
| 75 | - | return | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | if IsTrustedProxy(remoteIP, allowedNets) { | |
| 79 | - | next.ServeHTTP(w, r) | |
| 80 | - | return | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | log.Warn(). | |
| 84 | - | Str(zerowrap.FieldLayer, "adapter"). | |
| 85 | - | Str(zerowrap.FieldAdapter, "http"). | |
| 86 | - | Str(zerowrap.FieldMethod, r.Method). | |
| 87 | - | Str("host", r.Host). | |
| 88 | - | Str(zerowrap.FieldClientIP, remoteIP). | |
| 89 | - | Msg("proxy access denied by origin IP allowlist") | |
| 90 | - | ||
| 91 | - | w.Header().Set("Content-Type", "application/json") | |
| 92 | - | w.WriteHeader(http.StatusForbidden) | |
| 93 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"}) | |
| 94 | - | }) | |
| 95 | - | } | |
| 70 | + | return remoteIP | |
| 71 | + | }, "proxy origin", log) | |
| 96 | 72 | } | |
| @@ -13,18 +13,17 @@ import ( | |||||
| 13 | // localhostNets contains IPv4 and IPv6 loopback ranges that are always allowed. | 13 | // localhostNets contains IPv4 and IPv6 loopback ranges that are always allowed. | ||
| 14 | var localhostNets = ParseTrustedProxies([]string{"127.0.0.0/8", "::1"}) | 14 | var localhostNets = ParseTrustedProxies([]string{"127.0.0.0/8", "::1"}) | ||
| 15 | 15 | ||||
| 16 | - | // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges. | 16 | + | // cidrAllowlist is the shared implementation for CIDR-based access control middleware. |
| 17 | - | // Localhost is always allowed so Gordon can pull from its own registry. | 17 | + | // ipExtractor determines how the client IP is obtained from the request. |
| 18 | - | // An empty allowedNets slice is a no-op (all traffic passes through). | 18 | + | // logLabel is used in the deny log message (e.g. "registry", "proxy origin"). |
| 19 | - | func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | 19 | + | func cidrAllowlist(allowedNets []*net.IPNet, ipExtractor func(*http.Request) string, logLabel string, log zerowrap.Logger) func(http.Handler) http.Handler { |
| 20 | return func(next http.Handler) http.Handler { | 20 | return func(next http.Handler) http.Handler { | ||
| 21 | if len(allowedNets) == 0 { | 21 | if len(allowedNets) == 0 { | ||
| 22 | return next | 22 | return next | ||
| 23 | } | 23 | } | ||
| 24 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 24 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 25 | - | clientIP := GetClientIP(r, trustedNets) | 25 | + | clientIP := ipExtractor(r) |
| 26 | 26 | ||||
| 27 | - | // Always allow localhost — Gordon must reach its own registry. | |||
| 28 | if IsTrustedProxy(clientIP, localhostNets) { | 27 | if IsTrustedProxy(clientIP, localhostNets) { | ||
| 29 | next.ServeHTTP(w, r) | 28 | next.ServeHTTP(w, r) | ||
| 30 | return | 29 | return | ||
| @@ -39,9 +38,8 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L | |||||
| 39 | Str(zerowrap.FieldLayer, "adapter"). | 38 | Str(zerowrap.FieldLayer, "adapter"). | ||
| 40 | Str(zerowrap.FieldAdapter, "http"). | 39 | Str(zerowrap.FieldAdapter, "http"). | ||
| 41 | Str(zerowrap.FieldMethod, r.Method). | 40 | Str(zerowrap.FieldMethod, r.Method). | ||
| 42 | - | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 43 | Str(zerowrap.FieldClientIP, clientIP). | 41 | Str(zerowrap.FieldClientIP, clientIP). | ||
| 44 | - | Msg("registry access denied by CIDR allowlist") | 42 | + | Msgf("%s access denied by CIDR allowlist", logLabel) |
| 45 | 43 | ||||
| 46 | w.Header().Set("Content-Type", "application/json") | 44 | w.Header().Set("Content-Type", "application/json") | ||
| 47 | w.WriteHeader(http.StatusForbidden) | 45 | w.WriteHeader(http.StatusForbidden) | ||
| @@ -50,47 +48,25 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L | |||||
| 50 | } | 48 | } | ||
| 51 | } | 49 | } | ||
| 52 | 50 | ||||
| 51 | + | // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges. | |||
| 52 | + | // Localhost is always allowed so Gordon can pull from its own registry. | |||
| 53 | + | // An empty allowedNets slice is a no-op (all traffic passes through). | |||
| 54 | + | func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |||
| 55 | + | return cidrAllowlist(allowedNets, func(r *http.Request) string { | |||
| 56 | + | return GetClientIP(r, trustedNets) | |||
| 57 | + | }, "registry", log) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 53 | // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges. | 60 | // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges. | ||
| 54 | - | // This is used to ensure only trusted sources (e.g. Cloudflare edge IPs) can reach the | 61 | + | // This validates the direct network peer (RemoteAddr), not forwarded IPs, to ensure only |
| 55 | - | // proxy server, preventing direct-to-origin attacks that bypass CDN protections. | 62 | + | // trusted sources (e.g. Cloudflare edge IPs) can reach the proxy server. |
| 56 | // An empty allowedNets slice is a no-op (all traffic passes through). | 63 | // An empty allowedNets slice is a no-op (all traffic passes through). | ||
| 57 | - | // Note: the second parameter is accepted for signature consistency with RegistryCIDRAllowlist | 64 | + | func ProxyCIDRAllowlist(allowedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { |
| 58 | - | // but is intentionally unused — this middleware validates the direct network peer, not forwarded IPs. | 65 | + | return cidrAllowlist(allowedNets, func(r *http.Request) string { |
| 59 | - | func ProxyCIDRAllowlist(allowedNets, _ []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | 66 | + | remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) |
| 60 | - | return func(next http.Handler) http.Handler { | 67 | + | if err != nil { |
| 61 | - | if len(allowedNets) == 0 { | 68 | + | return r.RemoteAddr |
| 62 | - | return next | |||
| 63 | } | 69 | } | ||
| 64 | - | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 70 | + | return remoteIP |
| 65 | - | // For proxy allowlisting we check the direct connection IP (RemoteAddr), | 71 | + | }, "proxy origin", log) |
| 66 | - | // not the client IP from headers, because we're validating the network | |||
| 67 | - | // peer (e.g. Cloudflare edge), not the end-user. | |||
| 68 | - | remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) | |||
| 69 | - | if err != nil { | |||
| 70 | - | remoteIP = r.RemoteAddr | |||
| 71 | - | } | |||
| 72 | - | ||||
| 73 | - | if IsTrustedProxy(remoteIP, localhostNets) { | |||
| 74 | - | next.ServeHTTP(w, r) | |||
| 75 | - | return | |||
| 76 | - | } | |||
| 77 | - | ||||
| 78 | - | if IsTrustedProxy(remoteIP, allowedNets) { | |||
| 79 | - | next.ServeHTTP(w, r) | |||
| 80 | - | return | |||
| 81 | - | } | |||
| 82 | - | ||||
| 83 | - | log.Warn(). | |||
| 84 | - | Str(zerowrap.FieldLayer, "adapter"). | |||
| 85 | - | Str(zerowrap.FieldAdapter, "http"). | |||
| 86 | - | Str(zerowrap.FieldMethod, r.Method). | |||
| 87 | - | Str("host", r.Host). | |||
| 88 | - | Str(zerowrap.FieldClientIP, remoteIP). | |||
| 89 | - | Msg("proxy access denied by origin IP allowlist") | |||
| 90 | - | ||||
| 91 | - | w.Header().Set("Content-Type", "application/json") | |||
| 92 | - | w.WriteHeader(http.StatusForbidden) | |||
| 93 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"}) | |||
| 94 | - | }) | |||
| 95 | - | } | |||
| 96 | } | 72 | } | ||
M internal/adapters/in/http/middleware/clientip.go +1 -1
| @@ -16,7 +16,7 @@ func normalizeIP(raw string) string { | |||
| 16 | 16 | ip = host | |
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | - | parsed := net.ParseIP(strings.TrimSpace(ip)) | |
| 19 | + | parsed := net.ParseIP(ip) | |
| 20 | 20 | if parsed == nil { | |
| 21 | 21 | return "" | |
| 22 | 22 | } | |
| @@ -16,7 +16,7 @@ func normalizeIP(raw string) string { | |||||
| 16 | ip = host | 16 | ip = host | ||
| 17 | } | 17 | } | ||
| 18 | 18 | ||||
| 19 | - | parsed := net.ParseIP(strings.TrimSpace(ip)) | 19 | + | parsed := net.ParseIP(ip) |
| 20 | if parsed == nil { | 20 | if parsed == nil { | ||
| 21 | return "" | 21 | return "" | ||
| 22 | } | 22 | } | ||
M internal/adapters/in/http/middleware/security.go +5 -17
| @@ -5,7 +5,6 @@ import ( | |||
| 5 | 5 | ) | |
| 6 | 6 | ||
| 7 | 7 | // SecurityHeaders middleware adds standard security headers to HTTP responses. | |
| 8 | - | // This provides defense-in-depth against various web attacks. | |
| 9 | 8 | func SecurityHeaders(next http.Handler) http.Handler { | |
| 10 | 9 | return SecurityHeadersWithOptions(false)(next) | |
| 11 | 10 | } | |
| @@ -17,32 +16,21 @@ func SecurityHeaders(next http.Handler) http.Handler { | |||
| 17 | 16 | func SecurityHeadersWithOptions(forceHSTS bool) func(http.Handler) http.Handler { | |
| 18 | 17 | return func(next http.Handler) http.Handler { | |
| 19 | 18 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 20 | - | // Prevent MIME type sniffing | |
| 21 | 19 | w.Header().Set("X-Content-Type-Options", "nosniff") | |
| 22 | - | ||
| 23 | - | // Prevent clickjacking | |
| 24 | 20 | w.Header().Set("X-Frame-Options", "DENY") | |
| 25 | - | ||
| 26 | - | // XSS protection (legacy, but still useful for older browsers) | |
| 27 | 21 | w.Header().Set("X-XSS-Protection", "1; mode=block") | |
| 28 | - | ||
| 29 | - | // Control referrer information | |
| 30 | 22 | w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") | |
| 31 | - | ||
| 32 | - | // Restrict browser features | |
| 33 | 23 | w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") | |
| 34 | 24 | ||
| 35 | - | // HSTS: Enforce HTTPS-only for browsers. | |
| 36 | - | // Set when the request arrived over TLS, or when force_hsts is enabled | |
| 37 | - | // (for deployments behind a TLS-terminating proxy like Cloudflare). | |
| 25 | + | // Only set when TLS is active or force_hsts is configured, | |
| 26 | + | // since HSTS over plain HTTP would lock out clients. | |
| 38 | 27 | if r.TLS != nil || forceHSTS { | |
| 39 | 28 | w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") | |
| 40 | 29 | } | |
| 41 | 30 | ||
| 42 | - | // NOTE: CSP is intentionally NOT set here. Gordon proxies arbitrary web | |
| 43 | - | // apps, so a blanket "default-src 'none'" would break all proxied sites. | |
| 44 | - | // Instead, CSP is set directly on proxy-generated error responses in the | |
| 45 | - | // proxy error handlers (service.go). | |
| 31 | + | // CSP is intentionally NOT set here — Gordon proxies arbitrary web apps, | |
| 32 | + | // so a blanket policy would break proxied sites. CSP is set only on | |
| 33 | + | // Gordon-generated error responses (see proxy error handlers). | |
| 46 | 34 | ||
| 47 | 35 | next.ServeHTTP(w, r) | |
| 48 | 36 | }) | |
| @@ -5,7 +5,6 @@ import ( | |||||
| 5 | ) | 5 | ) | ||
| 6 | 6 | ||||
| 7 | // SecurityHeaders middleware adds standard security headers to HTTP responses. | 7 | // SecurityHeaders middleware adds standard security headers to HTTP responses. | ||
| 8 | - | // This provides defense-in-depth against various web attacks. | |||
| 9 | func SecurityHeaders(next http.Handler) http.Handler { | 8 | func SecurityHeaders(next http.Handler) http.Handler { | ||
| 10 | return SecurityHeadersWithOptions(false)(next) | 9 | return SecurityHeadersWithOptions(false)(next) | ||
| 11 | } | 10 | } | ||
| @@ -17,32 +16,21 @@ func SecurityHeaders(next http.Handler) http.Handler { | |||||
| 17 | func SecurityHeadersWithOptions(forceHSTS bool) func(http.Handler) http.Handler { | 16 | func SecurityHeadersWithOptions(forceHSTS bool) func(http.Handler) http.Handler { | ||
| 18 | return func(next http.Handler) http.Handler { | 17 | return func(next http.Handler) http.Handler { | ||
| 19 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 18 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 20 | - | // Prevent MIME type sniffing | |||
| 21 | w.Header().Set("X-Content-Type-Options", "nosniff") | 19 | w.Header().Set("X-Content-Type-Options", "nosniff") | ||
| 22 | - | ||||
| 23 | - | // Prevent clickjacking | |||
| 24 | w.Header().Set("X-Frame-Options", "DENY") | 20 | w.Header().Set("X-Frame-Options", "DENY") | ||
| 25 | - | ||||
| 26 | - | // XSS protection (legacy, but still useful for older browsers) | |||
| 27 | w.Header().Set("X-XSS-Protection", "1; mode=block") | 21 | w.Header().Set("X-XSS-Protection", "1; mode=block") | ||
| 28 | - | ||||
| 29 | - | // Control referrer information | |||
| 30 | w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") | 22 | w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") | ||
| 31 | - | ||||
| 32 | - | // Restrict browser features | |||
| 33 | w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") | 23 | w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") | ||
| 34 | 24 | ||||
| 35 | - | // HSTS: Enforce HTTPS-only for browsers. | 25 | + | // Only set when TLS is active or force_hsts is configured, |
| 36 | - | // Set when the request arrived over TLS, or when force_hsts is enabled | 26 | + | // since HSTS over plain HTTP would lock out clients. |
| 37 | - | // (for deployments behind a TLS-terminating proxy like Cloudflare). | |||
| 38 | if r.TLS != nil || forceHSTS { | 27 | if r.TLS != nil || forceHSTS { | ||
| 39 | w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") | 28 | w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") | ||
| 40 | } | 29 | } | ||
| 41 | 30 | ||||
| 42 | - | // NOTE: CSP is intentionally NOT set here. Gordon proxies arbitrary web | 31 | + | // CSP is intentionally NOT set here — Gordon proxies arbitrary web apps, |
| 43 | - | // apps, so a blanket "default-src 'none'" would break all proxied sites. | 32 | + | // so a blanket policy would break proxied sites. CSP is set only on |
| 44 | - | // Instead, CSP is set directly on proxy-generated error responses in the | 33 | + | // Gordon-generated error responses (see proxy error handlers). |
| 45 | - | // proxy error handlers (service.go). | |||
| 46 | 34 | ||||
| 47 | next.ServeHTTP(w, r) | 35 | next.ServeHTTP(w, r) | ||
| 48 | }) | 36 | }) | ||
M internal/adapters/in/http/proxy/forwarder.go +2 -9
| @@ -156,16 +156,9 @@ func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, regi | |||
| 156 | 156 | log.Error().Err(proxyErr).Int("registry_port", registryPort).Msg("registry proxy error") | |
| 157 | 157 | proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable) | |
| 158 | 158 | }, | |
| 159 | - | // ModifyResponse adjusts registry responses for Docker client consumption. | |
| 160 | - | // Registry API responses are consumed by Docker CLI/daemon, not browsers. | |
| 161 | - | // Upstream registry servers may inject browser-oriented security headers | |
| 162 | - | // (e.g., HSTS, CSP, X-Frame-Options) that are irrelevant for API clients | |
| 163 | - | // and could conflict with Gordon's own security headers on browser-facing | |
| 164 | - | // routes. We strip them here to ensure clean, predictable registry responses. | |
| 159 | + | // Strip browser-oriented security headers from upstream registry responses | |
| 160 | + | // to avoid conflicts with Gordon's own headers on browser-facing routes. | |
| 165 | 161 | ModifyResponse: func(resp *http.Response) error { | |
| 166 | - | // Remove browser-oriented security headers injected by the upstream | |
| 167 | - | // registry. These are unnecessary for Docker client API traffic and | |
| 168 | - | // would conflict with headers set on browser-facing proxy routes. | |
| 169 | 162 | resp.Header.Del("X-Content-Type-Options") | |
| 170 | 163 | resp.Header.Del("X-Frame-Options") | |
| 171 | 164 | resp.Header.Del("X-XSS-Protection") | |
| @@ -156,16 +156,9 @@ func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, regi | |||||
| 156 | log.Error().Err(proxyErr).Int("registry_port", registryPort).Msg("registry proxy error") | 156 | log.Error().Err(proxyErr).Int("registry_port", registryPort).Msg("registry proxy error") | ||
| 157 | proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable) | 157 | proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable) | ||
| 158 | }, | 158 | }, | ||
| 159 | - | // ModifyResponse adjusts registry responses for Docker client consumption. | 159 | + | // Strip browser-oriented security headers from upstream registry responses |
| 160 | - | // Registry API responses are consumed by Docker CLI/daemon, not browsers. | 160 | + | // to avoid conflicts with Gordon's own headers on browser-facing routes. |
| 161 | - | // Upstream registry servers may inject browser-oriented security headers | |||
| 162 | - | // (e.g., HSTS, CSP, X-Frame-Options) that are irrelevant for API clients | |||
| 163 | - | // and could conflict with Gordon's own security headers on browser-facing | |||
| 164 | - | // routes. We strip them here to ensure clean, predictable registry responses. | |||
| 165 | ModifyResponse: func(resp *http.Response) error { | 161 | ModifyResponse: func(resp *http.Response) error { | ||
| 166 | - | // Remove browser-oriented security headers injected by the upstream | |||
| 167 | - | // registry. These are unnecessary for Docker client API traffic and | |||
| 168 | - | // would conflict with headers set on browser-facing proxy routes. | |||
| 169 | resp.Header.Del("X-Content-Type-Options") | 162 | resp.Header.Del("X-Content-Type-Options") | ||
| 170 | resp.Header.Del("X-Frame-Options") | 163 | resp.Header.Del("X-Frame-Options") | ||
| 171 | resp.Header.Del("X-XSS-Protection") | 164 | resp.Header.Del("X-XSS-Protection") | ||
M internal/adapters/out/docker/runtime.go +1 -1
| @@ -30,7 +30,7 @@ import ( | |||
| 30 | 30 | "github.com/docker/docker/client" | |
| 31 | 31 | "github.com/docker/docker/pkg/stdcopy" | |
| 32 | 32 | "github.com/docker/go-connections/nat" | |
| 33 | - | units "github.com/docker/go-units" | |
| 33 | + | "github.com/docker/go-units" | |
| 34 | 34 | ||
| 35 | 35 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 36 | 36 | "github.com/bnema/gordon/internal/domain" | |
| @@ -30,7 +30,7 @@ import ( | |||||
| 30 | "github.com/docker/docker/client" | 30 | "github.com/docker/docker/client" | ||
| 31 | "github.com/docker/docker/pkg/stdcopy" | 31 | "github.com/docker/docker/pkg/stdcopy" | ||
| 32 | "github.com/docker/go-connections/nat" | 32 | "github.com/docker/go-connections/nat" | ||
| 33 | - | units "github.com/docker/go-units" | 33 | + | "github.com/docker/go-units" |
| 34 | 34 | ||||
| 35 | "github.com/bnema/gordon/internal/boundaries/out" | 35 | "github.com/bnema/gordon/internal/boundaries/out" | ||
| 36 | "github.com/bnema/gordon/internal/domain" | 36 | "github.com/bnema/gordon/internal/domain" | ||
M internal/app/run.go +42 -47
| @@ -1747,76 +1747,71 @@ func buildRegistryHandlerWithMiddleware( | |||
| 1747 | 1747 | return registryWithOtel, cidrAllowlistMiddleware, rateLimitMiddleware | |
| 1748 | 1748 | } | |
| 1749 | 1749 | ||
| 1750 | - | func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 1751 | - | if len(cfg.Server.RegistryAllowedIPs) == 0 { | |
| 1752 | - | return nil | |
| 1750 | + | // parseCIDRAllowlist parses a list of IPs/CIDRs, logs warnings for invalid entries, | |
| 1751 | + | // and returns the parsed nets. label is used in log messages (e.g. "registry_allowed_ips"). | |
| 1752 | + | func parseCIDRAllowlist(ips []string, label string, log zerowrap.Logger) ([]*net.IPNet, bool) { | |
| 1753 | + | if len(ips) == 0 { | |
| 1754 | + | return nil, false | |
| 1753 | 1755 | } | |
| 1754 | 1756 | ||
| 1755 | - | allowedNets := middleware.ParseTrustedProxies(cfg.Server.RegistryAllowedIPs) | |
| 1756 | - | if len(allowedNets) != len(cfg.Server.RegistryAllowedIPs) { | |
| 1757 | - | for _, entry := range cfg.Server.RegistryAllowedIPs { | |
| 1757 | + | allowedNets := middleware.ParseTrustedProxies(ips) | |
| 1758 | + | if len(allowedNets) != len(ips) { | |
| 1759 | + | for _, entry := range ips { | |
| 1758 | 1760 | if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 { | |
| 1759 | - | log.Warn().Str("entry", entry).Msg("ignoring invalid registry_allowed_ips entry") | |
| 1761 | + | log.Warn().Str("entry", entry).Msgf("ignoring invalid %s entry", label) | |
| 1760 | 1762 | } | |
| 1761 | 1763 | } | |
| 1762 | 1764 | } | |
| 1763 | 1765 | ||
| 1764 | 1766 | if len(allowedNets) == 0 { | |
| 1765 | 1767 | log.Error(). | |
| 1766 | - | Strs("registry_allowed_ips", cfg.Server.RegistryAllowedIPs). | |
| 1767 | - | Msg("registry_allowed_ips is set but no valid entries were parsed; registry will deny all traffic (fail-closed)") | |
| 1768 | - | return func(next http.Handler) http.Handler { | |
| 1769 | - | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 1770 | - | log.Warn(). | |
| 1771 | - | Str(zerowrap.FieldPath, r.URL.Path). | |
| 1772 | - | Str(zerowrap.FieldClientIP, middleware.GetClientIP(r, trustedNets)). | |
| 1773 | - | Msg("registry access denied due to invalid registry_allowed_ips configuration") | |
| 1774 | - | w.Header().Set("Content-Type", "application/json") | |
| 1775 | - | w.WriteHeader(http.StatusForbidden) | |
| 1776 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"}) | |
| 1777 | - | }) | |
| 1778 | - | } | |
| 1768 | + | Strs(label, ips). | |
| 1769 | + | Msgf("%s is set but no valid entries were parsed; will deny all traffic (fail-closed)", label) | |
| 1770 | + | return nil, true // allInvalid | |
| 1779 | 1771 | } | |
| 1780 | 1772 | ||
| 1781 | - | return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log) | |
| 1773 | + | return allowedNets, false | |
| 1782 | 1774 | } | |
| 1783 | 1775 | ||
| 1784 | - | func buildProxyCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 1785 | - | if len(cfg.Server.ProxyAllowedIPs) == 0 { | |
| 1786 | - | return nil | |
| 1776 | + | // denyAllHandler returns a middleware that rejects every request with 403 Forbidden. | |
| 1777 | + | func denyAllHandler(label string, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 1778 | + | return func(next http.Handler) http.Handler { | |
| 1779 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 1780 | + | log.Warn(). | |
| 1781 | + | Str(zerowrap.FieldClientIP, middleware.GetClientIP(r, trustedNets)). | |
| 1782 | + | Msgf("access denied due to invalid %s configuration", label) | |
| 1783 | + | w.Header().Set("Content-Type", "application/json") | |
| 1784 | + | w.WriteHeader(http.StatusForbidden) | |
| 1785 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"}) | |
| 1786 | + | }) | |
| 1787 | 1787 | } | |
| 1788 | + | } | |
| 1788 | 1789 | ||
| 1789 | - | allowedNets := middleware.ParseTrustedProxies(cfg.Server.ProxyAllowedIPs) | |
| 1790 | - | if len(allowedNets) != len(cfg.Server.ProxyAllowedIPs) { | |
| 1791 | - | for _, entry := range cfg.Server.ProxyAllowedIPs { | |
| 1792 | - | if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 { | |
| 1793 | - | log.Warn().Str("entry", entry).Msg("ignoring invalid proxy_allowed_ips entry") | |
| 1794 | - | } | |
| 1795 | - | } | |
| 1790 | + | func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 1791 | + | allowedNets, allInvalid := parseCIDRAllowlist(cfg.Server.RegistryAllowedIPs, "registry_allowed_ips", log) | |
| 1792 | + | if allInvalid { | |
| 1793 | + | return denyAllHandler("registry_allowed_ips", trustedNets, log) | |
| 1794 | + | } | |
| 1795 | + | if allowedNets == nil { | |
| 1796 | + | return nil | |
| 1796 | 1797 | } | |
| 1798 | + | return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log) | |
| 1799 | + | } | |
| 1797 | 1800 | ||
| 1798 | - | if len(allowedNets) == 0 { | |
| 1799 | - | log.Error(). | |
| 1800 | - | Strs("proxy_allowed_ips", cfg.Server.ProxyAllowedIPs). | |
| 1801 | - | Msg("proxy_allowed_ips is set but no valid entries were parsed; proxy will deny all traffic (fail-closed)") | |
| 1802 | - | return func(next http.Handler) http.Handler { | |
| 1803 | - | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 1804 | - | log.Warn(). | |
| 1805 | - | Str("host", r.Host). | |
| 1806 | - | Str(zerowrap.FieldClientIP, middleware.GetClientIP(r, trustedNets)). | |
| 1807 | - | Msg("proxy access denied due to invalid proxy_allowed_ips configuration") | |
| 1808 | - | w.Header().Set("Content-Type", "application/json") | |
| 1809 | - | w.WriteHeader(http.StatusForbidden) | |
| 1810 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"}) | |
| 1811 | - | }) | |
| 1812 | - | } | |
| 1801 | + | func buildProxyCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 1802 | + | allowedNets, allInvalid := parseCIDRAllowlist(cfg.Server.ProxyAllowedIPs, "proxy_allowed_ips", log) | |
| 1803 | + | if allInvalid { | |
| 1804 | + | return denyAllHandler("proxy_allowed_ips", trustedNets, log) | |
| 1805 | + | } | |
| 1806 | + | if allowedNets == nil { | |
| 1807 | + | return nil | |
| 1813 | 1808 | } | |
| 1814 | 1809 | ||
| 1815 | 1810 | log.Info(). | |
| 1816 | 1811 | Strs("proxy_allowed_ips", cfg.Server.ProxyAllowedIPs). | |
| 1817 | 1812 | Msg("proxy origin IP allowlist enabled") | |
| 1818 | 1813 | ||
| 1819 | - | return middleware.ProxyCIDRAllowlist(allowedNets, trustedNets, log) | |
| 1814 | + | return middleware.ProxyCIDRAllowlist(allowedNets, log) | |
| 1820 | 1815 | } | |
| 1821 | 1816 | ||
| 1822 | 1817 | func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| @@ -1747,76 +1747,71 @@ func buildRegistryHandlerWithMiddleware( | |||||
| 1747 | return registryWithOtel, cidrAllowlistMiddleware, rateLimitMiddleware | 1747 | return registryWithOtel, cidrAllowlistMiddleware, rateLimitMiddleware | ||
| 1748 | } | 1748 | } | ||
| 1749 | 1749 | ||||
| 1750 | - | func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | 1750 | + | // parseCIDRAllowlist parses a list of IPs/CIDRs, logs warnings for invalid entries, |
| 1751 | - | if len(cfg.Server.RegistryAllowedIPs) == 0 { | 1751 | + | // and returns the parsed nets. label is used in log messages (e.g. "registry_allowed_ips"). |
| 1752 | - | return nil | 1752 | + | func parseCIDRAllowlist(ips []string, label string, log zerowrap.Logger) ([]*net.IPNet, bool) { |
| 1753 | + | if len(ips) == 0 { | |||
| 1754 | + | return nil, false | |||
| 1753 | } | 1755 | } | ||
| 1754 | 1756 | ||||
| 1755 | - | allowedNets := middleware.ParseTrustedProxies(cfg.Server.RegistryAllowedIPs) | 1757 | + | allowedNets := middleware.ParseTrustedProxies(ips) |
| 1756 | - | if len(allowedNets) != len(cfg.Server.RegistryAllowedIPs) { | 1758 | + | if len(allowedNets) != len(ips) { |
| 1757 | - | for _, entry := range cfg.Server.RegistryAllowedIPs { | 1759 | + | for _, entry := range ips { |
| 1758 | if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 { | 1760 | if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 { | ||
| 1759 | - | log.Warn().Str("entry", entry).Msg("ignoring invalid registry_allowed_ips entry") | 1761 | + | log.Warn().Str("entry", entry).Msgf("ignoring invalid %s entry", label) |
| 1760 | } | 1762 | } | ||
| 1761 | } | 1763 | } | ||
| 1762 | } | 1764 | } | ||
| 1763 | 1765 | ||||
| 1764 | if len(allowedNets) == 0 { | 1766 | if len(allowedNets) == 0 { | ||
| 1765 | log.Error(). | 1767 | log.Error(). | ||
| 1766 | - | Strs("registry_allowed_ips", cfg.Server.RegistryAllowedIPs). | 1768 | + | Strs(label, ips). |
| 1767 | - | Msg("registry_allowed_ips is set but no valid entries were parsed; registry will deny all traffic (fail-closed)") | 1769 | + | Msgf("%s is set but no valid entries were parsed; will deny all traffic (fail-closed)", label) |
| 1768 | - | return func(next http.Handler) http.Handler { | 1770 | + | return nil, true // allInvalid |
| 1769 | - | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 1770 | - | log.Warn(). | |||
| 1771 | - | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 1772 | - | Str(zerowrap.FieldClientIP, middleware.GetClientIP(r, trustedNets)). | |||
| 1773 | - | Msg("registry access denied due to invalid registry_allowed_ips configuration") | |||
| 1774 | - | w.Header().Set("Content-Type", "application/json") | |||
| 1775 | - | w.WriteHeader(http.StatusForbidden) | |||
| 1776 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"}) | |||
| 1777 | - | }) | |||
| 1778 | - | } | |||
| 1779 | } | 1771 | } | ||
| 1780 | 1772 | ||||
| 1781 | - | return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log) | 1773 | + | return allowedNets, false |
| 1782 | } | 1774 | } | ||
| 1783 | 1775 | ||||
| 1784 | - | func buildProxyCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | 1776 | + | // denyAllHandler returns a middleware that rejects every request with 403 Forbidden. |
| 1785 | - | if len(cfg.Server.ProxyAllowedIPs) == 0 { | 1777 | + | func denyAllHandler(label string, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { |
| 1786 | - | return nil | 1778 | + | return func(next http.Handler) http.Handler { |
| 1779 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 1780 | + | log.Warn(). | |||
| 1781 | + | Str(zerowrap.FieldClientIP, middleware.GetClientIP(r, trustedNets)). | |||
| 1782 | + | Msgf("access denied due to invalid %s configuration", label) | |||
| 1783 | + | w.Header().Set("Content-Type", "application/json") | |||
| 1784 | + | w.WriteHeader(http.StatusForbidden) | |||
| 1785 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"}) | |||
| 1786 | + | }) | |||
| 1787 | } | 1787 | } | ||
| 1788 | + | } | |||
| 1788 | 1789 | ||||
| 1789 | - | allowedNets := middleware.ParseTrustedProxies(cfg.Server.ProxyAllowedIPs) | 1790 | + | func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { |
| 1790 | - | if len(allowedNets) != len(cfg.Server.ProxyAllowedIPs) { | 1791 | + | allowedNets, allInvalid := parseCIDRAllowlist(cfg.Server.RegistryAllowedIPs, "registry_allowed_ips", log) |
| 1791 | - | for _, entry := range cfg.Server.ProxyAllowedIPs { | 1792 | + | if allInvalid { |
| 1792 | - | if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 { | 1793 | + | return denyAllHandler("registry_allowed_ips", trustedNets, log) |
| 1793 | - | log.Warn().Str("entry", entry).Msg("ignoring invalid proxy_allowed_ips entry") | 1794 | + | } |
| 1794 | - | } | 1795 | + | if allowedNets == nil { |
| 1795 | - | } | 1796 | + | return nil |
| 1796 | } | 1797 | } | ||
| 1798 | + | return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log) | |||
| 1799 | + | } | |||
| 1797 | 1800 | ||||
| 1798 | - | if len(allowedNets) == 0 { | 1801 | + | func buildProxyCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { |
| 1799 | - | log.Error(). | 1802 | + | allowedNets, allInvalid := parseCIDRAllowlist(cfg.Server.ProxyAllowedIPs, "proxy_allowed_ips", log) |
| 1800 | - | Strs("proxy_allowed_ips", cfg.Server.ProxyAllowedIPs). | 1803 | + | if allInvalid { |
| 1801 | - | Msg("proxy_allowed_ips is set but no valid entries were parsed; proxy will deny all traffic (fail-closed)") | 1804 | + | return denyAllHandler("proxy_allowed_ips", trustedNets, log) |
| 1802 | - | return func(next http.Handler) http.Handler { | 1805 | + | } |
| 1803 | - | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 1806 | + | if allowedNets == nil { |
| 1804 | - | log.Warn(). | 1807 | + | return nil |
| 1805 | - | Str("host", r.Host). | |||
| 1806 | - | Str(zerowrap.FieldClientIP, middleware.GetClientIP(r, trustedNets)). | |||
| 1807 | - | Msg("proxy access denied due to invalid proxy_allowed_ips configuration") | |||
| 1808 | - | w.Header().Set("Content-Type", "application/json") | |||
| 1809 | - | w.WriteHeader(http.StatusForbidden) | |||
| 1810 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"}) | |||
| 1811 | - | }) | |||
| 1812 | - | } | |||
| 1813 | } | 1808 | } | ||
| 1814 | 1809 | ||||
| 1815 | log.Info(). | 1810 | log.Info(). | ||
| 1816 | Strs("proxy_allowed_ips", cfg.Server.ProxyAllowedIPs). | 1811 | Strs("proxy_allowed_ips", cfg.Server.ProxyAllowedIPs). | ||
| 1817 | Msg("proxy origin IP allowlist enabled") | 1812 | Msg("proxy origin IP allowlist enabled") | ||
| 1818 | 1813 | ||||
| 1819 | - | return middleware.ProxyCIDRAllowlist(allowedNets, trustedNets, log) | 1814 | + | return middleware.ProxyCIDRAllowlist(allowedNets, log) |
| 1820 | } | 1815 | } | ||
| 1821 | 1816 | ||||
| 1822 | func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http.Handler) http.Handler { | 1817 | func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http.Handler) http.Handler { | ||