detached 3ee801c

Merge pull request #151 from bnema/security/hardening-production

security: harden production deployment defaults
commit
3ee801c691ce3969fe750eb052ff3e5eee7a5c24
author
bnema · · 2026-03-28 05:44 UTC
committer
GitHub · · 2026-03-28 05:44 UTC
merge of
851aace + c5f57f6 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.30.0tag: v2.30.0-pre7
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).
77
88 ## Requirements
99
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`).
1111
1212 The CLI will warn you if you try to add an attachment without network isolation enabled.
1313
@@ -7,7 +7,7 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
7 7
8 ## Requirements8 ## 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
197197 | `volumes.auto_create` | `true` |
198198 | `volumes.prefix` | `"gordon"` |
199199 | `volumes.preserve` | `true` |
200-| `network_isolation.enabled` | `false` |
200+| `network_isolation.enabled` | `true` |
201201 | `auto_route.enabled` | `false` |
202202 | `backups.enabled` | `false` |
203203 | `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
1010 network_prefix = "gordon"
1111 ```
1212
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+
1323 ## Options
1424
1525 | Option | Type | Default | Description |
1626 |--------|------|---------|-------------|
17-| `enabled` | bool | `false` | Enable per-app network isolation |
27+| `enabled` | bool | `true` | Enable per-app network isolation (changed from `false`) |
1828 | `network_prefix` | string | `"gordon"` | Prefix for created networks |
1929
2030 ## 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 ## Options23 ## 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 Works30 ## How It Works
M docs/config/reference.md +8 -2
@@ -19,6 +19,9 @@ gordon_domain = "" # Required: Gordon domain (registry
1919 data_dir = "~/.gordon" # Data directory (varies by install type)
2020 max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk
2121 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)
2225
2326 # =============================================================================
2427 # AUTHENTICATION (required - Gordon won't start without credentials configured)
@@ -101,7 +104,7 @@ enabled = false # Create routes from image labels a
101104 # NETWORK ISOLATION
102105 # =============================================================================
103106 [network_isolation]
104-enabled = false # Enable per-app Docker networks
107+enabled = true # Enable per-app Docker networks
105108 network_prefix = "gordon" # Prefix for created networks
106109
107110 # =============================================================================
@@ -177,6 +180,9 @@ keep_last = 3 # Keep N newest tags per repository
177180 | `server.data_dir` | `~/.gordon` | Data directory |
178181 | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk |
179182 | `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) |
180186 | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) |
181187 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
182188 | `auth.token_expiry` | `"720h"` | 30 days |
@@ -211,7 +217,7 @@ keep_last = 3 # Keep N newest tags per repository
211217 | `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop |
212218 | `deploy.drain_delay` | `"2s"` | Delay before stopping previous container after cache invalidation |
213219 | `auto_route.enabled` | `false` | Auto-route disabled |
214-| `network_isolation.enabled` | `false` | Isolation disabled |
220+| `network_isolation.enabled` | `true` | Network isolation enabled |
215221 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |
216222 | `volumes.auto_create` | `true` | Auto-create volumes |
217223 | `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 chunk20 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 ISOLATION104 # NETWORK ISOLATION
102 # =============================================================================105 # =============================================================================
103 [network_isolation]106 [network_isolation]
104-enabled = false # Enable per-app Docker networks107+enabled = true # Enable per-app Docker networks
105 network_prefix = "gordon" # Prefix for created networks108 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)
3232 | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests |
3333 | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk |
3434 | `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) |
3538
3639 ## Port Configuration
3740
@@ -255,6 +258,48 @@ Via environment variable:
255258 GORDON_SERVER_REGISTRY_ALLOWED_IPS=100.64.0.0/10
256259 ```
257260
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+
258303 ## Examples
259304
260305 ### 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 Configuration39 ## Port Configuration
37 40
@@ -255,6 +258,48 @@ Via environment variable:
255 GORDON_SERVER_REGISTRY_ALLOWED_IPS=100.64.0.0/10258 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 ## Examples303 ## Examples
259 304
260 ### Development Configuration305 ### Development Configuration
M internal/adapters/in/http/middleware/cidr.go +29 -8
@@ -13,18 +13,17 @@ import (
1313 // localhostNets contains IPv4 and IPv6 loopback ranges that are always allowed.
1414 var localhostNets = ParseTrustedProxies([]string{"127.0.0.0/8", "::1"})
1515
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 {
2020 return func(next http.Handler) http.Handler {
2121 if len(allowedNets) == 0 {
2222 return next
2323 }
2424 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25- clientIP := GetClientIP(r, trustedNets)
25+ clientIP := ipExtractor(r)
2626
27- // Always allow localhost — Gordon must reach its own registry.
2827 if IsTrustedProxy(clientIP, localhostNets) {
2928 next.ServeHTTP(w, r)
3029 return
@@ -39,9 +38,8 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L
3938 Str(zerowrap.FieldLayer, "adapter").
4039 Str(zerowrap.FieldAdapter, "http").
4140 Str(zerowrap.FieldMethod, r.Method).
42- Str(zerowrap.FieldPath, r.URL.Path).
4341 Str(zerowrap.FieldClientIP, clientIP).
44- Msg("registry access denied by CIDR allowlist")
42+ Msgf("%s access denied by CIDR allowlist", logLabel)
4543
4644 w.Header().Set("Content-Type", "application/json")
4745 w.WriteHeader(http.StatusForbidden)
@@ -49,3 +47,26 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L
4947 })
5048 }
5149 }
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 next22 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 return29 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) {
109109 })
110110 }
111111 }
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 (
66 "strings"
77 )
88
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+
939 func normalizeIP(raw string) string {
1040 ip := strings.TrimSpace(raw)
1141 if ip == "" {
@@ -16,7 +46,7 @@ func normalizeIP(raw string) string {
1646 ip = host
1747 }
1848
19- parsed := net.ParseIP(strings.TrimSpace(ip))
49+ parsed := net.ParseIP(ip)
2050 if parsed == nil {
2151 return ""
2252 }
@@ -84,6 +114,18 @@ func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string {
84114
85115 // Only honor proxy headers if the request comes from a trusted proxy
86116 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+
87129 // Check X-Forwarded-For for proxied requests.
88130 // SECURITY: Parse from right to left and return the first untrusted IP.
89131 // 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 = host46 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 proxy115 // 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) {
1313 // Parse trusted proxies for tests
1414 trustedNets := ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"})
1515 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"})
1618
1719 tests := []struct {
1820 name string
1921 remoteAddr string
2022 xff string
2123 xRealIP string
24+ cfIP string
2225 trustedNets []*net.IPNet
2326 wantIP string
2427 }{
@@ -98,6 +101,28 @@ func TestGetClientIP(t *testing.T) {
98101 trustedNets: trustedNets,
99102 wantIP: "203.0.113.50",
100103 },
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+ },
101126 {
102127 name: "IPv6 RemoteAddr",
103128 remoteAddr: "[::1]:12345",
@@ -116,6 +141,9 @@ func TestGetClientIP(t *testing.T) {
116141 if tt.xRealIP != "" {
117142 req.Header.Set("X-Real-IP", tt.xRealIP)
118143 }
144+ if tt.cfIP != "" {
145+ req.Header.Set("Cf-Connecting-Ip", tt.cfIP)
146+ }
119147
120148 ip := GetClientIP(req, tt.trustedNets)
121149 assert.Equal(t, tt.wantIP, ip)
@@ -13,12 +13,15 @@ func TestGetClientIP(t *testing.T) {
13 // Parse trusted proxies for tests13 // 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 string20 name string
19 remoteAddr string21 remoteAddr string
20 xff string22 xff string
21 xRealIP string23 xRealIP string
24+ cfIP string
22 trustedNets []*net.IPNet25 trustedNets []*net.IPNet
23 wantIP string26 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 (
55 )
66
77 // SecurityHeaders middleware adds standard security headers to HTTP responses.
8-// This provides defense-in-depth against various web attacks.
98 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+}
3811
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+ }
4138 }
@@ -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 sniffing10+}
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) {
5151 assert.Equal(t, "max-age=31536000; includeSubDomains", rec.Header().Get("Strict-Transport-Security"))
5252 }
5353
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+
5470 func TestSecurityHeaders_HSTS_WithForwardedProto_NotTrusted(t *testing.T) {
5571 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
5672 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
156156 log.Error().Err(proxyErr).Int("registry_port", registryPort).Msg("registry proxy error")
157157 proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable)
158158 },
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.
165161 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.
169162 resp.Header.Del("X-Content-Type-Options")
170163 resp.Header.Del("X-Frame-Options")
171164 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 (
2525 "github.com/docker/docker/api/types/image"
2626 "github.com/docker/docker/api/types/network"
2727 "github.com/docker/docker/api/types/registry"
28+ "github.com/docker/docker/api/types/strslice"
2829 "github.com/docker/docker/api/types/volume"
2930 "github.com/docker/docker/client"
3031 "github.com/docker/docker/pkg/stdcopy"
3132 "github.com/docker/go-connections/nat"
33+ "github.com/docker/go-units"
3234
3335 "github.com/bnema/gordon/internal/boundaries/out"
3436 "github.com/bnema/gordon/internal/domain"
@@ -164,6 +166,9 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
164166 resources := container.Resources{
165167 Memory: config.MemoryLimit,
166168 NanoCPUs: config.NanoCPUs,
169+ Ulimits: []*units.Ulimit{
170+ {Name: "nofile", Soft: 65536, Hard: 65536},
171+ },
167172 }
168173 if config.PidsLimit > 0 {
169174 resources.PidsLimit = &config.PidsLimit
@@ -174,6 +179,12 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
174179 Binds: binds,
175180 NetworkMode: container.NetworkMode(config.NetworkMode),
176181 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"},
177188 }
178189
179190 // 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.PidsLimit174 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 container190 // Create network configuration for container
M internal/app/run.go +91 -31
@@ -6,6 +6,7 @@ import (
66 "crypto/ecdsa"
77 "crypto/elliptic"
88 "crypto/rand"
9+ "crypto/tls"
910 "crypto/x509"
1011 "crypto/x509/pkix"
1112 "encoding/hex"
@@ -100,6 +101,9 @@ type Config struct {
100101 MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit
101102 MaxConcurrentConns int `mapstructure:"max_concurrent_connections"`
102103 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"`
103107 } `mapstructure:"server"`
104108
105109 Logging struct {
@@ -282,10 +286,10 @@ func Run(ctx context.Context, configPath string) error {
282286 defer svc.eventBus.Stop()
283287
284288 // Create HTTP handlers
285- registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)
289+ registryHandler, proxyHandler, proxyCIDRMiddleware := createHTTPHandlers(svc, cfg, log)
286290
287291 // 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)
289293 }
290294
291295 func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error {
@@ -1665,7 +1669,9 @@ func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler {
16651669 }
16661670
16671671 // 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) {
16691675 // Parse trusted proxies once for all middleware chains.
16701676 // This ensures consistent IP extraction across logging, rate limiting, and auth.
16711677 trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
@@ -1690,7 +1696,15 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
16901696 proxyMiddlewares := []func(http.Handler) http.Handler{
16911697 middleware.PanicRecovery(log),
16921698 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)
16941708 }
16951709
16961710 proxyHandler := proxyadapter.NewHandler(svc.proxySvc, log)
@@ -1703,7 +1717,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
17031717
17041718 registerAdminRoutes(registryMux, svc, cfg, trustedNets, log)
17051719
1706- return registryMux, proxyMux
1720+ return registryMux, proxyMux, proxyCIDRMiddleware
17071721 }
17081722
17091723 func buildRegistryHandlerWithMiddleware(
@@ -1736,40 +1750,73 @@ func buildRegistryHandlerWithMiddleware(
17361750 return registryWithOtel, cidrAllowlistMiddleware, rateLimitMiddleware
17371751 }
17381752
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
17421758 }
17431759
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 {
17471763 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)
17491765 }
17501766 }
17511767 }
17521768
17531769 if len(allowedNets) == 0 {
17541770 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
17681774 }
17691775
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+ }
17701801 return middleware.RegistryCIDRAllowlist(allowedNets, trustedNets, log)
17711802 }
17721803
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+
17731820 func buildRegistryRateLimitMiddleware(cfg Config, log zerowrap.Logger) func(http.Handler) http.Handler {
17741821 if cfg.API.RateLimit.Enabled {
17751822 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,
19021949 // - SIGUSR2: Triggers manual deploy for a specific route
19031950 // The deferred signal.Stop calls ensure signal handlers are properly
19041951 // 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 {
19061953 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
19071954 defer cancel()
19081955
@@ -1920,9 +1967,10 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
19201967
19211968 errChan := make(chan error, 3)
19221969
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)
19241972
1925- proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, proxyHandler, errChan, log)
1973+ proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, proxyHandler, proxyCIDRMiddleware, errChan, log)
19261974 if err != nil {
19271975 return err
19281976 }
@@ -2208,7 +2256,9 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *
22082256 // startProxyServers sets up proxy server(s) depending on TLS configuration.
22092257 // With TLS enabled, the HTTP port becomes a redirect-only server and a separate TLS server handles traffic.
22102258 // 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) {
22122262 if !cfg.Server.TLSEnabled {
22132263 var proxyProtos http.Protocols
22142264 proxyProtos.SetHTTP1(true)
@@ -2238,7 +2288,11 @@ func startProxyServers(cfg Config, proxyHandler http.Handler, errChan chan<- err
22382288 http.Redirect(w, r, target, http.StatusPermanentRedirect)
22392289 })
22402290
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)
22422296 tlsSrv, tlsReady := startTLSServer(
22432297 fmt.Sprintf(":%d", cfg.Server.TLSPort),
22442298 proxyHandler,
@@ -2301,6 +2355,9 @@ func startTLSServer(addr string, handler http.Handler, name, certFile, keyFile s
23012355 WriteTimeout: 5 * time.Minute,
23022356 IdleTimeout: 120 * time.Second,
23032357 MaxHeaderBytes: 1 << 20,
2358+ TLSConfig: &tls.Config{
2359+ MinVersion: tls.VersionTLS12,
2360+ },
23042361 }
23052362
23062363 go func() {
@@ -2605,7 +2662,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
26052662 v.SetDefault("api.rate_limit.per_ip_rps", 50)
26062663 v.SetDefault("api.rate_limit.burst", 100)
26072664 v.SetDefault("auto_route.enabled", false)
2608- v.SetDefault("network_isolation.enabled", false)
2665+ v.SetDefault("network_isolation.enabled", true)
26092666 v.SetDefault("network_isolation.network_prefix", "gordon")
26102667 v.SetDefault("volumes.auto_create", true)
26112668 v.SetDefault("volumes.prefix", "gordon")
@@ -2631,6 +2688,9 @@ func loadConfig(v *viper.Viper, configPath string) error {
26312688
26322689 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
26332690 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)
26342694 v.SetDefault("deploy.readiness_delay", "5s")
26352695 v.SetDefault("deploy.readiness_mode", "auto")
26362696 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 limit101 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 handlers288 // 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, proxyMux1720+ return registryMux, proxyMux, proxyCIDRMiddleware
1707 }1721 }
1708 1722
1709 func buildRegistryHandlerWithMiddleware(1723 func buildRegistryHandlerWithMiddleware(
@@ -1736,40 +1750,73 @@ func buildRegistryHandlerWithMiddleware(
1736 return registryWithOtel, cidrAllowlistMiddleware, rateLimitMiddleware1750 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 nil1755+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 route1949 // - SIGUSR2: Triggers manual deploy for a specific route
1903 // The deferred signal.Stop calls ensure signal handlers are properly1950 // 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 err1975 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.Protocols2263 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 limit2689 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) {
3535 cfg.Auth.Enabled = false
3636
3737 svc := &services{adminHandler: &adminhttp.Handler{}}
38- registryHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
38+ registryHandler, _, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
3939
4040 req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
4141 req.RemoteAddr = "192.0.2.10:12345"
@@ -54,7 +54,7 @@ func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T)
5454 cfg := Config{}
5555 cfg.Auth.Enabled = false
5656
57- registryHandler, _ := createHTTPHandlers(&services{}, cfg, zerowrap.Default())
57+ registryHandler, _, _ := createHTTPHandlers(&services{}, cfg, zerowrap.Default())
5858
5959 req := httptest.NewRequest(http.MethodGet, "/v2/test/manifests/latest", nil)
6060 req.RemoteAddr = "192.0.2.20:12345"
@@ -35,7 +35,7 @@ func TestCreateHTTPHandlers_LocalMode_DisablesAdminRoutes(t *testing.T) {
35 cfg.Auth.Enabled = false35 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 = false55 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"

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close