Merge pull request #151 from bnema/security/hardening-production
security: harden production deployment defaults
15 files · +384 -87
M docs/cli/attachments.md +1 -1
| @@ -7,7 +7,7 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |||
| 7 | 7 | ||
| 8 | 8 | ## Requirements | |
| 9 | 9 | ||
| 10 | - | Attachments require `network_isolation.enabled = true` in your configuration. Without network isolation, containers use Docker's default bridge network which does not provide DNS resolution - your app won't be able to reach attachments by hostname (e.g., `postgres:5432`). | |
| 10 | + | Attachments require `network_isolation.enabled = true` in your configuration (enabled by default). Without network isolation, containers use Docker's default bridge network which does not provide DNS resolution - your app won't be able to reach attachments by hostname (e.g., `postgres:5432`). | |
| 11 | 11 | ||
| 12 | 12 | The CLI will warn you if you try to add an attachment without network isolation enabled. | |
| 13 | 13 | ||
| @@ -7,7 +7,7 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |||||
| 7 | 7 | ||||
| 8 | ## Requirements | 8 | ## Requirements | ||
| 9 | 9 | ||||
| 10 | - | Attachments require `network_isolation.enabled = true` in your configuration. Without network isolation, containers use Docker's default bridge network which does not provide DNS resolution - your app won't be able to reach attachments by hostname (e.g., `postgres:5432`). | 10 | + | Attachments require `network_isolation.enabled = true` in your configuration (enabled by default). Without network isolation, containers use Docker's default bridge network which does not provide DNS resolution - your app won't be able to reach attachments by hostname (e.g., `postgres:5432`). |
| 11 | 11 | ||||
| 12 | The CLI will warn you if you try to add an attachment without network isolation enabled. | 12 | The CLI will warn you if you try to add an attachment without network isolation enabled. | ||
| 13 | 13 | ||||
M docs/config/index.md +1 -1
| @@ -197,7 +197,7 @@ keep_last = 3 | |||
| 197 | 197 | | `volumes.auto_create` | `true` | | |
| 198 | 198 | | `volumes.prefix` | `"gordon"` | | |
| 199 | 199 | | `volumes.preserve` | `true` | | |
| 200 | - | | `network_isolation.enabled` | `false` | | |
| 200 | + | | `network_isolation.enabled` | `true` | | |
| 201 | 201 | | `auto_route.enabled` | `false` | | |
| 202 | 202 | | `backups.enabled` | `false` | | |
| 203 | 203 | | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) | | |
| @@ -197,7 +197,7 @@ keep_last = 3 | |||||
| 197 | | `volumes.auto_create` | `true` | | 197 | | `volumes.auto_create` | `true` | | ||
| 198 | | `volumes.prefix` | `"gordon"` | | 198 | | `volumes.prefix` | `"gordon"` | | ||
| 199 | | `volumes.preserve` | `true` | | 199 | | `volumes.preserve` | `true` | | ||
| 200 | - | | `network_isolation.enabled` | `false` | | 200 | + | | `network_isolation.enabled` | `true` | |
| 201 | | `auto_route.enabled` | `false` | | 201 | | `auto_route.enabled` | `false` | | ||
| 202 | | `backups.enabled` | `false` | | 202 | | `backups.enabled` | `false` | | ||
| 203 | | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) | | 203 | | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) | | ||
M docs/config/network-isolation.md +11 -1
| @@ -10,11 +10,21 @@ enabled = true | |||
| 10 | 10 | network_prefix = "gordon" | |
| 11 | 11 | ``` | |
| 12 | 12 | ||
| 13 | + | ## Migration Note | |
| 14 | + | ||
| 15 | + | As of this release, `network_isolation.enabled` defaults to `true` (previously `false`). | |
| 16 | + | Existing installs that rely on a shared network must explicitly opt out: | |
| 17 | + | ||
| 18 | + | ```toml | |
| 19 | + | [network_isolation] | |
| 20 | + | enabled = false | |
| 21 | + | ``` | |
| 22 | + | ||
| 13 | 23 | ## Options | |
| 14 | 24 | ||
| 15 | 25 | | Option | Type | Default | Description | | |
| 16 | 26 | |--------|------|---------|-------------| | |
| 17 | - | | `enabled` | bool | `false` | Enable per-app network isolation | | |
| 27 | + | | `enabled` | bool | `true` | Enable per-app network isolation (changed from `false`) | | |
| 18 | 28 | | `network_prefix` | string | `"gordon"` | Prefix for created networks | | |
| 19 | 29 | ||
| 20 | 30 | ## How It Works | |
| @@ -10,11 +10,21 @@ enabled = true | |||||
| 10 | network_prefix = "gordon" | 10 | network_prefix = "gordon" | ||
| 11 | ``` | 11 | ``` | ||
| 12 | 12 | ||||
| 13 | + | ## Migration Note | |||
| 14 | + | ||||
| 15 | + | As of this release, `network_isolation.enabled` defaults to `true` (previously `false`). | |||
| 16 | + | Existing installs that rely on a shared network must explicitly opt out: | |||
| 17 | + | ||||
| 18 | + | ```toml | |||
| 19 | + | [network_isolation] | |||
| 20 | + | enabled = false | |||
| 21 | + | ``` | |||
| 22 | + | ||||
| 13 | ## Options | 23 | ## Options | ||
| 14 | 24 | ||||
| 15 | | Option | Type | Default | Description | | 25 | | Option | Type | Default | Description | | ||
| 16 | |--------|------|---------|-------------| | 26 | |--------|------|---------|-------------| | ||
| 17 | - | | `enabled` | bool | `false` | Enable per-app network isolation | | 27 | + | | `enabled` | bool | `true` | Enable per-app network isolation (changed from `false`) | |
| 18 | | `network_prefix` | string | `"gordon"` | Prefix for created networks | | 28 | | `network_prefix` | string | `"gordon"` | Prefix for created networks | | ||
| 19 | 29 | ||||
| 20 | ## How It Works | 30 | ## How It Works | ||
M docs/config/reference.md +8 -2
| @@ -19,6 +19,9 @@ gordon_domain = "" # Required: Gordon domain (registry | |||
| 19 | 19 | data_dir = "~/.gordon" # Data directory (varies by install type) | |
| 20 | 20 | max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk | |
| 21 | 21 | registry_allowed_ips = [] # IPs or CIDR ranges allowed to access the registry (empty = allow all) | |
| 22 | + | proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach the proxy (empty = allow all, e.g. Cloudflare IPs) | |
| 23 | + | registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only) | |
| 24 | + | force_hsts = false # Always send HSTS header (enable when behind TLS-terminating proxy) | |
| 22 | 25 | ||
| 23 | 26 | # ============================================================================= | |
| 24 | 27 | # AUTHENTICATION (required - Gordon won't start without credentials configured) | |
| @@ -101,7 +104,7 @@ enabled = false # Create routes from image labels a | |||
| 101 | 104 | # NETWORK ISOLATION | |
| 102 | 105 | # ============================================================================= | |
| 103 | 106 | [network_isolation] | |
| 104 | - | enabled = false # Enable per-app Docker networks | |
| 107 | + | enabled = true # Enable per-app Docker networks | |
| 105 | 108 | network_prefix = "gordon" # Prefix for created networks | |
| 106 | 109 | ||
| 107 | 110 | # ============================================================================= | |
| @@ -177,6 +180,9 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 177 | 180 | | `server.data_dir` | `~/.gordon` | Data directory | | |
| 178 | 181 | | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk | | |
| 179 | 182 | | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | |
| 183 | + | | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) | | |
| 184 | + | | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) | | |
| 185 | + | | `server.force_hsts` | `false` | Always send HSTS header (for TLS-terminating proxy setups) | | |
| 180 | 186 | | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) | | |
| 181 | 187 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | |
| 182 | 188 | | `auth.token_expiry` | `"720h"` | 30 days | | |
| @@ -211,7 +217,7 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 211 | 217 | | `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop | | |
| 212 | 218 | | `deploy.drain_delay` | `"2s"` | Delay before stopping previous container after cache invalidation | | |
| 213 | 219 | | `auto_route.enabled` | `false` | Auto-route disabled | | |
| 214 | - | | `network_isolation.enabled` | `false` | Isolation disabled | | |
| 220 | + | | `network_isolation.enabled` | `true` | Network isolation enabled | | |
| 215 | 221 | | `network_isolation.network_prefix` | `"gordon"` | Network prefix | | |
| 216 | 222 | | `volumes.auto_create` | `true` | Auto-create volumes | | |
| 217 | 223 | | `volumes.prefix` | `"gordon"` | Volume prefix | | |
| @@ -19,6 +19,9 @@ gordon_domain = "" # Required: Gordon domain (registry | |||||
| 19 | data_dir = "~/.gordon" # Data directory (varies by install type) | 19 | data_dir = "~/.gordon" # Data directory (varies by install type) | ||
| 20 | max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk | 20 | max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk | ||
| 21 | registry_allowed_ips = [] # IPs or CIDR ranges allowed to access the registry (empty = allow all) | 21 | registry_allowed_ips = [] # IPs or CIDR ranges allowed to access the registry (empty = allow all) | ||
| 22 | + | proxy_allowed_ips = [] # IPs or CIDR ranges allowed to reach the proxy (empty = allow all, e.g. Cloudflare IPs) | |||
| 23 | + | registry_listen_address = "" # Bind address for registry (empty = all interfaces, "127.0.0.1" = loopback only) | |||
| 24 | + | force_hsts = false # Always send HSTS header (enable when behind TLS-terminating proxy) | |||
| 22 | 25 | ||||
| 23 | # ============================================================================= | 26 | # ============================================================================= | ||
| 24 | # AUTHENTICATION (required - Gordon won't start without credentials configured) | 27 | # AUTHENTICATION (required - Gordon won't start without credentials configured) | ||
| @@ -101,7 +104,7 @@ enabled = false # Create routes from image labels a | |||||
| 101 | # NETWORK ISOLATION | 104 | # NETWORK ISOLATION | ||
| 102 | # ============================================================================= | 105 | # ============================================================================= | ||
| 103 | [network_isolation] | 106 | [network_isolation] | ||
| 104 | - | enabled = false # Enable per-app Docker networks | 107 | + | enabled = true # Enable per-app Docker networks |
| 105 | network_prefix = "gordon" # Prefix for created networks | 108 | network_prefix = "gordon" # Prefix for created networks | ||
| 106 | 109 | ||||
| 107 | # ============================================================================= | 110 | # ============================================================================= | ||
| @@ -177,6 +180,9 @@ keep_last = 3 # Keep N newest tags per repository | |||||
| 177 | | `server.data_dir` | `~/.gordon` | Data directory | | 180 | | `server.data_dir` | `~/.gordon` | Data directory | | ||
| 178 | | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk | | 181 | | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk | | ||
| 179 | | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | 182 | | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | ||
| 183 | + | | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) | | |||
| 184 | + | | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) | | |||
| 185 | + | | `server.force_hsts` | `false` | Always send HSTS header (for TLS-terminating proxy setups) | | |||
| 180 | | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) | | 186 | | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) | | ||
| 181 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | 187 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | ||
| 182 | | `auth.token_expiry` | `"720h"` | 30 days | | 188 | | `auth.token_expiry` | `"720h"` | 30 days | | ||
| @@ -211,7 +217,7 @@ keep_last = 3 # Keep N newest tags per repository | |||||
| 211 | | `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop | | 217 | | `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop | | ||
| 212 | | `deploy.drain_delay` | `"2s"` | Delay before stopping previous container after cache invalidation | | 218 | | `deploy.drain_delay` | `"2s"` | Delay before stopping previous container after cache invalidation | | ||
| 213 | | `auto_route.enabled` | `false` | Auto-route disabled | | 219 | | `auto_route.enabled` | `false` | Auto-route disabled | | ||
| 214 | - | | `network_isolation.enabled` | `false` | Isolation disabled | | 220 | + | | `network_isolation.enabled` | `true` | Network isolation enabled | |
| 215 | | `network_isolation.network_prefix` | `"gordon"` | Network prefix | | 221 | | `network_isolation.network_prefix` | `"gordon"` | Network prefix | | ||
| 216 | | `volumes.auto_create` | `true` | Auto-create volumes | | 222 | | `volumes.auto_create` | `true` | Auto-create volumes | | ||
| 217 | | `volumes.prefix` | `"gordon"` | Volume prefix | | 223 | | `volumes.prefix` | `"gordon"` | Volume prefix | | ||
M docs/config/server.md +45 -0
| @@ -32,6 +32,9 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | |||
| 32 | 32 | | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests | | |
| 33 | 33 | | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk | | |
| 34 | 34 | | `registry_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | |
| 35 | + | | `proxy_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) | | |
| 36 | + | | `registry_listen_address` | string | `""` | Bind address for registry (empty = all interfaces) | | |
| 37 | + | | `force_hsts` | bool | `false` | Always send HSTS header (for TLS-terminating proxy setups) | | |
| 35 | 38 | ||
| 36 | 39 | ## Port Configuration | |
| 37 | 40 | ||
| @@ -255,6 +258,48 @@ Via environment variable: | |||
| 255 | 258 | GORDON_SERVER_REGISTRY_ALLOWED_IPS=100.64.0.0/10 | |
| 256 | 259 | ``` | |
| 257 | 260 | ||
| 261 | + | ## Proxy Origin IP Allowlist | |
| 262 | + | ||
| 263 | + | The `proxy_allowed_ips` setting restricts which IPs can reach the proxy server. This prevents direct-to-origin attacks that bypass CDN protections (e.g. Cloudflare WAF, DDoS mitigation). When set, only connections from listed CIDRs (plus localhost) are accepted. | |
| 264 | + | ||
| 265 | + | ```toml | |
| 266 | + | [server] | |
| 267 | + | # Only accept proxy traffic from Cloudflare edge IPs | |
| 268 | + | proxy_allowed_ips = [ | |
| 269 | + | "173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", | |
| 270 | + | "103.31.4.0/22", "141.101.64.0/18", "108.162.192.0/18", | |
| 271 | + | "190.93.240.0/20", "188.114.96.0/20", "197.234.240.0/22", | |
| 272 | + | "198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13", | |
| 273 | + | "104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22", | |
| 274 | + | ] | |
| 275 | + | ``` | |
| 276 | + | ||
| 277 | + | **Behavior:** | |
| 278 | + | - Checks the direct connection IP (`RemoteAddr`), not `X-Forwarded-For` | |
| 279 | + | - Localhost is always allowed | |
| 280 | + | - Denied requests receive `403 Forbidden` | |
| 281 | + | - An empty list allows all traffic (default) | |
| 282 | + | ||
| 283 | + | **Important:** Cloudflare publishes their IP ranges at [cloudflare.com/ips](https://www.cloudflare.com/ips/). Keep your allowlist updated when Cloudflare adds new ranges. | |
| 284 | + | ||
| 285 | + | ## Registry Listen Address | |
| 286 | + | ||
| 287 | + | The `registry_listen_address` controls which interface the registry server binds to. By default it binds to all interfaces. Set to `127.0.0.1` to restrict registry access to localhost only, preventing containers from reaching the registry via the Docker bridge gateway. | |
| 288 | + | ||
| 289 | + | ```toml | |
| 290 | + | [server] | |
| 291 | + | registry_listen_address = "127.0.0.1" # Loopback only | |
| 292 | + | ``` | |
| 293 | + | ||
| 294 | + | ## Force HSTS | |
| 295 | + | ||
| 296 | + | When Gordon runs behind a TLS-terminating proxy (e.g. Cloudflare), `r.TLS` is nil so the HSTS header is not sent by default. Enable `force_hsts` to always include the `Strict-Transport-Security` header. | |
| 297 | + | ||
| 298 | + | ```toml | |
| 299 | + | [server] | |
| 300 | + | force_hsts = true | |
| 301 | + | ``` | |
| 302 | + | ||
| 258 | 303 | ## Examples | |
| 259 | 304 | ||
| 260 | 305 | ### Development Configuration | |
| @@ -32,6 +32,9 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | |||||
| 32 | | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests | | 32 | | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests | | ||
| 33 | | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk | | 33 | | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk | | ||
| 34 | | `registry_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | 34 | | `registry_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | ||
| 35 | + | | `proxy_allowed_ips` | []string | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) | | |||
| 36 | + | | `registry_listen_address` | string | `""` | Bind address for registry (empty = all interfaces) | | |||
| 37 | + | | `force_hsts` | bool | `false` | Always send HSTS header (for TLS-terminating proxy setups) | | |||
| 35 | 38 | ||||
| 36 | ## Port Configuration | 39 | ## Port Configuration | ||
| 37 | 40 | ||||
| @@ -255,6 +258,48 @@ Via environment variable: | |||||
| 255 | GORDON_SERVER_REGISTRY_ALLOWED_IPS=100.64.0.0/10 | 258 | GORDON_SERVER_REGISTRY_ALLOWED_IPS=100.64.0.0/10 | ||
| 256 | ``` | 259 | ``` | ||
| 257 | 260 | ||||
| 261 | + | ## Proxy Origin IP Allowlist | |||
| 262 | + | ||||
| 263 | + | The `proxy_allowed_ips` setting restricts which IPs can reach the proxy server. This prevents direct-to-origin attacks that bypass CDN protections (e.g. Cloudflare WAF, DDoS mitigation). When set, only connections from listed CIDRs (plus localhost) are accepted. | |||
| 264 | + | ||||
| 265 | + | ```toml | |||
| 266 | + | [server] | |||
| 267 | + | # Only accept proxy traffic from Cloudflare edge IPs | |||
| 268 | + | proxy_allowed_ips = [ | |||
| 269 | + | "173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", | |||
| 270 | + | "103.31.4.0/22", "141.101.64.0/18", "108.162.192.0/18", | |||
| 271 | + | "190.93.240.0/20", "188.114.96.0/20", "197.234.240.0/22", | |||
| 272 | + | "198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13", | |||
| 273 | + | "104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22", | |||
| 274 | + | ] | |||
| 275 | + | ``` | |||
| 276 | + | ||||
| 277 | + | **Behavior:** | |||
| 278 | + | - Checks the direct connection IP (`RemoteAddr`), not `X-Forwarded-For` | |||
| 279 | + | - Localhost is always allowed | |||
| 280 | + | - Denied requests receive `403 Forbidden` | |||
| 281 | + | - An empty list allows all traffic (default) | |||
| 282 | + | ||||
| 283 | + | **Important:** Cloudflare publishes their IP ranges at [cloudflare.com/ips](https://www.cloudflare.com/ips/). Keep your allowlist updated when Cloudflare adds new ranges. | |||
| 284 | + | ||||
| 285 | + | ## Registry Listen Address | |||
| 286 | + | ||||
| 287 | + | The `registry_listen_address` controls which interface the registry server binds to. By default it binds to all interfaces. Set to `127.0.0.1` to restrict registry access to localhost only, preventing containers from reaching the registry via the Docker bridge gateway. | |||
| 288 | + | ||||
| 289 | + | ```toml | |||
| 290 | + | [server] | |||
| 291 | + | registry_listen_address = "127.0.0.1" # Loopback only | |||
| 292 | + | ``` | |||
| 293 | + | ||||
| 294 | + | ## Force HSTS | |||
| 295 | + | ||||
| 296 | + | When Gordon runs behind a TLS-terminating proxy (e.g. Cloudflare), `r.TLS` is nil so the HSTS header is not sent by default. Enable `force_hsts` to always include the `Strict-Transport-Security` header. | |||
| 297 | + | ||||
| 298 | + | ```toml | |||
| 299 | + | [server] | |||
| 300 | + | force_hsts = true | |||
| 301 | + | ``` | |||
| 302 | + | ||||
| 258 | ## Examples | 303 | ## Examples | ||
| 259 | 304 | ||||
| 260 | ### Development Configuration | 305 | ### Development Configuration | ||
M internal/adapters/in/http/middleware/cidr.go +29 -8
| @@ -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) | |
| @@ -49,3 +47,26 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L | |||
| 49 | 47 | }) | |
| 50 | 48 | } | |
| 51 | 49 | } | |
| 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 | + | ||
| 60 | + | // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges. | |
| 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. | |
| 63 | + | // An empty allowedNets slice is a no-op (all traffic passes through). | |
| 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 | |
| 69 | + | } | |
| 70 | + | return remoteIP | |
| 71 | + | }, "proxy origin", log) | |
| 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) | ||
| @@ -49,3 +47,26 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L | |||||
| 49 | }) | 47 | }) | ||
| 50 | } | 48 | } | ||
| 51 | } | 49 | } | ||
| 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 | + | ||||
| 60 | + | // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges. | |||
| 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. | |||
| 63 | + | // An empty allowedNets slice is a no-op (all traffic passes through). | |||
| 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 | |||
| 69 | + | } | |||
| 70 | + | return remoteIP | |||
| 71 | + | }, "proxy origin", log) | |||
| 72 | + | } | |||
M internal/adapters/in/http/middleware/cidr_test.go +68 -0
| @@ -109,3 +109,71 @@ func TestRegistryCIDRAllowlist(t *testing.T) { | |||
| 109 | 109 | }) | |
| 110 | 110 | } | |
| 111 | 111 | } | |
| 112 | + | ||
| 113 | + | func TestProxyCIDRAllowlist(t *testing.T) { | |
| 114 | + | log := testLogger() | |
| 115 | + | redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 116 | + | http.Redirect(w, r, "https://"+r.Host+r.RequestURI, http.StatusPermanentRedirect) | |
| 117 | + | }) | |
| 118 | + | ||
| 119 | + | tests := []struct { | |
| 120 | + | name string | |
| 121 | + | allowedNets []*net.IPNet | |
| 122 | + | remoteAddr string | |
| 123 | + | wantStatus int | |
| 124 | + | }{ | |
| 125 | + | { | |
| 126 | + | name: "no CIDRs configured passes through", | |
| 127 | + | allowedNets: nil, | |
| 128 | + | remoteAddr: "203.0.113.50:1234", | |
| 129 | + | wantStatus: http.StatusPermanentRedirect, | |
| 130 | + | }, | |
| 131 | + | { | |
| 132 | + | name: "allowed IP gets redirect", | |
| 133 | + | allowedNets: ParseTrustedProxies([]string{"173.245.48.0/20"}), | |
| 134 | + | remoteAddr: "173.245.48.1:1234", | |
| 135 | + | wantStatus: http.StatusPermanentRedirect, | |
| 136 | + | }, | |
| 137 | + | { | |
| 138 | + | name: "blocked IP gets 403", | |
| 139 | + | allowedNets: ParseTrustedProxies([]string{"173.245.48.0/20"}), | |
| 140 | + | remoteAddr: "203.0.113.50:1234", | |
| 141 | + | wantStatus: http.StatusForbidden, | |
| 142 | + | }, | |
| 143 | + | { | |
| 144 | + | name: "localhost always allowed", | |
| 145 | + | allowedNets: ParseTrustedProxies([]string{"173.245.48.0/20"}), | |
| 146 | + | remoteAddr: "127.0.0.1:1234", | |
| 147 | + | wantStatus: http.StatusPermanentRedirect, | |
| 148 | + | }, | |
| 149 | + | { | |
| 150 | + | name: "ignores XFF (uses RemoteAddr only)", | |
| 151 | + | allowedNets: ParseTrustedProxies([]string{"173.245.48.0/20"}), | |
| 152 | + | remoteAddr: "203.0.113.50:1234", | |
| 153 | + | wantStatus: http.StatusForbidden, | |
| 154 | + | }, | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | for _, tt := range tests { | |
| 158 | + | t.Run(tt.name, func(t *testing.T) { | |
| 159 | + | handler := ProxyCIDRAllowlist(tt.allowedNets, log)(redirect) | |
| 160 | + | ||
| 161 | + | req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) | |
| 162 | + | req.RemoteAddr = tt.remoteAddr | |
| 163 | + | // Even with XFF from an "allowed" range, the proxy middleware | |
| 164 | + | // must check RemoteAddr, not forwarded headers. | |
| 165 | + | req.Header.Set("X-Forwarded-For", "173.245.48.1") | |
| 166 | + | rr := httptest.NewRecorder() | |
| 167 | + | ||
| 168 | + | handler.ServeHTTP(rr, req) | |
| 169 | + | ||
| 170 | + | assert.Equal(t, tt.wantStatus, rr.Code) | |
| 171 | + | ||
| 172 | + | if tt.wantStatus == http.StatusForbidden { | |
| 173 | + | var errResp dto.ErrorResponse | |
| 174 | + | require.NoError(t, json.NewDecoder(rr.Body).Decode(&errResp)) | |
| 175 | + | assert.Equal(t, "Forbidden", errResp.Error) | |
| 176 | + | } | |
| 177 | + | }) | |
| 178 | + | } | |
| 179 | + | } | |
| @@ -109,3 +109,71 @@ func TestRegistryCIDRAllowlist(t *testing.T) { | |||||
| 109 | }) | 109 | }) | ||
| 110 | } | 110 | } | ||
| 111 | } | 111 | } | ||
| 112 | + | ||||
| 113 | + | func TestProxyCIDRAllowlist(t *testing.T) { | |||
| 114 | + | log := testLogger() | |||
| 115 | + | redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 116 | + | http.Redirect(w, r, "https://"+r.Host+r.RequestURI, http.StatusPermanentRedirect) | |||
| 117 | + | }) | |||
| 118 | + | ||||
| 119 | + | tests := []struct { | |||
| 120 | + | name string | |||
| 121 | + | allowedNets []*net.IPNet | |||
| 122 | + | remoteAddr string | |||
| 123 | + | wantStatus int | |||
| 124 | + | }{ | |||
| 125 | + | { | |||
| 126 | + | name: "no CIDRs configured passes through", | |||
| 127 | + | allowedNets: nil, | |||
| 128 | + | remoteAddr: "203.0.113.50:1234", | |||
| 129 | + | wantStatus: http.StatusPermanentRedirect, | |||
| 130 | + | }, | |||
| 131 | + | { | |||
| 132 | + | name: "allowed IP gets redirect", | |||
| 133 | + | allowedNets: ParseTrustedProxies([]string{"173.245.48.0/20"}), | |||
| 134 | + | remoteAddr: "173.245.48.1:1234", | |||
| 135 | + | wantStatus: http.StatusPermanentRedirect, | |||
| 136 | + | }, | |||
| 137 | + | { | |||
| 138 | + | name: "blocked IP gets 403", | |||
| 139 | + | allowedNets: ParseTrustedProxies([]string{"173.245.48.0/20"}), | |||
| 140 | + | remoteAddr: "203.0.113.50:1234", | |||
| 141 | + | wantStatus: http.StatusForbidden, | |||
| 142 | + | }, | |||
| 143 | + | { | |||
| 144 | + | name: "localhost always allowed", | |||
| 145 | + | allowedNets: ParseTrustedProxies([]string{"173.245.48.0/20"}), | |||
| 146 | + | remoteAddr: "127.0.0.1:1234", | |||
| 147 | + | wantStatus: http.StatusPermanentRedirect, | |||
| 148 | + | }, | |||
| 149 | + | { | |||
| 150 | + | name: "ignores XFF (uses RemoteAddr only)", | |||
| 151 | + | allowedNets: ParseTrustedProxies([]string{"173.245.48.0/20"}), | |||
| 152 | + | remoteAddr: "203.0.113.50:1234", | |||
| 153 | + | wantStatus: http.StatusForbidden, | |||
| 154 | + | }, | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | for _, tt := range tests { | |||
| 158 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 159 | + | handler := ProxyCIDRAllowlist(tt.allowedNets, log)(redirect) | |||
| 160 | + | ||||
| 161 | + | req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) | |||
| 162 | + | req.RemoteAddr = tt.remoteAddr | |||
| 163 | + | // Even with XFF from an "allowed" range, the proxy middleware | |||
| 164 | + | // must check RemoteAddr, not forwarded headers. | |||
| 165 | + | req.Header.Set("X-Forwarded-For", "173.245.48.1") | |||
| 166 | + | rr := httptest.NewRecorder() | |||
| 167 | + | ||||
| 168 | + | handler.ServeHTTP(rr, req) | |||
| 169 | + | ||||
| 170 | + | assert.Equal(t, tt.wantStatus, rr.Code) | |||
| 171 | + | ||||
| 172 | + | if tt.wantStatus == http.StatusForbidden { | |||
| 173 | + | var errResp dto.ErrorResponse | |||
| 174 | + | require.NoError(t, json.NewDecoder(rr.Body).Decode(&errResp)) | |||
| 175 | + | assert.Equal(t, "Forbidden", errResp.Error) | |||
| 176 | + | } | |||
| 177 | + | }) | |||
| 178 | + | } | |||
| 179 | + | } | |||
M internal/adapters/in/http/middleware/clientip.go +43 -1
| @@ -6,6 +6,36 @@ import ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | + | // cloudflareNets contains Cloudflare's published IPv4 and IPv6 ranges. | |
| 10 | + | // Cf-Connecting-Ip is only trusted when the immediate upstream matches these. | |
| 11 | + | // Source: https://www.cloudflare.com/ips/ | |
| 12 | + | var cloudflareNets = ParseTrustedProxies([]string{ | |
| 13 | + | // IPv4 | |
| 14 | + | "173.245.48.0/20", | |
| 15 | + | "103.21.244.0/22", | |
| 16 | + | "103.22.200.0/22", | |
| 17 | + | "103.31.4.0/22", | |
| 18 | + | "141.101.64.0/18", | |
| 19 | + | "108.162.192.0/18", | |
| 20 | + | "190.93.240.0/20", | |
| 21 | + | "188.114.96.0/20", | |
| 22 | + | "197.234.240.0/22", | |
| 23 | + | "198.41.128.0/17", | |
| 24 | + | "162.158.0.0/15", | |
| 25 | + | "104.16.0.0/13", | |
| 26 | + | "104.24.0.0/14", | |
| 27 | + | "172.64.0.0/13", | |
| 28 | + | "131.0.72.0/22", | |
| 29 | + | // IPv6 | |
| 30 | + | "2400:cb00::/32", | |
| 31 | + | "2606:4700::/32", | |
| 32 | + | "2803:f800::/32", | |
| 33 | + | "2405:b500::/32", | |
| 34 | + | "2405:8100::/32", | |
| 35 | + | "2a06:98c0::/29", | |
| 36 | + | "2c0f:f248::/32", | |
| 37 | + | }) | |
| 38 | + | ||
| 9 | 39 | func normalizeIP(raw string) string { | |
| 10 | 40 | ip := strings.TrimSpace(raw) | |
| 11 | 41 | if ip == "" { | |
| @@ -16,7 +46,7 @@ func normalizeIP(raw string) string { | |||
| 16 | 46 | ip = host | |
| 17 | 47 | } | |
| 18 | 48 | ||
| 19 | - | parsed := net.ParseIP(strings.TrimSpace(ip)) | |
| 49 | + | parsed := net.ParseIP(ip) | |
| 20 | 50 | if parsed == nil { | |
| 21 | 51 | return "" | |
| 22 | 52 | } | |
| @@ -84,6 +114,18 @@ func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string { | |||
| 84 | 114 | ||
| 85 | 115 | // Only honor proxy headers if the request comes from a trusted proxy | |
| 86 | 116 | if IsTrustedProxy(remoteIP, trustedNets) { | |
| 117 | + | // Cf-Connecting-IP is set by Cloudflare to the true client IP. | |
| 118 | + | // Only trust it when the immediate upstream is a known Cloudflare IP, | |
| 119 | + | // not any generic trusted proxy (e.g. local LB/nginx), because a | |
| 120 | + | // non-Cloudflare proxy would blindly forward a spoofed header. | |
| 121 | + | if IsTrustedProxy(remoteIP, cloudflareNets) { | |
| 122 | + | if cfIP := r.Header.Get("Cf-Connecting-Ip"); cfIP != "" { | |
| 123 | + | if ip := normalizeIP(cfIP); ip != "" { | |
| 124 | + | return ip | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 87 | 129 | // Check X-Forwarded-For for proxied requests. | |
| 88 | 130 | // SECURITY: Parse from right to left and return the first untrusted IP. | |
| 89 | 131 | // This prevents spoofing when a trusted proxy appends to an attacker- | |
| @@ -6,6 +6,36 @@ import ( | |||||
| 6 | "strings" | 6 | "strings" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | + | // cloudflareNets contains Cloudflare's published IPv4 and IPv6 ranges. | |||
| 10 | + | // Cf-Connecting-Ip is only trusted when the immediate upstream matches these. | |||
| 11 | + | // Source: https://www.cloudflare.com/ips/ | |||
| 12 | + | var cloudflareNets = ParseTrustedProxies([]string{ | |||
| 13 | + | // IPv4 | |||
| 14 | + | "173.245.48.0/20", | |||
| 15 | + | "103.21.244.0/22", | |||
| 16 | + | "103.22.200.0/22", | |||
| 17 | + | "103.31.4.0/22", | |||
| 18 | + | "141.101.64.0/18", | |||
| 19 | + | "108.162.192.0/18", | |||
| 20 | + | "190.93.240.0/20", | |||
| 21 | + | "188.114.96.0/20", | |||
| 22 | + | "197.234.240.0/22", | |||
| 23 | + | "198.41.128.0/17", | |||
| 24 | + | "162.158.0.0/15", | |||
| 25 | + | "104.16.0.0/13", | |||
| 26 | + | "104.24.0.0/14", | |||
| 27 | + | "172.64.0.0/13", | |||
| 28 | + | "131.0.72.0/22", | |||
| 29 | + | // IPv6 | |||
| 30 | + | "2400:cb00::/32", | |||
| 31 | + | "2606:4700::/32", | |||
| 32 | + | "2803:f800::/32", | |||
| 33 | + | "2405:b500::/32", | |||
| 34 | + | "2405:8100::/32", | |||
| 35 | + | "2a06:98c0::/29", | |||
| 36 | + | "2c0f:f248::/32", | |||
| 37 | + | }) | |||
| 38 | + | ||||
| 9 | func normalizeIP(raw string) string { | 39 | func normalizeIP(raw string) string { | ||
| 10 | ip := strings.TrimSpace(raw) | 40 | ip := strings.TrimSpace(raw) | ||
| 11 | if ip == "" { | 41 | if ip == "" { | ||
| @@ -16,7 +46,7 @@ func normalizeIP(raw string) string { | |||||
| 16 | ip = host | 46 | ip = host | ||
| 17 | } | 47 | } | ||
| 18 | 48 | ||||
| 19 | - | parsed := net.ParseIP(strings.TrimSpace(ip)) | 49 | + | parsed := net.ParseIP(ip) |
| 20 | if parsed == nil { | 50 | if parsed == nil { | ||
| 21 | return "" | 51 | return "" | ||
| 22 | } | 52 | } | ||
| @@ -84,6 +114,18 @@ func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string { | |||||
| 84 | 114 | ||||
| 85 | // Only honor proxy headers if the request comes from a trusted proxy | 115 | // Only honor proxy headers if the request comes from a trusted proxy | ||
| 86 | if IsTrustedProxy(remoteIP, trustedNets) { | 116 | if IsTrustedProxy(remoteIP, trustedNets) { | ||
| 117 | + | // Cf-Connecting-IP is set by Cloudflare to the true client IP. | |||
| 118 | + | // Only trust it when the immediate upstream is a known Cloudflare IP, | |||
| 119 | + | // not any generic trusted proxy (e.g. local LB/nginx), because a | |||
| 120 | + | // non-Cloudflare proxy would blindly forward a spoofed header. | |||
| 121 | + | if IsTrustedProxy(remoteIP, cloudflareNets) { | |||
| 122 | + | if cfIP := r.Header.Get("Cf-Connecting-Ip"); cfIP != "" { | |||
| 123 | + | if ip := normalizeIP(cfIP); ip != "" { | |||
| 124 | + | return ip | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 87 | // Check X-Forwarded-For for proxied requests. | 129 | // Check X-Forwarded-For for proxied requests. | ||
| 88 | // SECURITY: Parse from right to left and return the first untrusted IP. | 130 | // SECURITY: Parse from right to left and return the first untrusted IP. | ||
| 89 | // This prevents spoofing when a trusted proxy appends to an attacker- | 131 | // This prevents spoofing when a trusted proxy appends to an attacker- | ||
M internal/adapters/in/http/middleware/clientip_test.go +28 -0
| @@ -13,12 +13,15 @@ func TestGetClientIP(t *testing.T) { | |||
| 13 | 13 | // Parse trusted proxies for tests | |
| 14 | 14 | trustedNets := ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"}) | |
| 15 | 15 | noTrustedNets := []*net.IPNet{} | |
| 16 | + | // Include a Cloudflare IP in trusted proxies to test Cf-Connecting-Ip | |
| 17 | + | trustedWithCF := ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8", "173.245.48.0/20"}) | |
| 16 | 18 | ||
| 17 | 19 | tests := []struct { | |
| 18 | 20 | name string | |
| 19 | 21 | remoteAddr string | |
| 20 | 22 | xff string | |
| 21 | 23 | xRealIP string | |
| 24 | + | cfIP string | |
| 22 | 25 | trustedNets []*net.IPNet | |
| 23 | 26 | wantIP string | |
| 24 | 27 | }{ | |
| @@ -98,6 +101,28 @@ func TestGetClientIP(t *testing.T) { | |||
| 98 | 101 | trustedNets: trustedNets, | |
| 99 | 102 | wantIP: "203.0.113.50", | |
| 100 | 103 | }, | |
| 104 | + | { | |
| 105 | + | name: "Cf-Connecting-Ip honored from Cloudflare IP", | |
| 106 | + | remoteAddr: "173.245.48.1:12345", | |
| 107 | + | cfIP: "203.0.113.99", | |
| 108 | + | trustedNets: trustedWithCF, | |
| 109 | + | wantIP: "203.0.113.99", | |
| 110 | + | }, | |
| 111 | + | { | |
| 112 | + | name: "Cf-Connecting-Ip ignored from non-Cloudflare trusted proxy", | |
| 113 | + | remoteAddr: "127.0.0.1:12345", | |
| 114 | + | cfIP: "203.0.113.99", | |
| 115 | + | xff: "203.0.113.50", | |
| 116 | + | trustedNets: trustedNets, | |
| 117 | + | wantIP: "203.0.113.50", | |
| 118 | + | }, | |
| 119 | + | { | |
| 120 | + | name: "Cf-Connecting-Ip ignored from untrusted IP", | |
| 121 | + | remoteAddr: "192.168.1.100:12345", | |
| 122 | + | cfIP: "203.0.113.99", | |
| 123 | + | trustedNets: trustedNets, | |
| 124 | + | wantIP: "192.168.1.100", | |
| 125 | + | }, | |
| 101 | 126 | { | |
| 102 | 127 | name: "IPv6 RemoteAddr", | |
| 103 | 128 | remoteAddr: "[::1]:12345", | |
| @@ -116,6 +141,9 @@ func TestGetClientIP(t *testing.T) { | |||
| 116 | 141 | if tt.xRealIP != "" { | |
| 117 | 142 | req.Header.Set("X-Real-IP", tt.xRealIP) | |
| 118 | 143 | } | |
| 144 | + | if tt.cfIP != "" { | |
| 145 | + | req.Header.Set("Cf-Connecting-Ip", tt.cfIP) | |
| 146 | + | } | |
| 119 | 147 | ||
| 120 | 148 | ip := GetClientIP(req, tt.trustedNets) | |
| 121 | 149 | assert.Equal(t, tt.wantIP, ip) | |
| @@ -13,12 +13,15 @@ func TestGetClientIP(t *testing.T) { | |||||
| 13 | // Parse trusted proxies for tests | 13 | // Parse trusted proxies for tests | ||
| 14 | trustedNets := ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"}) | 14 | trustedNets := ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"}) | ||
| 15 | noTrustedNets := []*net.IPNet{} | 15 | noTrustedNets := []*net.IPNet{} | ||
| 16 | + | // Include a Cloudflare IP in trusted proxies to test Cf-Connecting-Ip | |||
| 17 | + | trustedWithCF := ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8", "173.245.48.0/20"}) | |||
| 16 | 18 | ||||
| 17 | tests := []struct { | 19 | tests := []struct { | ||
| 18 | name string | 20 | name string | ||
| 19 | remoteAddr string | 21 | remoteAddr string | ||
| 20 | xff string | 22 | xff string | ||
| 21 | xRealIP string | 23 | xRealIP string | ||
| 24 | + | cfIP string | |||
| 22 | trustedNets []*net.IPNet | 25 | trustedNets []*net.IPNet | ||
| 23 | wantIP string | 26 | wantIP string | ||
| 24 | }{ | 27 | }{ | ||
| @@ -98,6 +101,28 @@ func TestGetClientIP(t *testing.T) { | |||||
| 98 | trustedNets: trustedNets, | 101 | trustedNets: trustedNets, | ||
| 99 | wantIP: "203.0.113.50", | 102 | wantIP: "203.0.113.50", | ||
| 100 | }, | 103 | }, | ||
| 104 | + | { | |||
| 105 | + | name: "Cf-Connecting-Ip honored from Cloudflare IP", | |||
| 106 | + | remoteAddr: "173.245.48.1:12345", | |||
| 107 | + | cfIP: "203.0.113.99", | |||
| 108 | + | trustedNets: trustedWithCF, | |||
| 109 | + | wantIP: "203.0.113.99", | |||
| 110 | + | }, | |||
| 111 | + | { | |||
| 112 | + | name: "Cf-Connecting-Ip ignored from non-Cloudflare trusted proxy", | |||
| 113 | + | remoteAddr: "127.0.0.1:12345", | |||
| 114 | + | cfIP: "203.0.113.99", | |||
| 115 | + | xff: "203.0.113.50", | |||
| 116 | + | trustedNets: trustedNets, | |||
| 117 | + | wantIP: "203.0.113.50", | |||
| 118 | + | }, | |||
| 119 | + | { | |||
| 120 | + | name: "Cf-Connecting-Ip ignored from untrusted IP", | |||
| 121 | + | remoteAddr: "192.168.1.100:12345", | |||
| 122 | + | cfIP: "203.0.113.99", | |||
| 123 | + | trustedNets: trustedNets, | |||
| 124 | + | wantIP: "192.168.1.100", | |||
| 125 | + | }, | |||
| 101 | { | 126 | { | ||
| 102 | name: "IPv6 RemoteAddr", | 127 | name: "IPv6 RemoteAddr", | ||
| 103 | remoteAddr: "[::1]:12345", | 128 | remoteAddr: "[::1]:12345", | ||
| @@ -116,6 +141,9 @@ func TestGetClientIP(t *testing.T) { | |||||
| 116 | if tt.xRealIP != "" { | 141 | if tt.xRealIP != "" { | ||
| 117 | req.Header.Set("X-Real-IP", tt.xRealIP) | 142 | req.Header.Set("X-Real-IP", tt.xRealIP) | ||
| 118 | } | 143 | } | ||
| 144 | + | if tt.cfIP != "" { | |||
| 145 | + | req.Header.Set("Cf-Connecting-Ip", tt.cfIP) | |||
| 146 | + | } | |||
| 119 | 147 | ||||
| 120 | ip := GetClientIP(req, tt.trustedNets) | 148 | ip := GetClientIP(req, tt.trustedNets) | ||
| 121 | assert.Equal(t, tt.wantIP, ip) | 149 | assert.Equal(t, tt.wantIP, ip) | ||
M internal/adapters/in/http/middleware/security.go +28 -31
| @@ -5,37 +5,34 @@ 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 | - | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 11 | - | // Prevent MIME type sniffing | |
| 12 | - | w.Header().Set("X-Content-Type-Options", "nosniff") | |
| 13 | - | ||
| 14 | - | // Prevent clickjacking | |
| 15 | - | w.Header().Set("X-Frame-Options", "DENY") | |
| 16 | - | ||
| 17 | - | // XSS protection (legacy, but still useful for older browsers) | |
| 18 | - | w.Header().Set("X-XSS-Protection", "1; mode=block") | |
| 19 | - | ||
| 20 | - | // Control referrer information | |
| 21 | - | w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") | |
| 22 | - | ||
| 23 | - | // Restrict browser features | |
| 24 | - | w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") | |
| 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). | |
| 9 | + | return SecurityHeadersWithOptions(false)(next) | |
| 10 | + | } | |
| 38 | 11 | ||
| 39 | - | next.ServeHTTP(w, r) | |
| 40 | - | }) | |
| 12 | + | // SecurityHeadersWithOptions returns a middleware that adds standard security headers. | |
| 13 | + | // When forceHSTS is true, the HSTS header is always set regardless of TLS state. | |
| 14 | + | // This is needed when Gordon runs behind a TLS-terminating proxy (e.g. Cloudflare) | |
| 15 | + | // where r.TLS is nil but the client connection is always HTTPS. | |
| 16 | + | func SecurityHeadersWithOptions(forceHSTS bool) func(http.Handler) http.Handler { | |
| 17 | + | return func(next http.Handler) http.Handler { | |
| 18 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 19 | + | w.Header().Set("X-Content-Type-Options", "nosniff") | |
| 20 | + | w.Header().Set("X-Frame-Options", "DENY") | |
| 21 | + | w.Header().Set("X-XSS-Protection", "1; mode=block") | |
| 22 | + | w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") | |
| 23 | + | w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") | |
| 24 | + | ||
| 25 | + | // Only set when TLS is active or force_hsts is configured, | |
| 26 | + | // since HSTS over plain HTTP would lock out clients. | |
| 27 | + | if r.TLS != nil || forceHSTS { | |
| 28 | + | w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") | |
| 29 | + | } | |
| 30 | + | ||
| 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). | |
| 34 | + | ||
| 35 | + | next.ServeHTTP(w, r) | |
| 36 | + | }) | |
| 37 | + | } | |
| 41 | 38 | } | |
| @@ -5,37 +5,34 @@ 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 http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 9 | + | return SecurityHeadersWithOptions(false)(next) |
| 11 | - | // Prevent MIME type sniffing | 10 | + | } |
| 12 | - | w.Header().Set("X-Content-Type-Options", "nosniff") | |||
| 13 | - | ||||
| 14 | - | // Prevent clickjacking | |||
| 15 | - | w.Header().Set("X-Frame-Options", "DENY") | |||
| 16 | - | ||||
| 17 | - | // XSS protection (legacy, but still useful for older browsers) | |||
| 18 | - | w.Header().Set("X-XSS-Protection", "1; mode=block") | |||
| 19 | - | ||||
| 20 | - | // Control referrer information | |||
| 21 | - | w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") | |||
| 22 | - | ||||
| 23 | - | // Restrict browser features | |||
| 24 | - | w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") | |||
| 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 | 11 | ||||
| 39 | - | next.ServeHTTP(w, r) | 12 | + | // SecurityHeadersWithOptions returns a middleware that adds standard security headers. |
| 40 | - | }) | 13 | + | // When forceHSTS is true, the HSTS header is always set regardless of TLS state. |
| 14 | + | // This is needed when Gordon runs behind a TLS-terminating proxy (e.g. Cloudflare) | |||
| 15 | + | // where r.TLS is nil but the client connection is always HTTPS. | |||
| 16 | + | func SecurityHeadersWithOptions(forceHSTS bool) func(http.Handler) http.Handler { | |||
| 17 | + | return func(next http.Handler) http.Handler { | |||
| 18 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 19 | + | w.Header().Set("X-Content-Type-Options", "nosniff") | |||
| 20 | + | w.Header().Set("X-Frame-Options", "DENY") | |||
| 21 | + | w.Header().Set("X-XSS-Protection", "1; mode=block") | |||
| 22 | + | w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") | |||
| 23 | + | w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") | |||
| 24 | + | ||||
| 25 | + | // Only set when TLS is active or force_hsts is configured, | |||
| 26 | + | // since HSTS over plain HTTP would lock out clients. | |||
| 27 | + | if r.TLS != nil || forceHSTS { | |||
| 28 | + | w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") | |||
| 29 | + | } | |||
| 30 | + | ||||
| 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). | |||
| 34 | + | ||||
| 35 | + | next.ServeHTTP(w, r) | |||
| 36 | + | }) | |||
| 37 | + | } | |||
| 41 | } | 38 | } | ||
M internal/adapters/in/http/middleware/security_test.go +16 -0
| @@ -51,6 +51,22 @@ func TestSecurityHeaders_HSTS_WithTLS(t *testing.T) { | |||
| 51 | 51 | assert.Equal(t, "max-age=31536000; includeSubDomains", rec.Header().Get("Strict-Transport-Security")) | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | + | func TestSecurityHeaders_HSTS_ForceWithoutTLS(t *testing.T) { | |
| 55 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 56 | + | w.WriteHeader(http.StatusOK) | |
| 57 | + | }) | |
| 58 | + | ||
| 59 | + | wrapped := SecurityHeadersWithOptions(true)(handler) | |
| 60 | + | ||
| 61 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 62 | + | // r.TLS is nil — no TLS on the connection, but forceHSTS is true | |
| 63 | + | rec := httptest.NewRecorder() | |
| 64 | + | wrapped.ServeHTTP(rec, req) | |
| 65 | + | ||
| 66 | + | assert.Equal(t, "max-age=31536000; includeSubDomains", rec.Header().Get("Strict-Transport-Security"), | |
| 67 | + | "HSTS should be set when forceHSTS is true even without TLS") | |
| 68 | + | } | |
| 69 | + | ||
| 54 | 70 | func TestSecurityHeaders_HSTS_WithForwardedProto_NotTrusted(t *testing.T) { | |
| 55 | 71 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 56 | 72 | w.WriteHeader(http.StatusOK) | |
| @@ -51,6 +51,22 @@ func TestSecurityHeaders_HSTS_WithTLS(t *testing.T) { | |||||
| 51 | assert.Equal(t, "max-age=31536000; includeSubDomains", rec.Header().Get("Strict-Transport-Security")) | 51 | assert.Equal(t, "max-age=31536000; includeSubDomains", rec.Header().Get("Strict-Transport-Security")) | ||
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | + | func TestSecurityHeaders_HSTS_ForceWithoutTLS(t *testing.T) { | |||
| 55 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 56 | + | w.WriteHeader(http.StatusOK) | |||
| 57 | + | }) | |||
| 58 | + | ||||
| 59 | + | wrapped := SecurityHeadersWithOptions(true)(handler) | |||
| 60 | + | ||||
| 61 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |||
| 62 | + | // r.TLS is nil — no TLS on the connection, but forceHSTS is true | |||
| 63 | + | rec := httptest.NewRecorder() | |||
| 64 | + | wrapped.ServeHTTP(rec, req) | |||
| 65 | + | ||||
| 66 | + | assert.Equal(t, "max-age=31536000; includeSubDomains", rec.Header().Get("Strict-Transport-Security"), | |||
| 67 | + | "HSTS should be set when forceHSTS is true even without TLS") | |||
| 68 | + | } | |||
| 69 | + | ||||
| 54 | func TestSecurityHeaders_HSTS_WithForwardedProto_NotTrusted(t *testing.T) { | 70 | func TestSecurityHeaders_HSTS_WithForwardedProto_NotTrusted(t *testing.T) { | ||
| 55 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | 71 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | ||
| 56 | w.WriteHeader(http.StatusOK) | 72 | w.WriteHeader(http.StatusOK) | ||
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 +11 -0
| @@ -25,10 +25,12 @@ import ( | |||
| 25 | 25 | "github.com/docker/docker/api/types/image" | |
| 26 | 26 | "github.com/docker/docker/api/types/network" | |
| 27 | 27 | "github.com/docker/docker/api/types/registry" | |
| 28 | + | "github.com/docker/docker/api/types/strslice" | |
| 28 | 29 | "github.com/docker/docker/api/types/volume" | |
| 29 | 30 | "github.com/docker/docker/client" | |
| 30 | 31 | "github.com/docker/docker/pkg/stdcopy" | |
| 31 | 32 | "github.com/docker/go-connections/nat" | |
| 33 | + | "github.com/docker/go-units" | |
| 32 | 34 | ||
| 33 | 35 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 34 | 36 | "github.com/bnema/gordon/internal/domain" | |
| @@ -164,6 +166,9 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC | |||
| 164 | 166 | resources := container.Resources{ | |
| 165 | 167 | Memory: config.MemoryLimit, | |
| 166 | 168 | NanoCPUs: config.NanoCPUs, | |
| 169 | + | Ulimits: []*units.Ulimit{ | |
| 170 | + | {Name: "nofile", Soft: 65536, Hard: 65536}, | |
| 171 | + | }, | |
| 167 | 172 | } | |
| 168 | 173 | if config.PidsLimit > 0 { | |
| 169 | 174 | resources.PidsLimit = &config.PidsLimit | |
| @@ -174,6 +179,12 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC | |||
| 174 | 179 | Binds: binds, | |
| 175 | 180 | NetworkMode: container.NetworkMode(config.NetworkMode), | |
| 176 | 181 | Resources: resources, | |
| 182 | + | SecurityOpt: []string{"no-new-privileges:true"}, | |
| 183 | + | CapDrop: strslice.StrSlice{"ALL"}, | |
| 184 | + | // Re-add the minimal set of capabilities that standard images need. | |
| 185 | + | // Without these, containers that chown files (e.g. postgres, mysql) | |
| 186 | + | // or bind to ports <1024 (e.g. nginx) would fail to start. | |
| 187 | + | CapAdd: strslice.StrSlice{"CHOWN", "SETUID", "SETGID", "NET_BIND_SERVICE"}, | |
| 177 | 188 | } | |
| 178 | 189 | ||
| 179 | 190 | // Create network configuration for container | |
| @@ -25,10 +25,12 @@ import ( | |||||
| 25 | "github.com/docker/docker/api/types/image" | 25 | "github.com/docker/docker/api/types/image" | ||
| 26 | "github.com/docker/docker/api/types/network" | 26 | "github.com/docker/docker/api/types/network" | ||
| 27 | "github.com/docker/docker/api/types/registry" | 27 | "github.com/docker/docker/api/types/registry" | ||
| 28 | + | "github.com/docker/docker/api/types/strslice" | |||
| 28 | "github.com/docker/docker/api/types/volume" | 29 | "github.com/docker/docker/api/types/volume" | ||
| 29 | "github.com/docker/docker/client" | 30 | "github.com/docker/docker/client" | ||
| 30 | "github.com/docker/docker/pkg/stdcopy" | 31 | "github.com/docker/docker/pkg/stdcopy" | ||
| 31 | "github.com/docker/go-connections/nat" | 32 | "github.com/docker/go-connections/nat" | ||
| 33 | + | "github.com/docker/go-units" | |||
| 32 | 34 | ||||
| 33 | "github.com/bnema/gordon/internal/boundaries/out" | 35 | "github.com/bnema/gordon/internal/boundaries/out" | ||
| 34 | "github.com/bnema/gordon/internal/domain" | 36 | "github.com/bnema/gordon/internal/domain" | ||
| @@ -164,6 +166,9 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC | |||||
| 164 | resources := container.Resources{ | 166 | resources := container.Resources{ | ||
| 165 | Memory: config.MemoryLimit, | 167 | Memory: config.MemoryLimit, | ||
| 166 | NanoCPUs: config.NanoCPUs, | 168 | NanoCPUs: config.NanoCPUs, | ||
| 169 | + | Ulimits: []*units.Ulimit{ | |||
| 170 | + | {Name: "nofile", Soft: 65536, Hard: 65536}, | |||
| 171 | + | }, | |||
| 167 | } | 172 | } | ||
| 168 | if config.PidsLimit > 0 { | 173 | if config.PidsLimit > 0 { | ||
| 169 | resources.PidsLimit = &config.PidsLimit | 174 | resources.PidsLimit = &config.PidsLimit | ||
| @@ -174,6 +179,12 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC | |||||
| 174 | Binds: binds, | 179 | Binds: binds, | ||
| 175 | NetworkMode: container.NetworkMode(config.NetworkMode), | 180 | NetworkMode: container.NetworkMode(config.NetworkMode), | ||
| 176 | Resources: resources, | 181 | Resources: resources, | ||
| 182 | + | SecurityOpt: []string{"no-new-privileges:true"}, | |||
| 183 | + | CapDrop: strslice.StrSlice{"ALL"}, | |||
| 184 | + | // Re-add the minimal set of capabilities that standard images need. | |||
| 185 | + | // Without these, containers that chown files (e.g. postgres, mysql) | |||
| 186 | + | // or bind to ports <1024 (e.g. nginx) would fail to start. | |||
| 187 | + | CapAdd: strslice.StrSlice{"CHOWN", "SETUID", "SETGID", "NET_BIND_SERVICE"}, | |||
| 177 | } | 188 | } | ||
| 178 | 189 | ||||
| 179 | // Create network configuration for container | 190 | // Create network configuration for container | ||
M internal/app/run.go +91 -31
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "crypto/ecdsa" | |
| 7 | 7 | "crypto/elliptic" | |
| 8 | 8 | "crypto/rand" | |
| 9 | + | "crypto/tls" | |
| 9 | 10 | "crypto/x509" | |
| 10 | 11 | "crypto/x509/pkix" | |
| 11 | 12 | "encoding/hex" | |
| @@ -100,6 +101,9 @@ type Config struct { | |||
| 100 | 101 | MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit | |
| 101 | 102 | MaxConcurrentConns int `mapstructure:"max_concurrent_connections"` | |
| 102 | 103 | RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"` | |
| 104 | + | ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"` | |
| 105 | + | RegistryListenAddr string `mapstructure:"registry_listen_address"` | |
| 106 | + | ForceHSTS bool `mapstructure:"force_hsts"` | |
| 103 | 107 | } `mapstructure:"server"` | |
| 104 | 108 | ||
| 105 | 109 | Logging struct { | |
| @@ -282,10 +286,10 @@ func Run(ctx context.Context, configPath string) error { | |||
| 282 | 286 | defer svc.eventBus.Stop() | |
| 283 | 287 | ||
| 284 | 288 | // Create HTTP handlers | |
| 285 | - | registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log) | |
| 289 | + | registryHandler, proxyHandler, proxyCIDRMiddleware := createHTTPHandlers(svc, cfg, log) | |
| 286 | 290 | ||
| 287 | 291 | // Start servers, wait for listeners to bind, then sync/auto-start containers. | |
| 288 | - | return runServers(ctx, v, cfg, registryHandler, proxyHandler, svc, cleanupHandlers, log) | |
| 292 | + | return runServers(ctx, v, cfg, registryHandler, proxyHandler, proxyCIDRMiddleware, svc, cleanupHandlers, log) | |
| 289 | 293 | } | |
| 290 | 294 | ||
| 291 | 295 | func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error { | |
| @@ -1665,7 +1669,9 @@ func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler { | |||
| 1665 | 1669 | } | |
| 1666 | 1670 | ||
| 1667 | 1671 | // createHTTPHandlers creates HTTP handlers with middleware. | |
| 1668 | - | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) { | |
| 1672 | + | // The returned proxyCIDRMiddleware is non-nil when proxy_allowed_ips is configured; | |
| 1673 | + | // callers must also apply it to the HTTP-to-HTTPS redirect listener. | |
| 1674 | + | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler, func(http.Handler) http.Handler) { | |
| 1669 | 1675 | // Parse trusted proxies once for all middleware chains. | |
| 1670 | 1676 | // This ensures consistent IP extraction across logging, rate limiting, and auth. | |
| 1671 | 1677 | trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies) | |
| @@ -1690,7 +1696,15 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1690 | 1696 | proxyMiddlewares := []func(http.Handler) http.Handler{ | |
| 1691 | 1697 | middleware.PanicRecovery(log), | |
| 1692 | 1698 | middleware.RequestLogger(log, trustedNets), | |
| 1693 | - | middleware.SecurityHeaders, | |
| 1699 | + | middleware.SecurityHeadersWithOptions(cfg.Server.ForceHSTS), | |
| 1700 | + | } | |
| 1701 | + | ||
| 1702 | + | // SECURITY: When proxy_allowed_ips is set, only connections from those CIDRs | |
| 1703 | + | // can reach the proxy. This prevents direct-to-origin attacks that bypass | |
| 1704 | + | // CDN protections (e.g. Cloudflare WAF, DDoS mitigation). | |
| 1705 | + | proxyCIDRMiddleware := buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log) | |
| 1706 | + | if proxyCIDRMiddleware != nil { | |
| 1707 | + | proxyMiddlewares = append(proxyMiddlewares, proxyCIDRMiddleware) | |
| 1694 | 1708 | } | |
| 1695 | 1709 | ||
| 1696 | 1710 | proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log) | |
| @@ -1703,7 +1717,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1703 | 1717 | ||
| 1704 | 1718 | registerAdminRoutes(registryMux, svc, cfg, trustedNets, log) | |
| 1705 | 1719 | ||
| 1706 | - | return registryMux, proxyMux | |
| 1720 | + | return registryMux, proxyMux, proxyCIDRMiddleware | |
| 1707 | 1721 | } | |
| 1708 | 1722 | ||
| 1709 | 1723 | func buildRegistryHandlerWithMiddleware( | |
| @@ -1736,40 +1750,73 @@ func buildRegistryHandlerWithMiddleware( | |||
| 1736 | 1750 | return registryWithOtel, cidrAllowlistMiddleware, rateLimitMiddleware | |
| 1737 | 1751 | } | |
| 1738 | 1752 | ||
| 1739 | - | func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 1740 | - | if len(cfg.Server.RegistryAllowedIPs) == 0 { | |
| 1741 | - | return nil | |
| 1753 | + | // parseCIDRAllowlist parses a list of IPs/CIDRs, logs warnings for invalid entries, | |
| 1754 | + | // and returns the parsed nets. label is used in log messages (e.g. "registry_allowed_ips"). | |
| 1755 | + | func parseCIDRAllowlist(ips []string, label string, log zerowrap.Logger) ([]*net.IPNet, bool) { | |
| 1756 | + | if len(ips) == 0 { | |
| 1757 | + | return nil, false | |
| 1742 | 1758 | } | |
| 1743 | 1759 | ||
| 1744 | - | allowedNets := middleware.ParseTrustedProxies(cfg.Server.RegistryAllowedIPs) | |
| 1745 | - | if len(allowedNets) != len(cfg.Server.RegistryAllowedIPs) { | |
| 1746 | - | for _, entry := range cfg.Server.RegistryAllowedIPs { | |
| 1760 | + | allowedNets := middleware.ParseTrustedProxies(ips) | |
| 1761 | + | if len(allowedNets) != len(ips) { | |
| 1762 | + | for _, entry := range ips { | |
| 1747 | 1763 | if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 { | |
| 1748 | - | log.Warn().Str("entry", entry).Msg("ignoring invalid registry_allowed_ips entry") | |
| 1764 | + | log.Warn().Str("entry", entry).Msgf("ignoring invalid %s entry", label) | |
| 1749 | 1765 | } | |
| 1750 | 1766 | } | |
| 1751 | 1767 | } | |
| 1752 | 1768 | ||
| 1753 | 1769 | if len(allowedNets) == 0 { | |
| 1754 | 1770 | log.Error(). | |
| 1755 | - | Strs("registry_allowed_ips", cfg.Server.RegistryAllowedIPs). | |
| 1756 | - | Msg("registry_allowed_ips is set but no valid entries were parsed; registry will deny all traffic (fail-closed)") | |
| 1757 | - | return func(next http.Handler) http.Handler { | |
| 1758 | - | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 1759 | - | log.Warn(). | |
| 1760 | - | Str(zerowrap.FieldPath, r.URL.Path). | |
| 1761 | - | Str(zerowrap.FieldClientIP, middleware.GetClientIP(r, trustedNets)). | |
| 1762 | - | Msg("registry access denied due to invalid registry_allowed_ips configuration") | |
| 1763 | - | w.Header().Set("Content-Type", "application/json") | |
| 1764 | - | w.WriteHeader(http.StatusForbidden) | |
| 1765 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"}) | |
| 1766 | - | }) | |
| 1767 | - | } | |
| 1771 | + | Strs(label, ips). | |
| 1772 | + | Msgf("%s is set but no valid entries were parsed; will deny all traffic (fail-closed)", label) | |
| 1773 | + | return nil, true // allInvalid | |
| 1768 | 1774 | } | |
| 1769 | 1775 | ||
| 1776 | + | return allowedNets, false | |
| 1777 | + | } | |
| 1778 | + | ||
| 1779 | + | // denyAllHandler returns a middleware that rejects every request with 403 Forbidden. | |
| 1780 | + | func denyAllHandler(label string, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 1781 | + | return func(next http.Handler) http.Handler { | |
| 1782 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 1783 | + | log.Warn(). | |
| 1784 | + | Str(zerowrap.FieldClientIP, middleware.GetClientIP(r, trustedNets)). | |
| 1785 | + | Msgf("access denied due to invalid %s configuration", label) | |
| 1786 | + | w.Header().Set("Content-Type", "application/json") | |
| 1787 | + | w.WriteHeader(http.StatusForbidden) | |
| 1788 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"}) | |
| 1789 | + | }) | |
| 1790 | + | } | |
| 1791 | + | } | |
| 1792 | + | ||
| 1793 | + | func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 1794 | + | allowedNets, allInvalid := parseCIDRAllowlist(cfg.Server.RegistryAllowedIPs, "registry_allowed_ips", log) | |
| 1795 | + | if allInvalid { | |
| 1796 | + | return denyAllHandler("registry_allowed_ips", trustedNets, log) | |
| 1797 | + | } | |
| 1798 | + | if allowedNets == nil { | |
| 1799 | + | return nil | |
| 1800 | + | } | |
| 1770 | 1801 | return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log) | |
| 1771 | 1802 | } | |
| 1772 | 1803 | ||
| 1804 | + | func buildProxyCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 1805 | + | allowedNets, allInvalid := parseCIDRAllowlist(cfg.Server.ProxyAllowedIPs, "proxy_allowed_ips", log) | |
| 1806 | + | if allInvalid { | |
| 1807 | + | return denyAllHandler("proxy_allowed_ips", trustedNets, log) | |
| 1808 | + | } | |
| 1809 | + | if allowedNets == nil { | |
| 1810 | + | return nil | |
| 1811 | + | } | |
| 1812 | + | ||
| 1813 | + | log.Info(). | |
| 1814 | + | Strs("proxy_allowed_ips", cfg.Server.ProxyAllowedIPs). | |
| 1815 | + | Msg("proxy origin IP allowlist enabled") | |
| 1816 | + | ||
| 1817 | + | return middleware.ProxyCIDRAllowlist(allowedNets, log) | |
| 1818 | + | } | |
| 1819 | + | ||
| 1773 | 1820 | func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 1774 | 1821 | if cfg.API.RateLimit.Enabled { | |
| 1775 | 1822 | globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log) | |
| @@ -1902,7 +1949,7 @@ func registerAdminRoutes(registryMux *http.ServeMux, svc *services, cfg Config, | |||
| 1902 | 1949 | // - SIGUSR2: Triggers manual deploy for a specific route | |
| 1903 | 1950 | // The deferred signal.Stop calls ensure signal handlers are properly | |
| 1904 | 1951 | // cleaned up before program exit, preventing signal handler leaks. | |
| 1905 | - | func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler, proxyHandler http.Handler, svc *services, cleanupHandlers func(), log zerowrap.Logger) error { | |
| 1952 | + | func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler, proxyHandler http.Handler, proxyCIDRMiddleware func(http.Handler) http.Handler, svc *services, cleanupHandlers func(), log zerowrap.Logger) error { | |
| 1906 | 1953 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | |
| 1907 | 1954 | defer cancel() | |
| 1908 | 1955 | ||
| @@ -1920,9 +1967,10 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||
| 1920 | 1967 | ||
| 1921 | 1968 | errChan := make(chan error, 3) | |
| 1922 | 1969 | ||
| 1923 | - | registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", nil, errChan, log) | |
| 1970 | + | registryAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, strconv.Itoa(cfg.Server.RegistryPort)) | |
| 1971 | + | registrySrv, registryReady := startServer(registryAddr, registryHandler, "registry", nil, errChan, log) | |
| 1924 | 1972 | ||
| 1925 | - | proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, proxyHandler, errChan, log) | |
| 1973 | + | proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, proxyHandler, proxyCIDRMiddleware, errChan, log) | |
| 1926 | 1974 | if err != nil { | |
| 1927 | 1975 | return err | |
| 1928 | 1976 | } | |
| @@ -2208,7 +2256,9 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc * | |||
| 2208 | 2256 | // startProxyServers sets up proxy server(s) depending on TLS configuration. | |
| 2209 | 2257 | // With TLS enabled, the HTTP port becomes a redirect-only server and a separate TLS server handles traffic. | |
| 2210 | 2258 | // Without TLS, a single server handles both HTTP/1.1 and cleartext HTTP/2. | |
| 2211 | - | func startProxyServers(cfg Config, proxyHandler http.Handler, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) { | |
| 2259 | + | // proxyCIDRMiddleware, when non-nil, is also applied to the HTTP redirect listener | |
| 2260 | + | // so that proxy_allowed_ips cannot be bypassed via the plain-HTTP port. | |
| 2261 | + | func startProxyServers(cfg Config, proxyHandler http.Handler, proxyCIDRMiddleware func(http.Handler) http.Handler, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) { | |
| 2212 | 2262 | if !cfg.Server.TLSEnabled { | |
| 2213 | 2263 | var proxyProtos http.Protocols | |
| 2214 | 2264 | proxyProtos.SetHTTP1(true) | |
| @@ -2238,7 +2288,11 @@ func startProxyServers(cfg Config, proxyHandler http.Handler, errChan chan<- err | |||
| 2238 | 2288 | http.Redirect(w, r, target, http.StatusPermanentRedirect) | |
| 2239 | 2289 | }) | |
| 2240 | 2290 | ||
| 2241 | - | proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), redirectHandler, "proxy-redirect", nil, errChan, log) | |
| 2291 | + | var redirectWithMiddleware http.Handler = redirectHandler | |
| 2292 | + | if proxyCIDRMiddleware != nil { | |
| 2293 | + | redirectWithMiddleware = proxyCIDRMiddleware(redirectHandler) | |
| 2294 | + | } | |
| 2295 | + | proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), redirectWithMiddleware, "proxy-redirect", nil, errChan, log) | |
| 2242 | 2296 | tlsSrv, tlsReady := startTLSServer( | |
| 2243 | 2297 | fmt.Sprintf(":%d", cfg.Server.TLSPort), | |
| 2244 | 2298 | proxyHandler, | |
| @@ -2301,6 +2355,9 @@ func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile s | |||
| 2301 | 2355 | WriteTimeout: 5 * time.Minute, | |
| 2302 | 2356 | IdleTimeout: 120 * time.Second, | |
| 2303 | 2357 | MaxHeaderBytes: 1 << 20, | |
| 2358 | + | TLSConfig: &tls.Config{ | |
| 2359 | + | MinVersion: tls.VersionTLS12, | |
| 2360 | + | }, | |
| 2304 | 2361 | } | |
| 2305 | 2362 | ||
| 2306 | 2363 | go func() { | |
| @@ -2605,7 +2662,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 2605 | 2662 | v.SetDefault("api.rate_limit.per_ip_rps", 50) | |
| 2606 | 2663 | v.SetDefault("api.rate_limit.burst", 100) | |
| 2607 | 2664 | v.SetDefault("auto_route.enabled", false) | |
| 2608 | - | v.SetDefault("network_isolation.enabled", false) | |
| 2665 | + | v.SetDefault("network_isolation.enabled", true) | |
| 2609 | 2666 | v.SetDefault("network_isolation.network_prefix", "gordon") | |
| 2610 | 2667 | v.SetDefault("volumes.auto_create", true) | |
| 2611 | 2668 | v.SetDefault("volumes.prefix", "gordon") | |
| @@ -2631,6 +2688,9 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 2631 | 2688 | ||
| 2632 | 2689 | v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit | |
| 2633 | 2690 | v.SetDefault("server.registry_allowed_ips", []string{}) | |
| 2691 | + | v.SetDefault("server.proxy_allowed_ips", []string{}) | |
| 2692 | + | v.SetDefault("server.registry_listen_address", "") | |
| 2693 | + | v.SetDefault("server.force_hsts", false) | |
| 2634 | 2694 | v.SetDefault("deploy.readiness_delay", "5s") | |
| 2635 | 2695 | v.SetDefault("deploy.readiness_mode", "auto") | |
| 2636 | 2696 | v.SetDefault("deploy.health_timeout", "90s") | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "crypto/ecdsa" | 6 | "crypto/ecdsa" | ||
| 7 | "crypto/elliptic" | 7 | "crypto/elliptic" | ||
| 8 | "crypto/rand" | 8 | "crypto/rand" | ||
| 9 | + | "crypto/tls" | |||
| 9 | "crypto/x509" | 10 | "crypto/x509" | ||
| 10 | "crypto/x509/pkix" | 11 | "crypto/x509/pkix" | ||
| 11 | "encoding/hex" | 12 | "encoding/hex" | ||
| @@ -100,6 +101,9 @@ type Config struct { | |||||
| 100 | MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit | 101 | MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit | ||
| 101 | MaxConcurrentConns int `mapstructure:"max_concurrent_connections"` | 102 | MaxConcurrentConns int `mapstructure:"max_concurrent_connections"` | ||
| 102 | RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"` | 103 | RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"` | ||
| 104 | + | ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"` | |||
| 105 | + | RegistryListenAddr string `mapstructure:"registry_listen_address"` | |||
| 106 | + | ForceHSTS bool `mapstructure:"force_hsts"` | |||
| 103 | } `mapstructure:"server"` | 107 | } `mapstructure:"server"` | ||
| 104 | 108 | ||||
| 105 | Logging struct { | 109 | Logging struct { | ||
| @@ -282,10 +286,10 @@ func Run(ctx context.Context, configPath string) error { | |||||
| 282 | defer svc.eventBus.Stop() | 286 | defer svc.eventBus.Stop() | ||
| 283 | 287 | ||||
| 284 | // Create HTTP handlers | 288 | // Create HTTP handlers | ||
| 285 | - | registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log) | 289 | + | registryHandler, proxyHandler, proxyCIDRMiddleware := createHTTPHandlers(svc, cfg, log) |
| 286 | 290 | ||||
| 287 | // Start servers, wait for listeners to bind, then sync/auto-start containers. | 291 | // Start servers, wait for listeners to bind, then sync/auto-start containers. | ||
| 288 | - | return runServers(ctx, v, cfg, registryHandler, proxyHandler, svc, cleanupHandlers, log) | 292 | + | return runServers(ctx, v, cfg, registryHandler, proxyHandler, proxyCIDRMiddleware, svc, cleanupHandlers, log) |
| 289 | } | 293 | } | ||
| 290 | 294 | ||||
| 291 | func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error { | 295 | func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error { | ||
| @@ -1665,7 +1669,9 @@ func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler { | |||||
| 1665 | } | 1669 | } | ||
| 1666 | 1670 | ||||
| 1667 | // createHTTPHandlers creates HTTP handlers with middleware. | 1671 | // createHTTPHandlers creates HTTP handlers with middleware. | ||
| 1668 | - | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) { | 1672 | + | // The returned proxyCIDRMiddleware is non-nil when proxy_allowed_ips is configured; |
| 1673 | + | // callers must also apply it to the HTTP-to-HTTPS redirect listener. | |||
| 1674 | + | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler, func(http.Handler) http.Handler) { | |||
| 1669 | // Parse trusted proxies once for all middleware chains. | 1675 | // Parse trusted proxies once for all middleware chains. | ||
| 1670 | // This ensures consistent IP extraction across logging, rate limiting, and auth. | 1676 | // This ensures consistent IP extraction across logging, rate limiting, and auth. | ||
| 1671 | trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies) | 1677 | trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies) | ||
| @@ -1690,7 +1696,15 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 1690 | proxyMiddlewares := []func(http.Handler) http.Handler{ | 1696 | proxyMiddlewares := []func(http.Handler) http.Handler{ | ||
| 1691 | middleware.PanicRecovery(log), | 1697 | middleware.PanicRecovery(log), | ||
| 1692 | middleware.RequestLogger(log, trustedNets), | 1698 | middleware.RequestLogger(log, trustedNets), | ||
| 1693 | - | middleware.SecurityHeaders, | 1699 | + | middleware.SecurityHeadersWithOptions(cfg.Server.ForceHSTS), |
| 1700 | + | } | |||
| 1701 | + | ||||
| 1702 | + | // SECURITY: When proxy_allowed_ips is set, only connections from those CIDRs | |||
| 1703 | + | // can reach the proxy. This prevents direct-to-origin attacks that bypass | |||
| 1704 | + | // CDN protections (e.g. Cloudflare WAF, DDoS mitigation). | |||
| 1705 | + | proxyCIDRMiddleware := buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log) | |||
| 1706 | + | if proxyCIDRMiddleware != nil { | |||
| 1707 | + | proxyMiddlewares = append(proxyMiddlewares, proxyCIDRMiddleware) | |||
| 1694 | } | 1708 | } | ||
| 1695 | 1709 | ||||
| 1696 | proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log) | 1710 | proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log) | ||
| @@ -1703,7 +1717,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 1703 | 1717 | ||||
| 1704 | registerAdminRoutes(registryMux, svc, cfg, trustedNets, log) | 1718 | registerAdminRoutes(registryMux, svc, cfg, trustedNets, log) | ||
| 1705 | 1719 | ||||
| 1706 | - | return registryMux, proxyMux | 1720 | + | return registryMux, proxyMux, proxyCIDRMiddleware |
| 1707 | } | 1721 | } | ||
| 1708 | 1722 | ||||
| 1709 | func buildRegistryHandlerWithMiddleware( | 1723 | func buildRegistryHandlerWithMiddleware( | ||
| @@ -1736,40 +1750,73 @@ func buildRegistryHandlerWithMiddleware( | |||||
| 1736 | return registryWithOtel, cidrAllowlistMiddleware, rateLimitMiddleware | 1750 | return registryWithOtel, cidrAllowlistMiddleware, rateLimitMiddleware | ||
| 1737 | } | 1751 | } | ||
| 1738 | 1752 | ||||
| 1739 | - | func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | 1753 | + | // parseCIDRAllowlist parses a list of IPs/CIDRs, logs warnings for invalid entries, |
| 1740 | - | if len(cfg.Server.RegistryAllowedIPs) == 0 { | 1754 | + | // and returns the parsed nets. label is used in log messages (e.g. "registry_allowed_ips"). |
| 1741 | - | return nil | 1755 | + | func parseCIDRAllowlist(ips []string, label string, log zerowrap.Logger) ([]*net.IPNet, bool) { |
| 1756 | + | if len(ips) == 0 { | |||
| 1757 | + | return nil, false | |||
| 1742 | } | 1758 | } | ||
| 1743 | 1759 | ||||
| 1744 | - | allowedNets := middleware.ParseTrustedProxies(cfg.Server.RegistryAllowedIPs) | 1760 | + | allowedNets := middleware.ParseTrustedProxies(ips) |
| 1745 | - | if len(allowedNets) != len(cfg.Server.RegistryAllowedIPs) { | 1761 | + | if len(allowedNets) != len(ips) { |
| 1746 | - | for _, entry := range cfg.Server.RegistryAllowedIPs { | 1762 | + | for _, entry := range ips { |
| 1747 | if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 { | 1763 | if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 { | ||
| 1748 | - | log.Warn().Str("entry", entry).Msg("ignoring invalid registry_allowed_ips entry") | 1764 | + | log.Warn().Str("entry", entry).Msgf("ignoring invalid %s entry", label) |
| 1749 | } | 1765 | } | ||
| 1750 | } | 1766 | } | ||
| 1751 | } | 1767 | } | ||
| 1752 | 1768 | ||||
| 1753 | if len(allowedNets) == 0 { | 1769 | if len(allowedNets) == 0 { | ||
| 1754 | log.Error(). | 1770 | log.Error(). | ||
| 1755 | - | Strs("registry_allowed_ips", cfg.Server.RegistryAllowedIPs). | 1771 | + | Strs(label, ips). |
| 1756 | - | Msg("registry_allowed_ips is set but no valid entries were parsed; registry will deny all traffic (fail-closed)") | 1772 | + | Msgf("%s is set but no valid entries were parsed; will deny all traffic (fail-closed)", label) |
| 1757 | - | return func(next http.Handler) http.Handler { | 1773 | + | return nil, true // allInvalid |
| 1758 | - | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 1759 | - | log.Warn(). | |||
| 1760 | - | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 1761 | - | Str(zerowrap.FieldClientIP, middleware.GetClientIP(r, trustedNets)). | |||
| 1762 | - | Msg("registry access denied due to invalid registry_allowed_ips configuration") | |||
| 1763 | - | w.Header().Set("Content-Type", "application/json") | |||
| 1764 | - | w.WriteHeader(http.StatusForbidden) | |||
| 1765 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"}) | |||
| 1766 | - | }) | |||
| 1767 | - | } | |||
| 1768 | } | 1774 | } | ||
| 1769 | 1775 | ||||
| 1776 | + | return allowedNets, false | |||
| 1777 | + | } | |||
| 1778 | + | ||||
| 1779 | + | // denyAllHandler returns a middleware that rejects every request with 403 Forbidden. | |||
| 1780 | + | func denyAllHandler(label string, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |||
| 1781 | + | return func(next http.Handler) http.Handler { | |||
| 1782 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 1783 | + | log.Warn(). | |||
| 1784 | + | Str(zerowrap.FieldClientIP, middleware.GetClientIP(r, trustedNets)). | |||
| 1785 | + | Msgf("access denied due to invalid %s configuration", label) | |||
| 1786 | + | w.Header().Set("Content-Type", "application/json") | |||
| 1787 | + | w.WriteHeader(http.StatusForbidden) | |||
| 1788 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"}) | |||
| 1789 | + | }) | |||
| 1790 | + | } | |||
| 1791 | + | } | |||
| 1792 | + | ||||
| 1793 | + | func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |||
| 1794 | + | allowedNets, allInvalid := parseCIDRAllowlist(cfg.Server.RegistryAllowedIPs, "registry_allowed_ips", log) | |||
| 1795 | + | if allInvalid { | |||
| 1796 | + | return denyAllHandler("registry_allowed_ips", trustedNets, log) | |||
| 1797 | + | } | |||
| 1798 | + | if allowedNets == nil { | |||
| 1799 | + | return nil | |||
| 1800 | + | } | |||
| 1770 | return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log) | 1801 | return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log) | ||
| 1771 | } | 1802 | } | ||
| 1772 | 1803 | ||||
| 1804 | + | func buildProxyCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |||
| 1805 | + | allowedNets, allInvalid := parseCIDRAllowlist(cfg.Server.ProxyAllowedIPs, "proxy_allowed_ips", log) | |||
| 1806 | + | if allInvalid { | |||
| 1807 | + | return denyAllHandler("proxy_allowed_ips", trustedNets, log) | |||
| 1808 | + | } | |||
| 1809 | + | if allowedNets == nil { | |||
| 1810 | + | return nil | |||
| 1811 | + | } | |||
| 1812 | + | ||||
| 1813 | + | log.Info(). | |||
| 1814 | + | Strs("proxy_allowed_ips", cfg.Server.ProxyAllowedIPs). | |||
| 1815 | + | Msg("proxy origin IP allowlist enabled") | |||
| 1816 | + | ||||
| 1817 | + | return middleware.ProxyCIDRAllowlist(allowedNets, log) | |||
| 1818 | + | } | |||
| 1819 | + | ||||
| 1773 | func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http.Handler) http.Handler { | 1820 | func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http.Handler) http.Handler { | ||
| 1774 | if cfg.API.RateLimit.Enabled { | 1821 | if cfg.API.RateLimit.Enabled { | ||
| 1775 | globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log) | 1822 | globalLimiter := ratelimit.NewMemoryStore(cfg.API.RateLimit.GlobalRPS, cfg.API.RateLimit.Burst, log) | ||
| @@ -1902,7 +1949,7 @@ func registerAdminRoutes(registryMux *http.ServeMux, svc *services, cfg Config, | |||||
| 1902 | // - SIGUSR2: Triggers manual deploy for a specific route | 1949 | // - SIGUSR2: Triggers manual deploy for a specific route | ||
| 1903 | // The deferred signal.Stop calls ensure signal handlers are properly | 1950 | // The deferred signal.Stop calls ensure signal handlers are properly | ||
| 1904 | // cleaned up before program exit, preventing signal handler leaks. | 1951 | // cleaned up before program exit, preventing signal handler leaks. | ||
| 1905 | - | func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler, proxyHandler http.Handler, svc *services, cleanupHandlers func(), log zerowrap.Logger) error { | 1952 | + | func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler, proxyHandler http.Handler, proxyCIDRMiddleware func(http.Handler) http.Handler, svc *services, cleanupHandlers func(), log zerowrap.Logger) error { |
| 1906 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | 1953 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | ||
| 1907 | defer cancel() | 1954 | defer cancel() | ||
| 1908 | 1955 | ||||
| @@ -1920,9 +1967,10 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||||
| 1920 | 1967 | ||||
| 1921 | errChan := make(chan error, 3) | 1968 | errChan := make(chan error, 3) | ||
| 1922 | 1969 | ||||
| 1923 | - | registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", nil, errChan, log) | 1970 | + | registryAddr := net.JoinHostPort(cfg.Server.RegistryListenAddr, strconv.Itoa(cfg.Server.RegistryPort)) |
| 1971 | + | registrySrv, registryReady := startServer(registryAddr, registryHandler, "registry", nil, errChan, log) | |||
| 1924 | 1972 | ||||
| 1925 | - | proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, proxyHandler, errChan, log) | 1973 | + | proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, proxyHandler, proxyCIDRMiddleware, errChan, log) |
| 1926 | if err != nil { | 1974 | if err != nil { | ||
| 1927 | return err | 1975 | return err | ||
| 1928 | } | 1976 | } | ||
| @@ -2208,7 +2256,9 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc * | |||||
| 2208 | // startProxyServers sets up proxy server(s) depending on TLS configuration. | 2256 | // startProxyServers sets up proxy server(s) depending on TLS configuration. | ||
| 2209 | // With TLS enabled, the HTTP port becomes a redirect-only server and a separate TLS server handles traffic. | 2257 | // With TLS enabled, the HTTP port becomes a redirect-only server and a separate TLS server handles traffic. | ||
| 2210 | // Without TLS, a single server handles both HTTP/1.1 and cleartext HTTP/2. | 2258 | // Without TLS, a single server handles both HTTP/1.1 and cleartext HTTP/2. | ||
| 2211 | - | func startProxyServers(cfg Config, proxyHandler http.Handler, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) { | 2259 | + | // proxyCIDRMiddleware, when non-nil, is also applied to the HTTP redirect listener |
| 2260 | + | // so that proxy_allowed_ips cannot be bypassed via the plain-HTTP port. | |||
| 2261 | + | func startProxyServers(cfg Config, proxyHandler http.Handler, proxyCIDRMiddleware func(http.Handler) http.Handler, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) { | |||
| 2212 | if !cfg.Server.TLSEnabled { | 2262 | if !cfg.Server.TLSEnabled { | ||
| 2213 | var proxyProtos http.Protocols | 2263 | var proxyProtos http.Protocols | ||
| 2214 | proxyProtos.SetHTTP1(true) | 2264 | proxyProtos.SetHTTP1(true) | ||
| @@ -2238,7 +2288,11 @@ func startProxyServers(cfg Config, proxyHandler http.Handler, errChan chan<- err | |||||
| 2238 | http.Redirect(w, r, target, http.StatusPermanentRedirect) | 2288 | http.Redirect(w, r, target, http.StatusPermanentRedirect) | ||
| 2239 | }) | 2289 | }) | ||
| 2240 | 2290 | ||||
| 2241 | - | proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), redirectHandler, "proxy-redirect", nil, errChan, log) | 2291 | + | var redirectWithMiddleware http.Handler = redirectHandler |
| 2292 | + | if proxyCIDRMiddleware != nil { | |||
| 2293 | + | redirectWithMiddleware = proxyCIDRMiddleware(redirectHandler) | |||
| 2294 | + | } | |||
| 2295 | + | proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), redirectWithMiddleware, "proxy-redirect", nil, errChan, log) | |||
| 2242 | tlsSrv, tlsReady := startTLSServer( | 2296 | tlsSrv, tlsReady := startTLSServer( | ||
| 2243 | fmt.Sprintf(":%d", cfg.Server.TLSPort), | 2297 | fmt.Sprintf(":%d", cfg.Server.TLSPort), | ||
| 2244 | proxyHandler, | 2298 | proxyHandler, | ||
| @@ -2301,6 +2355,9 @@ func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile s | |||||
| 2301 | WriteTimeout: 5 * time.Minute, | 2355 | WriteTimeout: 5 * time.Minute, | ||
| 2302 | IdleTimeout: 120 * time.Second, | 2356 | IdleTimeout: 120 * time.Second, | ||
| 2303 | MaxHeaderBytes: 1 << 20, | 2357 | MaxHeaderBytes: 1 << 20, | ||
| 2358 | + | TLSConfig: &tls.Config{ | |||
| 2359 | + | MinVersion: tls.VersionTLS12, | |||
| 2360 | + | }, | |||
| 2304 | } | 2361 | } | ||
| 2305 | 2362 | ||||
| 2306 | go func() { | 2363 | go func() { | ||
| @@ -2605,7 +2662,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 2605 | v.SetDefault("api.rate_limit.per_ip_rps", 50) | 2662 | v.SetDefault("api.rate_limit.per_ip_rps", 50) | ||
| 2606 | v.SetDefault("api.rate_limit.burst", 100) | 2663 | v.SetDefault("api.rate_limit.burst", 100) | ||
| 2607 | v.SetDefault("auto_route.enabled", false) | 2664 | v.SetDefault("auto_route.enabled", false) | ||
| 2608 | - | v.SetDefault("network_isolation.enabled", false) | 2665 | + | v.SetDefault("network_isolation.enabled", true) |
| 2609 | v.SetDefault("network_isolation.network_prefix", "gordon") | 2666 | v.SetDefault("network_isolation.network_prefix", "gordon") | ||
| 2610 | v.SetDefault("volumes.auto_create", true) | 2667 | v.SetDefault("volumes.auto_create", true) | ||
| 2611 | v.SetDefault("volumes.prefix", "gordon") | 2668 | v.SetDefault("volumes.prefix", "gordon") | ||
| @@ -2631,6 +2688,9 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 2631 | 2688 | ||||
| 2632 | v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit | 2689 | v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit | ||
| 2633 | v.SetDefault("server.registry_allowed_ips", []string{}) | 2690 | v.SetDefault("server.registry_allowed_ips", []string{}) | ||
| 2691 | + | v.SetDefault("server.proxy_allowed_ips", []string{}) | |||
| 2692 | + | v.SetDefault("server.registry_listen_address", "") | |||
| 2693 | + | v.SetDefault("server.force_hsts", false) | |||
| 2634 | v.SetDefault("deploy.readiness_delay", "5s") | 2694 | v.SetDefault("deploy.readiness_delay", "5s") | ||
| 2635 | v.SetDefault("deploy.readiness_mode", "auto") | 2695 | v.SetDefault("deploy.readiness_mode", "auto") | ||
| 2636 | v.SetDefault("deploy.health_timeout", "90s") | 2696 | v.SetDefault("deploy.health_timeout", "90s") | ||
M internal/app/run_http_handlers_test.go +2 -2
| @@ -35,7 +35,7 @@ func TestCreateHTTPHandlers_LocalMode_DisablesAdminRoutes(t *testing.T) { | |||
| 35 | 35 | cfg.Auth.Enabled = false | |
| 36 | 36 | ||
| 37 | 37 | svc := &services{adminHandler: &adminhttp.Handler{}} | |
| 38 | - | registryHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 38 | + | registryHandler, _, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 39 | 39 | ||
| 40 | 40 | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 41 | 41 | req.RemoteAddr = "192.0.2.10:12345" | |
| @@ -54,7 +54,7 @@ func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T) | |||
| 54 | 54 | cfg := Config{} | |
| 55 | 55 | cfg.Auth.Enabled = false | |
| 56 | 56 | ||
| 57 | - | registryHandler, _ := createHTTPHandlers(&services{}, cfg, zerowrap.Default()) | |
| 57 | + | registryHandler, _, _ := createHTTPHandlers(&services{}, cfg, zerowrap.Default()) | |
| 58 | 58 | ||
| 59 | 59 | req := httptest.NewRequest(http.MethodGet, "/v2/test/manifests/latest", nil) | |
| 60 | 60 | req.RemoteAddr = "192.0.2.20:12345" | |
| @@ -35,7 +35,7 @@ func TestCreateHTTPHandlers_LocalMode_DisablesAdminRoutes(t *testing.T) { | |||||
| 35 | cfg.Auth.Enabled = false | 35 | cfg.Auth.Enabled = false | ||
| 36 | 36 | ||||
| 37 | svc := &services{adminHandler: &adminhttp.Handler{}} | 37 | svc := &services{adminHandler: &adminhttp.Handler{}} | ||
| 38 | - | registryHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | 38 | + | registryHandler, _, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) |
| 39 | 39 | ||||
| 40 | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | 40 | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | ||
| 41 | req.RemoteAddr = "192.0.2.10:12345" | 41 | req.RemoteAddr = "192.0.2.10:12345" | ||
| @@ -54,7 +54,7 @@ func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T) | |||||
| 54 | cfg := Config{} | 54 | cfg := Config{} | ||
| 55 | cfg.Auth.Enabled = false | 55 | cfg.Auth.Enabled = false | ||
| 56 | 56 | ||||
| 57 | - | registryHandler, _ := createHTTPHandlers(&services{}, cfg, zerowrap.Default()) | 57 | + | registryHandler, _, _ := createHTTPHandlers(&services{}, cfg, zerowrap.Default()) |
| 58 | 58 | ||||
| 59 | req := httptest.NewRequest(http.MethodGet, "/v2/test/manifests/latest", nil) | 59 | req := httptest.NewRequest(http.MethodGet, "/v2/test/manifests/latest", nil) | ||
| 60 | req.RemoteAddr = "192.0.2.20:12345" | 60 | req.RemoteAddr = "192.0.2.20:12345" | ||