security: harden production deployment defaults
- Add container security defaults: CapDrop ALL, no-new-privileges, nofile ulimit (65536) - Add proxy_allowed_ips config for origin IP validation (e.g. Cloudflare edge IPs) to prevent direct-to-origin attacks - Add Cf-Connecting-IP header support in GetClientIP for accurate client IP resolution behind Cloudflare - Default network_isolation.enabled to true for container-to-container isolation - Add registry_listen_address config to bind registry to loopback only - Add force_hsts config for HSTS behind TLS-terminating proxies - Add explicit TLS 1.2 minimum version on TLS server - Set FlushInterval: -1 on ReverseProxy for SSE/streaming support - Update docs for all new config options and changed defaults
- commit
- bcbd4d90ecbc277b35e3a50b4807bdd7a0709eab
- author
- bnema · · 2026-03-27 22:11 UTC
- parent
- 851aace
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +212 -38
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 +1 -1
| @@ -14,7 +14,7 @@ network_prefix = "gordon" | |||
| 14 | 14 | ||
| 15 | 15 | | Option | Type | Default | Description | | |
| 16 | 16 | |--------|------|---------|-------------| | |
| 17 | - | | `enabled` | bool | `false` | Enable per-app network isolation | | |
| 17 | + | | `enabled` | bool | `true` | Enable per-app network isolation | | |
| 18 | 18 | | `network_prefix` | string | `"gordon"` | Prefix for created networks | | |
| 19 | 19 | ||
| 20 | 20 | ## How It Works | |
| @@ -14,7 +14,7 @@ network_prefix = "gordon" | |||||
| 14 | 14 | ||||
| 15 | | Option | Type | Default | Description | | 15 | | Option | Type | Default | Description | | ||
| 16 | |--------|------|---------|-------------| | 16 | |--------|------|---------|-------------| | ||
| 17 | - | | `enabled` | bool | `false` | Enable per-app network isolation | | 17 | + | | `enabled` | bool | `true` | Enable per-app network isolation | |
| 18 | | `network_prefix` | string | `"gordon"` | Prefix for created networks | | 18 | | `network_prefix` | string | `"gordon"` | Prefix for created networks | | ||
| 19 | 19 | ||||
| 20 | ## How It Works | 20 | ## 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 +41 -0
| @@ -49,3 +49,44 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L | |||
| 49 | 49 | }) | |
| 50 | 50 | } | |
| 51 | 51 | } | |
| 52 | + | ||
| 53 | + | // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges. | |
| 54 | + | // This is used to ensure only trusted sources (e.g. Cloudflare edge IPs) can reach the | |
| 55 | + | // proxy server, preventing direct-to-origin attacks that bypass CDN protections. | |
| 56 | + | // An empty allowedNets slice is a no-op (all traffic passes through). | |
| 57 | + | func ProxyCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 58 | + | return func(next http.Handler) http.Handler { | |
| 59 | + | if len(allowedNets) == 0 { | |
| 60 | + | return next | |
| 61 | + | } | |
| 62 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 63 | + | // For proxy allowlisting we check the direct connection IP (RemoteAddr), | |
| 64 | + | // not the client IP from headers, because we're validating the network | |
| 65 | + | // peer (e.g. Cloudflare edge), not the end-user. | |
| 66 | + | remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) | |
| 67 | + | if err != nil { | |
| 68 | + | remoteIP = r.RemoteAddr | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | if IsTrustedProxy(remoteIP, localhostNets) { | |
| 72 | + | next.ServeHTTP(w, r) | |
| 73 | + | return | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | if IsTrustedProxy(remoteIP, allowedNets) { | |
| 77 | + | next.ServeHTTP(w, r) | |
| 78 | + | return | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | log.Warn(). | |
| 82 | + | Str(zerowrap.FieldLayer, "adapter"). | |
| 83 | + | Str(zerowrap.FieldAdapter, "http"). | |
| 84 | + | Str(zerowrap.FieldMethod, r.Method). | |
| 85 | + | Str("host", r.Host). | |
| 86 | + | Str(zerowrap.FieldClientIP, remoteIP). | |
| 87 | + | Msg("proxy access denied by origin IP allowlist") | |
| 88 | + | ||
| 89 | + | http.Error(w, "Forbidden", http.StatusForbidden) | |
| 90 | + | }) | |
| 91 | + | } | |
| 92 | + | } | |
| @@ -49,3 +49,44 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L | |||||
| 49 | }) | 49 | }) | ||
| 50 | } | 50 | } | ||
| 51 | } | 51 | } | ||
| 52 | + | ||||
| 53 | + | // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges. | |||
| 54 | + | // This is used to ensure only trusted sources (e.g. Cloudflare edge IPs) can reach the | |||
| 55 | + | // proxy server, preventing direct-to-origin attacks that bypass CDN protections. | |||
| 56 | + | // An empty allowedNets slice is a no-op (all traffic passes through). | |||
| 57 | + | func ProxyCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |||
| 58 | + | return func(next http.Handler) http.Handler { | |||
| 59 | + | if len(allowedNets) == 0 { | |||
| 60 | + | return next | |||
| 61 | + | } | |||
| 62 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 63 | + | // For proxy allowlisting we check the direct connection IP (RemoteAddr), | |||
| 64 | + | // not the client IP from headers, because we're validating the network | |||
| 65 | + | // peer (e.g. Cloudflare edge), not the end-user. | |||
| 66 | + | remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) | |||
| 67 | + | if err != nil { | |||
| 68 | + | remoteIP = r.RemoteAddr | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | if IsTrustedProxy(remoteIP, localhostNets) { | |||
| 72 | + | next.ServeHTTP(w, r) | |||
| 73 | + | return | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | if IsTrustedProxy(remoteIP, allowedNets) { | |||
| 77 | + | next.ServeHTTP(w, r) | |||
| 78 | + | return | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | log.Warn(). | |||
| 82 | + | Str(zerowrap.FieldLayer, "adapter"). | |||
| 83 | + | Str(zerowrap.FieldAdapter, "http"). | |||
| 84 | + | Str(zerowrap.FieldMethod, r.Method). | |||
| 85 | + | Str("host", r.Host). | |||
| 86 | + | Str(zerowrap.FieldClientIP, remoteIP). | |||
| 87 | + | Msg("proxy access denied by origin IP allowlist") | |||
| 88 | + | ||||
| 89 | + | http.Error(w, "Forbidden", http.StatusForbidden) | |||
| 90 | + | }) | |||
| 91 | + | } | |||
| 92 | + | } | |||
M internal/adapters/in/http/middleware/clientip.go +9 -0
| @@ -84,6 +84,15 @@ func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string { | |||
| 84 | 84 | ||
| 85 | 85 | // Only honor proxy headers if the request comes from a trusted proxy | |
| 86 | 86 | if IsTrustedProxy(remoteIP, trustedNets) { | |
| 87 | + | // Cf-Connecting-IP is set by Cloudflare to the true client IP. | |
| 88 | + | // It is a single IP (not a chain) and cannot be spoofed through Cloudflare. | |
| 89 | + | // Check it first as it is the most reliable source behind Cloudflare. | |
| 90 | + | if cfIP := r.Header.Get("Cf-Connecting-Ip"); cfIP != "" { | |
| 91 | + | if ip := normalizeIP(cfIP); ip != "" { | |
| 92 | + | return ip | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 87 | 96 | // Check X-Forwarded-For for proxied requests. | |
| 88 | 97 | // SECURITY: Parse from right to left and return the first untrusted IP. | |
| 89 | 98 | // This prevents spoofing when a trusted proxy appends to an attacker- | |
| @@ -84,6 +84,15 @@ func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string { | |||||
| 84 | 84 | ||||
| 85 | // Only honor proxy headers if the request comes from a trusted proxy | 85 | // Only honor proxy headers if the request comes from a trusted proxy | ||
| 86 | if IsTrustedProxy(remoteIP, trustedNets) { | 86 | if IsTrustedProxy(remoteIP, trustedNets) { | ||
| 87 | + | // Cf-Connecting-IP is set by Cloudflare to the true client IP. | |||
| 88 | + | // It is a single IP (not a chain) and cannot be spoofed through Cloudflare. | |||
| 89 | + | // Check it first as it is the most reliable source behind Cloudflare. | |||
| 90 | + | if cfIP := r.Header.Get("Cf-Connecting-Ip"); cfIP != "" { | |||
| 91 | + | if ip := normalizeIP(cfIP); ip != "" { | |||
| 92 | + | return ip | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 87 | // Check X-Forwarded-For for proxied requests. | 96 | // Check X-Forwarded-For for proxied requests. | ||
| 88 | // SECURITY: Parse from right to left and return the first untrusted IP. | 97 | // SECURITY: Parse from right to left and return the first untrusted IP. | ||
| 89 | // This prevents spoofing when a trusted proxy appends to an attacker- | 98 | // This prevents spoofing when a trusted proxy appends to an attacker- | ||
M internal/adapters/in/http/middleware/security.go +39 -30
| @@ -7,35 +7,44 @@ import ( | |||
| 7 | 7 | // SecurityHeaders middleware adds standard security headers to HTTP responses. | |
| 8 | 8 | // This provides defense-in-depth against various web attacks. | |
| 9 | 9 | 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). | |
| 10 | + | return SecurityHeadersWithOptions(false)(next) | |
| 11 | + | } | |
| 38 | 12 | ||
| 39 | - | next.ServeHTTP(w, r) | |
| 40 | - | }) | |
| 13 | + | // SecurityHeadersWithOptions returns a middleware that adds standard security headers. | |
| 14 | + | // When forceHSTS is true, the HSTS header is always set regardless of TLS state. | |
| 15 | + | // This is needed when Gordon runs behind a TLS-terminating proxy (e.g. Cloudflare) | |
| 16 | + | // where r.TLS is nil but the client connection is always HTTPS. | |
| 17 | + | func SecurityHeadersWithOptions(forceHSTS bool) func(http.Handler) http.Handler { | |
| 18 | + | return func(next http.Handler) http.Handler { | |
| 19 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 20 | + | // Prevent MIME type sniffing | |
| 21 | + | w.Header().Set("X-Content-Type-Options", "nosniff") | |
| 22 | + | ||
| 23 | + | // Prevent clickjacking | |
| 24 | + | w.Header().Set("X-Frame-Options", "DENY") | |
| 25 | + | ||
| 26 | + | // XSS protection (legacy, but still useful for older browsers) | |
| 27 | + | w.Header().Set("X-XSS-Protection", "1; mode=block") | |
| 28 | + | ||
| 29 | + | // Control referrer information | |
| 30 | + | w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") | |
| 31 | + | ||
| 32 | + | // Restrict browser features | |
| 33 | + | w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") | |
| 34 | + | ||
| 35 | + | // HSTS: Enforce HTTPS-only for browsers. | |
| 36 | + | // Set when the request arrived over TLS, or when force_hsts is enabled | |
| 37 | + | // (for deployments behind a TLS-terminating proxy like Cloudflare). | |
| 38 | + | if r.TLS != nil || forceHSTS { | |
| 39 | + | w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // NOTE: CSP is intentionally NOT set here. Gordon proxies arbitrary web | |
| 43 | + | // apps, so a blanket "default-src 'none'" would break all proxied sites. | |
| 44 | + | // Instead, CSP is set directly on proxy-generated error responses in the | |
| 45 | + | // proxy error handlers (service.go). | |
| 46 | + | ||
| 47 | + | next.ServeHTTP(w, r) | |
| 48 | + | }) | |
| 49 | + | } | |
| 41 | 50 | } | |
| @@ -7,35 +7,44 @@ import ( | |||||
| 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. | 8 | // This provides defense-in-depth against various web attacks. | ||
| 9 | func SecurityHeaders(next http.Handler) http.Handler { | 9 | func SecurityHeaders(next http.Handler) http.Handler { | ||
| 10 | - | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 10 | + | return SecurityHeadersWithOptions(false)(next) |
| 11 | - | // Prevent MIME type sniffing | 11 | + | } |
| 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 | 12 | ||||
| 39 | - | next.ServeHTTP(w, r) | 13 | + | // SecurityHeadersWithOptions returns a middleware that adds standard security headers. |
| 40 | - | }) | 14 | + | // When forceHSTS is true, the HSTS header is always set regardless of TLS state. |
| 15 | + | // This is needed when Gordon runs behind a TLS-terminating proxy (e.g. Cloudflare) | |||
| 16 | + | // where r.TLS is nil but the client connection is always HTTPS. | |||
| 17 | + | func SecurityHeadersWithOptions(forceHSTS bool) func(http.Handler) http.Handler { | |||
| 18 | + | return func(next http.Handler) http.Handler { | |||
| 19 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 20 | + | // Prevent MIME type sniffing | |||
| 21 | + | w.Header().Set("X-Content-Type-Options", "nosniff") | |||
| 22 | + | ||||
| 23 | + | // Prevent clickjacking | |||
| 24 | + | w.Header().Set("X-Frame-Options", "DENY") | |||
| 25 | + | ||||
| 26 | + | // XSS protection (legacy, but still useful for older browsers) | |||
| 27 | + | w.Header().Set("X-XSS-Protection", "1; mode=block") | |||
| 28 | + | ||||
| 29 | + | // Control referrer information | |||
| 30 | + | w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") | |||
| 31 | + | ||||
| 32 | + | // Restrict browser features | |||
| 33 | + | w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") | |||
| 34 | + | ||||
| 35 | + | // HSTS: Enforce HTTPS-only for browsers. | |||
| 36 | + | // Set when the request arrived over TLS, or when force_hsts is enabled | |||
| 37 | + | // (for deployments behind a TLS-terminating proxy like Cloudflare). | |||
| 38 | + | if r.TLS != nil || forceHSTS { | |||
| 39 | + | w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // NOTE: CSP is intentionally NOT set here. Gordon proxies arbitrary web | |||
| 43 | + | // apps, so a blanket "default-src 'none'" would break all proxied sites. | |||
| 44 | + | // Instead, CSP is set directly on proxy-generated error responses in the | |||
| 45 | + | // proxy error handlers (service.go). | |||
| 46 | + | ||||
| 47 | + | next.ServeHTTP(w, r) | |||
| 48 | + | }) | |||
| 49 | + | } | |||
| 41 | } | 50 | } | ||
M internal/adapters/in/http/proxy/forwarder.go +3 -0
| @@ -204,6 +204,9 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou | |||
| 204 | 204 | Transport: transport, | |
| 205 | 205 | ErrorHandler: errorHandler, | |
| 206 | 206 | ModifyResponse: modifyResp, | |
| 207 | + | // Flush immediately so SSE, chunked streaming, and long-polling responses | |
| 208 | + | // are forwarded to the client without buffering delay. | |
| 209 | + | FlushInterval: -1, | |
| 207 | 210 | } | |
| 208 | 211 | } | |
| 209 | 212 | ||
| @@ -204,6 +204,9 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou | |||||
| 204 | Transport: transport, | 204 | Transport: transport, | ||
| 205 | ErrorHandler: errorHandler, | 205 | ErrorHandler: errorHandler, | ||
| 206 | ModifyResponse: modifyResp, | 206 | ModifyResponse: modifyResp, | ||
| 207 | + | // Flush immediately so SSE, chunked streaming, and long-polling responses | |||
| 208 | + | // are forwarded to the client without buffering delay. | |||
| 209 | + | FlushInterval: -1, | |||
| 207 | } | 210 | } | ||
| 208 | } | 211 | } | ||
| 209 | 212 | ||||
M internal/adapters/out/docker/runtime.go +7 -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 | + | units "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,8 @@ 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"}, | |
| 177 | 184 | } | |
| 178 | 185 | ||
| 179 | 186 | // 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 | + | units "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,8 @@ 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"}, | |||
| 177 | } | 184 | } | ||
| 178 | 185 | ||||
| 179 | // Create network configuration for container | 186 | // Create network configuration for container | ||
M internal/app/run.go +57 -3
| @@ -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 { | |
| @@ -1690,7 +1694,14 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1690 | 1694 | proxyMiddlewares := []func(http.Handler) http.Handler{ | |
| 1691 | 1695 | middleware.PanicRecovery(log), | |
| 1692 | 1696 | middleware.RequestLogger(log, trustedNets), | |
| 1693 | - | middleware.SecurityHeaders, | |
| 1697 | + | middleware.SecurityHeadersWithOptions(cfg.Server.ForceHSTS), | |
| 1698 | + | } | |
| 1699 | + | ||
| 1700 | + | // SECURITY: When proxy_allowed_ips is set, only connections from those CIDRs | |
| 1701 | + | // can reach the proxy. This prevents direct-to-origin attacks that bypass | |
| 1702 | + | // CDN protections (e.g. Cloudflare WAF, DDoS mitigation). | |
| 1703 | + | if proxyCIDRMiddleware := buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log); proxyCIDRMiddleware != nil { | |
| 1704 | + | proxyMiddlewares = append(proxyMiddlewares, proxyCIDRMiddleware) | |
| 1694 | 1705 | } | |
| 1695 | 1706 | ||
| 1696 | 1707 | proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log) | |
| @@ -1770,6 +1781,42 @@ func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, | |||
| 1770 | 1781 | return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log) | |
| 1771 | 1782 | } | |
| 1772 | 1783 | ||
| 1784 | + | func buildProxyCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 1785 | + | if len(cfg.Server.ProxyAllowedIPs) == 0 { | |
| 1786 | + | return nil | |
| 1787 | + | } | |
| 1788 | + | ||
| 1789 | + | allowedNets := middleware.ParseTrustedProxies(cfg.Server.ProxyAllowedIPs) | |
| 1790 | + | if len(allowedNets) != len(cfg.Server.ProxyAllowedIPs) { | |
| 1791 | + | for _, entry := range cfg.Server.ProxyAllowedIPs { | |
| 1792 | + | if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 { | |
| 1793 | + | log.Warn().Str("entry", entry).Msg("ignoring invalid proxy_allowed_ips entry") | |
| 1794 | + | } | |
| 1795 | + | } | |
| 1796 | + | } | |
| 1797 | + | ||
| 1798 | + | if len(allowedNets) == 0 { | |
| 1799 | + | log.Error(). | |
| 1800 | + | Strs("proxy_allowed_ips", cfg.Server.ProxyAllowedIPs). | |
| 1801 | + | Msg("proxy_allowed_ips is set but no valid entries were parsed; proxy will deny all traffic (fail-closed)") | |
| 1802 | + | return func(next http.Handler) http.Handler { | |
| 1803 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 1804 | + | log.Warn(). | |
| 1805 | + | Str("host", r.Host). | |
| 1806 | + | Str(zerowrap.FieldClientIP, middleware.GetClientIP(r, trustedNets)). | |
| 1807 | + | Msg("proxy access denied due to invalid proxy_allowed_ips configuration") | |
| 1808 | + | http.Error(w, "Forbidden", http.StatusForbidden) | |
| 1809 | + | }) | |
| 1810 | + | } | |
| 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, trustedNets, 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) | |
| @@ -1920,7 +1967,8 @@ 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 := fmt.Sprintf("%s:%d", cfg.Server.RegistryListenAddr, cfg.Server.RegistryPort) | |
| 1971 | + | registrySrv, registryReady := startServer(registryAddr, registryHandler, "registry", nil, errChan, log) | |
| 1924 | 1972 | ||
| 1925 | 1973 | proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, proxyHandler, errChan, log) | |
| 1926 | 1974 | if err != nil { | |
| @@ -2301,6 +2349,9 @@ func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile s | |||
| 2301 | 2349 | WriteTimeout: 5 * time.Minute, | |
| 2302 | 2350 | IdleTimeout: 120 * time.Second, | |
| 2303 | 2351 | MaxHeaderBytes: 1 << 20, | |
| 2352 | + | TLSConfig: &tls.Config{ | |
| 2353 | + | MinVersion: tls.VersionTLS12, | |
| 2354 | + | }, | |
| 2304 | 2355 | } | |
| 2305 | 2356 | ||
| 2306 | 2357 | go func() { | |
| @@ -2605,7 +2656,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 2605 | 2656 | v.SetDefault("api.rate_limit.per_ip_rps", 50) | |
| 2606 | 2657 | v.SetDefault("api.rate_limit.burst", 100) | |
| 2607 | 2658 | v.SetDefault("auto_route.enabled", false) | |
| 2608 | - | v.SetDefault("network_isolation.enabled", false) | |
| 2659 | + | v.SetDefault("network_isolation.enabled", true) | |
| 2609 | 2660 | v.SetDefault("network_isolation.network_prefix", "gordon") | |
| 2610 | 2661 | v.SetDefault("volumes.auto_create", true) | |
| 2611 | 2662 | v.SetDefault("volumes.prefix", "gordon") | |
| @@ -2631,6 +2682,9 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 2631 | 2682 | ||
| 2632 | 2683 | v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit | |
| 2633 | 2684 | v.SetDefault("server.registry_allowed_ips", []string{}) | |
| 2685 | + | v.SetDefault("server.proxy_allowed_ips", []string{}) | |
| 2686 | + | v.SetDefault("server.registry_listen_address", "") | |
| 2687 | + | v.SetDefault("server.force_hsts", false) | |
| 2634 | 2688 | v.SetDefault("deploy.readiness_delay", "5s") | |
| 2635 | 2689 | v.SetDefault("deploy.readiness_mode", "auto") | |
| 2636 | 2690 | 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 { | ||
| @@ -1690,7 +1694,14 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 1690 | proxyMiddlewares := []func(http.Handler) http.Handler{ | 1694 | proxyMiddlewares := []func(http.Handler) http.Handler{ | ||
| 1691 | middleware.PanicRecovery(log), | 1695 | middleware.PanicRecovery(log), | ||
| 1692 | middleware.RequestLogger(log, trustedNets), | 1696 | middleware.RequestLogger(log, trustedNets), | ||
| 1693 | - | middleware.SecurityHeaders, | 1697 | + | middleware.SecurityHeadersWithOptions(cfg.Server.ForceHSTS), |
| 1698 | + | } | |||
| 1699 | + | ||||
| 1700 | + | // SECURITY: When proxy_allowed_ips is set, only connections from those CIDRs | |||
| 1701 | + | // can reach the proxy. This prevents direct-to-origin attacks that bypass | |||
| 1702 | + | // CDN protections (e.g. Cloudflare WAF, DDoS mitigation). | |||
| 1703 | + | if proxyCIDRMiddleware := buildProxyCIDRAllowlistMiddleware(cfg, trustedNets, log); proxyCIDRMiddleware != nil { | |||
| 1704 | + | proxyMiddlewares = append(proxyMiddlewares, proxyCIDRMiddleware) | |||
| 1694 | } | 1705 | } | ||
| 1695 | 1706 | ||||
| 1696 | proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log) | 1707 | proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log) | ||
| @@ -1770,6 +1781,42 @@ func buildRegistryCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, | |||||
| 1770 | return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log) | 1781 | return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log) | ||
| 1771 | } | 1782 | } | ||
| 1772 | 1783 | ||||
| 1784 | + | func buildProxyCIDRAllowlistMiddleware(cfg Config, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |||
| 1785 | + | if len(cfg.Server.ProxyAllowedIPs) == 0 { | |||
| 1786 | + | return nil | |||
| 1787 | + | } | |||
| 1788 | + | ||||
| 1789 | + | allowedNets := middleware.ParseTrustedProxies(cfg.Server.ProxyAllowedIPs) | |||
| 1790 | + | if len(allowedNets) != len(cfg.Server.ProxyAllowedIPs) { | |||
| 1791 | + | for _, entry := range cfg.Server.ProxyAllowedIPs { | |||
| 1792 | + | if nets := middleware.ParseTrustedProxies([]string{entry}); len(nets) == 0 { | |||
| 1793 | + | log.Warn().Str("entry", entry).Msg("ignoring invalid proxy_allowed_ips entry") | |||
| 1794 | + | } | |||
| 1795 | + | } | |||
| 1796 | + | } | |||
| 1797 | + | ||||
| 1798 | + | if len(allowedNets) == 0 { | |||
| 1799 | + | log.Error(). | |||
| 1800 | + | Strs("proxy_allowed_ips", cfg.Server.ProxyAllowedIPs). | |||
| 1801 | + | Msg("proxy_allowed_ips is set but no valid entries were parsed; proxy will deny all traffic (fail-closed)") | |||
| 1802 | + | return func(next http.Handler) http.Handler { | |||
| 1803 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 1804 | + | log.Warn(). | |||
| 1805 | + | Str("host", r.Host). | |||
| 1806 | + | Str(zerowrap.FieldClientIP, middleware.GetClientIP(r, trustedNets)). | |||
| 1807 | + | Msg("proxy access denied due to invalid proxy_allowed_ips configuration") | |||
| 1808 | + | http.Error(w, "Forbidden", http.StatusForbidden) | |||
| 1809 | + | }) | |||
| 1810 | + | } | |||
| 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, trustedNets, 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) | ||
| @@ -1920,7 +1967,8 @@ 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 := fmt.Sprintf("%s:%d", cfg.Server.RegistryListenAddr, 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, errChan, log) | ||
| 1926 | if err != nil { | 1974 | if err != nil { | ||
| @@ -2301,6 +2349,9 @@ func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile s | |||||
| 2301 | WriteTimeout: 5 * time.Minute, | 2349 | WriteTimeout: 5 * time.Minute, | ||
| 2302 | IdleTimeout: 120 * time.Second, | 2350 | IdleTimeout: 120 * time.Second, | ||
| 2303 | MaxHeaderBytes: 1 << 20, | 2351 | MaxHeaderBytes: 1 << 20, | ||
| 2352 | + | TLSConfig: &tls.Config{ | |||
| 2353 | + | MinVersion: tls.VersionTLS12, | |||
| 2354 | + | }, | |||
| 2304 | } | 2355 | } | ||
| 2305 | 2356 | ||||
| 2306 | go func() { | 2357 | go func() { | ||
| @@ -2605,7 +2656,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 2605 | v.SetDefault("api.rate_limit.per_ip_rps", 50) | 2656 | v.SetDefault("api.rate_limit.per_ip_rps", 50) | ||
| 2606 | v.SetDefault("api.rate_limit.burst", 100) | 2657 | v.SetDefault("api.rate_limit.burst", 100) | ||
| 2607 | v.SetDefault("auto_route.enabled", false) | 2658 | v.SetDefault("auto_route.enabled", false) | ||
| 2608 | - | v.SetDefault("network_isolation.enabled", false) | 2659 | + | v.SetDefault("network_isolation.enabled", true) |
| 2609 | v.SetDefault("network_isolation.network_prefix", "gordon") | 2660 | v.SetDefault("network_isolation.network_prefix", "gordon") | ||
| 2610 | v.SetDefault("volumes.auto_create", true) | 2661 | v.SetDefault("volumes.auto_create", true) | ||
| 2611 | v.SetDefault("volumes.prefix", "gordon") | 2662 | v.SetDefault("volumes.prefix", "gordon") | ||
| @@ -2631,6 +2682,9 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 2631 | 2682 | ||||
| 2632 | v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit | 2683 | v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit | ||
| 2633 | v.SetDefault("server.registry_allowed_ips", []string{}) | 2684 | v.SetDefault("server.registry_allowed_ips", []string{}) | ||
| 2685 | + | v.SetDefault("server.proxy_allowed_ips", []string{}) | |||
| 2686 | + | v.SetDefault("server.registry_listen_address", "") | |||
| 2687 | + | v.SetDefault("server.force_hsts", false) | |||
| 2634 | v.SetDefault("deploy.readiness_delay", "5s") | 2688 | v.SetDefault("deploy.readiness_delay", "5s") | ||
| 2635 | v.SetDefault("deploy.readiness_mode", "auto") | 2689 | v.SetDefault("deploy.readiness_mode", "auto") | ||
| 2636 | v.SetDefault("deploy.health_timeout", "90s") | 2690 | v.SetDefault("deploy.health_timeout", "90s") | ||