detached ea18bbe

Merge pull request #33 from bnema/security/audit-2026-01

Security Audit 2026-01: Fix vulnerabilities
commit
ea18bbe97b4d6d5574dc9df72cb5ec2ae4aacaff
author
bnema · · 2026-01-18 02:23 UTC
committer
GitHub · · 2026-01-18 02:23 UTC
merge of
8e190aa + f523c5f · diff against the first parent
signature
unsigned
29 files · +2544 -69
M .github/actions/deploy/README.md +3 -5
@@ -125,13 +125,11 @@ In your repository settings, add:
125125 Ensure your Gordon config uses token authentication:
126126
127127 ```toml
128-[secrets]
129-backend = "pass" # or "sops"
130-
131-[registry_auth]
128+[auth]
132129 enabled = true
133130 type = "token"
134-token_secret = "gordon/registry/token_secret"
131+secrets_backend = "pass" # or "sops"
132+token_secret = "gordon/auth/token_secret"
135133 ```
136134
137135 ## Examples
@@ -125,13 +125,11 @@ In your repository settings, add:
125 Ensure your Gordon config uses token authentication:125 Ensure your Gordon config uses token authentication:
126 126
127 ```toml127 ```toml
128-[secrets]128+[auth]
129-backend = "pass" # or "sops"
130-
131-[registry_auth]
132 enabled = true129 enabled = true
133 type = "token"130 type = "token"
134-token_secret = "gordon/registry/token_secret"131+secrets_backend = "pass" # or "sops"
132+token_secret = "gordon/auth/token_secret"
135 ```133 ```
136 134
137 ## Examples135 ## Examples
M docs/config/auth.md +15 -0
@@ -52,6 +52,21 @@ token_expiry = "30d"
5252 | `token_secret` | string | - | Path to JWT signing secret in secrets backend |
5353 | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) |
5454
55+## Environment Variables
56+
57+Gordon supports environment variables for sensitive configuration:
58+
59+| Variable | Description |
60+|----------|-------------|
61+| `GORDON_AUTH_TOKEN_SECRET` | JWT signing secret (takes priority over config file) |
62+
63+Using environment variables is recommended for production as it avoids storing secrets on disk:
64+
65+```bash
66+export GORDON_AUTH_TOKEN_SECRET="your-32-character-secret-here"
67+gordon serve
68+```
69+
5570 ## Secrets Backend
5671
5772 The `secrets_backend` option determines how Gordon retrieves sensitive values like `token_secret` and `password_hash`:
@@ -52,6 +52,21 @@ token_expiry = "30d"
52 | `token_secret` | string | - | Path to JWT signing secret in secrets backend |52 | `token_secret` | string | - | Path to JWT signing secret in secrets backend |
53 | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) |53 | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) |
54 54
55+## Environment Variables
56+
57+Gordon supports environment variables for sensitive configuration:
58+
59+| Variable | Description |
60+|----------|-------------|
61+| `GORDON_AUTH_TOKEN_SECRET` | JWT signing secret (takes priority over config file) |
62+
63+Using environment variables is recommended for production as it avoids storing secrets on disk:
64+
65+```bash
66+export GORDON_AUTH_TOKEN_SECRET="your-32-character-secret-here"
67+gordon serve
68+```
69+
55 ## Secrets Backend70 ## Secrets Backend
56 71
57 The `secrets_backend` option determines how Gordon retrieves sensitive values like `token_secret` and `password_hash`:72 The `secrets_backend` option determines how Gordon retrieves sensitive values like `token_secret` and `password_hash`:
M docs/config/index.md +14 -0
@@ -47,6 +47,14 @@ secrets_backend = "pass" # "pass", "sops", or "unsafe"
4747 token_secret = "gordon/auth/token_secret"
4848 token_expiry = "30d" # Duration (1y, 30d, 2w) or 0 for never
4949
50+# API rate limiting
51+[api.rate_limit]
52+enabled = true # Enable rate limiting (default: true)
53+global_rps = 500 # Max requests/second globally
54+per_ip_rps = 50 # Max requests/second per IP
55+burst = 100 # Burst size
56+trusted_proxies = [] # IPs/CIDRs trusted for X-Forwarded-For
57+
5058 # Deploy behavior
5159 [deploy]
5260 pull_policy = "if-tag-changed" # always, if-not-present, if-tag-changed
@@ -111,6 +119,7 @@ enabled = false # Auto-create routes from image names
111119 |---------|-------------|---------------|
112120 | `[server]` | Core server settings | [Server](./server.md) |
113121 | `[auth]` | Authentication and secrets backend | [Auth](./auth.md) |
122+| `[api.rate_limit]` | Rate limiting configuration | [Rate Limiting](./rate-limiting.md) |
114123 | `[deploy]` | Deployment behavior | [Deploy](./deploy.md) |
115124 | `[logging]` | Logging configuration | [Logging](./logging.md) |
116125 | `[env]` | Environment variable settings | [Environment](./env.md) |
@@ -133,6 +142,11 @@ enabled = false # Auto-create routes from image names
133142 | `auth.type` | `"password"` |
134143 | `auth.secrets_backend` | `"unsafe"` |
135144 | `auth.token_expiry` | `"30d"` |
145+| `api.rate_limit.enabled` | `true` |
146+| `api.rate_limit.global_rps` | `500` |
147+| `api.rate_limit.per_ip_rps` | `50` |
148+| `api.rate_limit.burst` | `100` |
149+| `api.rate_limit.trusted_proxies` | `[]` |
136150 | `deploy.pull_policy` | `"if-tag-changed"` |
137151 | `logging.level` | `"info"` |
138152 | `logging.format` | `"console"` |
@@ -47,6 +47,14 @@ secrets_backend = "pass" # "pass", "sops", or "unsafe"
47 token_secret = "gordon/auth/token_secret"47 token_secret = "gordon/auth/token_secret"
48 token_expiry = "30d" # Duration (1y, 30d, 2w) or 0 for never48 token_expiry = "30d" # Duration (1y, 30d, 2w) or 0 for never
49 49
50+# API rate limiting
51+[api.rate_limit]
52+enabled = true # Enable rate limiting (default: true)
53+global_rps = 500 # Max requests/second globally
54+per_ip_rps = 50 # Max requests/second per IP
55+burst = 100 # Burst size
56+trusted_proxies = [] # IPs/CIDRs trusted for X-Forwarded-For
57+
50 # Deploy behavior58 # Deploy behavior
51 [deploy]59 [deploy]
52 pull_policy = "if-tag-changed" # always, if-not-present, if-tag-changed60 pull_policy = "if-tag-changed" # always, if-not-present, if-tag-changed
@@ -111,6 +119,7 @@ enabled = false # Auto-create routes from image names
111 |---------|-------------|---------------|119 |---------|-------------|---------------|
112 | `[server]` | Core server settings | [Server](./server.md) |120 | `[server]` | Core server settings | [Server](./server.md) |
113 | `[auth]` | Authentication and secrets backend | [Auth](./auth.md) |121 | `[auth]` | Authentication and secrets backend | [Auth](./auth.md) |
122+| `[api.rate_limit]` | Rate limiting configuration | [Rate Limiting](./rate-limiting.md) |
114 | `[deploy]` | Deployment behavior | [Deploy](./deploy.md) |123 | `[deploy]` | Deployment behavior | [Deploy](./deploy.md) |
115 | `[logging]` | Logging configuration | [Logging](./logging.md) |124 | `[logging]` | Logging configuration | [Logging](./logging.md) |
116 | `[env]` | Environment variable settings | [Environment](./env.md) |125 | `[env]` | Environment variable settings | [Environment](./env.md) |
@@ -133,6 +142,11 @@ enabled = false # Auto-create routes from image names
133 | `auth.type` | `"password"` |142 | `auth.type` | `"password"` |
134 | `auth.secrets_backend` | `"unsafe"` |143 | `auth.secrets_backend` | `"unsafe"` |
135 | `auth.token_expiry` | `"30d"` |144 | `auth.token_expiry` | `"30d"` |
145+| `api.rate_limit.enabled` | `true` |
146+| `api.rate_limit.global_rps` | `500` |
147+| `api.rate_limit.per_ip_rps` | `50` |
148+| `api.rate_limit.burst` | `100` |
149+| `api.rate_limit.trusted_proxies` | `[]` |
136 | `deploy.pull_policy` | `"if-tag-changed"` |150 | `deploy.pull_policy` | `"if-tag-changed"` |
137 | `logging.level` | `"info"` |151 | `logging.level` | `"info"` |
138 | `logging.format` | `"console"` |152 | `logging.format` | `"console"` |
A docs/config/rate-limiting.md +368 -0
@@ -0,0 +1,368 @@
1+# Rate Limiting
2+
3+Protect your Gordon instance from abuse with configurable rate limiting.
4+
5+## Overview
6+
7+Rate limiting prevents:
8+- **Brute force attacks** on authentication endpoints
9+- **Denial of Service (DoS)** from excessive requests
10+- **Resource exhaustion** from misbehaving clients
11+- **Registry abuse** (e.g., automated scraping)
12+
13+Rate limiting applies to:
14+- **Registry API** (`/v2/*`) — Global + per-IP limits
15+- **Token endpoint** (`/v2/token`) — Same limiter as registry (Global + per-IP)
16+- **Admin API** (`/admin/*`) — Separate per-IP limit only
17+
18+## Quick Start
19+
20+Rate limiting is **enabled by default** with sensible defaults:
21+
22+```toml
23+[api.rate_limit]
24+enabled = true
25+global_rps = 500
26+per_ip_rps = 50
27+burst = 100
28+trusted_proxies = []
29+```
30+
31+> **Important:** Gordon does not handle TLS termination, so production deployments require a front proxy (Cloudflare, nginx, etc.) for HTTPS. You **must** configure `trusted_proxies` to ensure rate limiting sees real client IPs instead of your proxy's IP.
32+
33+## Configuration
34+
35+```toml
36+[api.rate_limit]
37+enabled = true
38+global_rps = 500
39+per_ip_rps = 50
40+burst = 100
41+trusted_proxies = ["127.0.0.1", "10.0.0.0/8"]
42+```
43+
44+## Options
45+
46+| Option | Type | Default | Description |
47+|--------|------|---------|-------------|
48+| `enabled` | bool | `true` | Enable or disable rate limiting |
49+| `global_rps` | float | `500` | Maximum requests per second across all clients combined |
50+| `per_ip_rps` | float | `50` | Maximum requests per second per client IP address |
51+| `burst` | int | `100` | Maximum burst size (requests allowed to exceed the rate temporarily) |
52+| `trusted_proxies` | []string | `[]` | IP addresses or CIDR ranges trusted to set `X-Forwarded-For` |
53+
54+## Architecture
55+
56+Gordon uses two separate rate limiters:
57+
58+```
59+┌─────────────────────────────────────────────────────────┐
60+│ Incoming Request │
61+└─────────────────────────────────────────────────────────┘
62+ │
63+ ┌───────────────┴───────────────┐
64+ ▼ ▼
65+ ┌───────────────┐ ┌───────────────┐
66+ │ /v2/* routes │ │ /admin/* routes│
67+ └───────────────┘ └───────────────┘
68+ │ │
69+ ▼ ▼
70+┌─────────────────────────┐ ┌─────────────────────────┐
71+│ Registry Rate Limiter │ │ Admin Rate Limiter │
72+│ ┌───────────────────┐ │ │ ┌───────────────────┐ │
73+│ │ Global Limiter │ │ │ │ Per-IP Limiter │ │
74+│ │ (global_rps) │ │ │ │ (per_ip_rps) │ │
75+│ └───────────────────┘ │ │ └───────────────────┘ │
76+│ ┌───────────────────┐ │ └─────────────────────────┘
77+│ │ Per-IP Limiters │ │
78+│ │ (per_ip_rps) │ │
79+│ └───────────────────┘ │
80+└─────────────────────────┘
81+```
82+
83+**Registry Limiter** (for `/v2/*` and `/v2/token`):
84+- Checks global limit first (all clients combined)
85+- Then checks per-IP limit
86+- Both must pass for the request to proceed
87+
88+**Admin Limiter** (for `/admin/*`):
89+- Per-IP limit only
90+- No global limit (admin traffic is typically low volume)
91+- Separate from registry to avoid CI/CD traffic affecting admin access
92+
93+## Option Details
94+
95+### `enabled`
96+
97+Master switch for rate limiting. When `false`, all requests are allowed without throttling.
98+
99+```toml
100+[api.rate_limit]
101+enabled = false # Disable rate limiting (not recommended for production)
102+```
103+
104+### `global_rps`
105+
106+The maximum number of requests per second that Gordon will accept from **all clients combined**. This is a hard cap that protects against distributed attacks where many IPs each send moderate traffic.
107+
108+```toml
109+[api.rate_limit]
110+global_rps = 500 # 500 requests/second total
111+```
112+
113+**Sizing guidance:**
114+- **Small deployments** (1-10 apps): `100-500` RPS
115+- **Medium deployments** (10-50 apps): `500-2000` RPS
116+- **Large deployments** (50+ apps): `2000-10000` RPS
117+
118+Consider your CI/CD patterns—parallel builds pushing multiple images can generate bursts of traffic.
119+
120+### `per_ip_rps`
121+
122+The maximum requests per second allowed from a **single client IP**. This prevents any single client from monopolizing Gordon's resources.
123+
124+```toml
125+[api.rate_limit]
126+per_ip_rps = 50 # 50 requests/second per IP
127+```
128+
129+**Sizing guidance:**
130+- Normal Docker operations (pull, push) rarely exceed 10-20 RPS
131+- CI/CD pipelines with parallel jobs may need 50-100 RPS
132+- Automated tooling (vulnerability scanners, etc.) may need higher limits
133+
134+### `burst`
135+
136+The token bucket burst size. Allows clients to temporarily exceed the rate limit for short bursts, which is useful for legitimate traffic patterns like:
137+- Initial connection setup (multiple manifest/blob requests)
138+- Parallel layer downloads
139+- Health check bursts
140+
141+```toml
142+[api.rate_limit]
143+burst = 100 # Allow bursts of up to 100 requests
144+```
145+
146+A burst of `100` with `per_ip_rps = 50` means a client can send 100 requests instantly, then must wait ~2 seconds for the bucket to refill before another burst.
147+
148+### `trusted_proxies`
149+
150+**Critical for correct IP detection in production.**
151+
152+Gordon is a reverse proxy that routes requests to your containers, but it **does not handle TLS termination**. For HTTPS support, you must place Gordon behind a TLS-terminating proxy like Cloudflare, nginx, or a load balancer.
153+
154+```
155+Internet → [Cloudflare/nginx] → Gordon → Containers
156+ (HTTPS) (HTTP)
157+```
158+
159+In this setup, Gordon sees all connections coming from your front proxy's IP, not the real clients. The proxy sets `X-Forwarded-For` to communicate the original client IP.
160+
161+**The problem:** `X-Forwarded-For` can be spoofed. If Gordon trusted this header unconditionally, attackers could:
162+- Bypass per-IP rate limits by sending fake IPs
163+- Create unlimited rate limiter entries (memory exhaustion)
164+
165+**The solution:** Gordon only honors `X-Forwarded-For` from IPs listed in `trusted_proxies`. For all other connections, it uses the direct connection IP.
166+
167+```toml
168+[api.rate_limit]
169+trusted_proxies = ["127.0.0.1", "10.0.0.0/8"]
170+```
171+
172+**Supported formats:**
173+- Single IP: `"192.168.1.1"`
174+- CIDR range: `"10.0.0.0/8"`
175+- IPv6: `"::1"`, `"fd00::/8"`
176+
177+> **If `trusted_proxies` is empty or misconfigured**, all requests appear to come from your proxy's IP, making per-IP rate limiting ineffective (all clients share one limit).
178+
179+## Deployment Examples
180+
181+### Cloudflare (Recommended)
182+
183+Cloudflare provides free TLS termination and DDoS protection. This is the recommended setup for production.
184+
185+Cloudflare publishes their IP ranges at https://www.cloudflare.com/ips/
186+
187+```toml
188+[api.rate_limit]
189+# Cloudflare IPs (verified 2026-01-18 - check cloudflare.com/ips for updates)
190+trusted_proxies = [
191+ # IPv4
192+ "173.245.48.0/20",
193+ "103.21.244.0/22",
194+ "103.22.200.0/22",
195+ "103.31.4.0/22",
196+ "141.101.64.0/18",
197+ "108.162.192.0/18",
198+ "190.93.240.0/20",
199+ "188.114.96.0/20",
200+ "197.234.240.0/22",
201+ "198.41.128.0/17",
202+ "162.158.0.0/15",
203+ "104.16.0.0/13",
204+ "104.24.0.0/14",
205+ "172.64.0.0/13",
206+ "131.0.72.0/22",
207+ # IPv6
208+ "2400:cb00::/32",
209+ "2606:4700::/32",
210+ "2803:f800::/32",
211+ "2405:b500::/32",
212+ "2405:8100::/32",
213+ "2a06:98c0::/29",
214+ "2c0f:f248::/32",
215+]
216+```
217+
218+> **Note:** Cloudflare IPs may change. Verify at https://www.cloudflare.com/ips/ periodically.
219+
220+### Local Development (No TLS)
221+
222+For local development only, Gordon can be accessed directly without a proxy:
223+
224+```toml
225+[api.rate_limit]
226+trusted_proxies = [] # Empty = use RemoteAddr directly
227+```
228+
229+> **Warning:** Never expose Gordon directly to the internet without TLS. Use this configuration only for local development.
230+
231+### Behind nginx (Same Host)
232+
233+When nginx runs on the same machine as Gordon:
234+
235+```toml
236+[api.rate_limit]
237+trusted_proxies = ["127.0.0.1", "::1"]
238+```
239+
240+### Behind nginx (Separate Host)
241+
242+When nginx runs on a different server:
243+
244+```toml
245+[api.rate_limit]
246+trusted_proxies = ["10.0.1.5"] # nginx server IP
247+```
248+
249+### Behind AWS Application Load Balancer (ALB)
250+
251+ALB terminates TLS and forwards requests to your instances. Unlike Cloudflare, ALB IPs are dynamic and change as AWS scales the load balancer. You need to trust your VPC's private IP range (CIDR) since ALB connects from within your VPC.
252+
253+```toml
254+[api.rate_limit]
255+trusted_proxies = ["10.0.0.0/16"] # Your VPC CIDR
256+```
257+
258+For detailed setup instructions, see the [AWS ALB Guide](../../wiki/guides/aws-alb.md).
259+
260+### Behind Kubernetes Ingress
261+
262+When running Gordon in Kubernetes with an ingress controller (nginx-ingress, Traefik, etc.), you need to trust the pod network CIDR. The ingress controller must also be configured to forward client IPs via `X-Forwarded-For`.
263+
264+```toml
265+[api.rate_limit]
266+trusted_proxies = ["10.244.0.0/16"] # Your pod network CIDR
267+```
268+
269+For detailed setup instructions, see the [Kubernetes Ingress Guide](../../wiki/guides/kubernetes-ingress.md).
270+
271+## Rate Limit Response
272+
273+When a client exceeds the rate limit, Gordon returns:
274+
275+```
276+HTTP/1.1 429 Too Many Requests
277+Content-Type: application/json
278+Docker-Distribution-API-Version: registry/2.0
279+Retry-After: 1
280+
281+{
282+ "errors": [
283+ {
284+ "code": "TOOMANYREQUESTS",
285+ "message": "rate limit exceeded"
286+ }
287+ ]
288+}
289+```
290+
291+The `Retry-After` header indicates when the client can retry (in seconds).
292+
293+## Monitoring
294+
295+Rate-limited requests are logged at `info` level:
296+
297+```
298+level=info msg="rate limit exceeded" client_ip=203.0.113.50 path=/v2/myapp/manifests/latest
299+```
300+
301+To see rate limiting in action, enable debug logging:
302+
303+```toml
304+[logging]
305+level = "debug"
306+```
307+
308+## Security Considerations
309+
310+### IP Spoofing Prevention
311+
312+If `trusted_proxies` is misconfigured, attackers can:
313+1. Send requests with fake `X-Forwarded-For: 1.2.3.4`
314+2. Each request appears to come from a different IP
315+3. Per-IP rate limiting becomes ineffective
316+4. Attackers create unbounded limiter entries (memory exhaustion)
317+
318+**Always verify your proxy configuration** by checking logs to ensure client IPs are detected correctly.
319+
320+### Endpoint Coverage
321+
322+All authenticated endpoints are protected:
323+
324+| Endpoint | Rate Limiter | Limits Applied |
325+|----------|--------------|----------------|
326+| `/v2/*` (registry) | Registry limiter | Global RPS + Per-IP RPS |
327+| `/v2/token` | Registry limiter | Global RPS + Per-IP RPS |
328+| `/admin/*` | Admin limiter | Per-IP RPS only |
329+
330+The token endpoint shares the registry limiter, preventing brute force attacks on credentials. The admin API uses a separate per-IP limiter to avoid interference with registry traffic.
331+
332+### Recommended Production Settings
333+
334+```toml
335+[api.rate_limit]
336+enabled = true
337+global_rps = 1000 # Adjust based on your traffic
338+per_ip_rps = 30 # Stricter per-IP limit
339+burst = 50 # Smaller burst for tighter control
340+trusted_proxies = [] # Configure based on your proxy setup
341+```
342+
343+## Troubleshooting
344+
345+### "I'm getting rate limited but traffic is low"
346+
347+1. Check if `trusted_proxies` is configured correctly
348+2. All traffic may appear to come from your proxy's IP
349+3. Add your proxy to `trusted_proxies` to see real client IPs
350+
351+### "Rate limiting doesn't seem to work"
352+
353+1. Verify `enabled = true`
354+2. Check that requests go through Gordon (not cached by CDN)
355+3. Ensure `global_rps` and `per_ip_rps` are set appropriately
356+
357+### "I need to whitelist certain IPs"
358+
359+Rate limiting doesn't support IP whitelisting. Consider:
360+- Increasing `per_ip_rps` for legitimate high-traffic clients
361+- Using a reverse proxy with its own rate limiting rules
362+- Implementing IP-based access control at the network level
363+
364+## Related
365+
366+- [Authentication](./auth.md)
367+- [Configuration Reference](./reference.md)
368+- [Logging](./logging.md)
@@ -0,0 +1,368 @@
1+# Rate Limiting
2+
3+Protect your Gordon instance from abuse with configurable rate limiting.
4+
5+## Overview
6+
7+Rate limiting prevents:
8+- **Brute force attacks** on authentication endpoints
9+- **Denial of Service (DoS)** from excessive requests
10+- **Resource exhaustion** from misbehaving clients
11+- **Registry abuse** (e.g., automated scraping)
12+
13+Rate limiting applies to:
14+- **Registry API** (`/v2/*`) — Global + per-IP limits
15+- **Token endpoint** (`/v2/token`) — Same limiter as registry (Global + per-IP)
16+- **Admin API** (`/admin/*`) — Separate per-IP limit only
17+
18+## Quick Start
19+
20+Rate limiting is **enabled by default** with sensible defaults:
21+
22+```toml
23+[api.rate_limit]
24+enabled = true
25+global_rps = 500
26+per_ip_rps = 50
27+burst = 100
28+trusted_proxies = []
29+```
30+
31+> **Important:** Gordon does not handle TLS termination, so production deployments require a front proxy (Cloudflare, nginx, etc.) for HTTPS. You **must** configure `trusted_proxies` to ensure rate limiting sees real client IPs instead of your proxy's IP.
32+
33+## Configuration
34+
35+```toml
36+[api.rate_limit]
37+enabled = true
38+global_rps = 500
39+per_ip_rps = 50
40+burst = 100
41+trusted_proxies = ["127.0.0.1", "10.0.0.0/8"]
42+```
43+
44+## Options
45+
46+| Option | Type | Default | Description |
47+|--------|------|---------|-------------|
48+| `enabled` | bool | `true` | Enable or disable rate limiting |
49+| `global_rps` | float | `500` | Maximum requests per second across all clients combined |
50+| `per_ip_rps` | float | `50` | Maximum requests per second per client IP address |
51+| `burst` | int | `100` | Maximum burst size (requests allowed to exceed the rate temporarily) |
52+| `trusted_proxies` | []string | `[]` | IP addresses or CIDR ranges trusted to set `X-Forwarded-For` |
53+
54+## Architecture
55+
56+Gordon uses two separate rate limiters:
57+
58+```
59+┌─────────────────────────────────────────────────────────┐
60+│ Incoming Request │
61+└─────────────────────────────────────────────────────────┘
62+ │
63+ ┌───────────────┴───────────────┐
64+ ▼ ▼
65+ ┌───────────────┐ ┌───────────────┐
66+ │ /v2/* routes │ │ /admin/* routes│
67+ └───────────────┘ └───────────────┘
68+ │ │
69+ ▼ ▼
70+┌─────────────────────────┐ ┌─────────────────────────┐
71+│ Registry Rate Limiter │ │ Admin Rate Limiter │
72+│ ┌───────────────────┐ │ │ ┌───────────────────┐ │
73+│ │ Global Limiter │ │ │ │ Per-IP Limiter │ │
74+│ │ (global_rps) │ │ │ │ (per_ip_rps) │ │
75+│ └───────────────────┘ │ │ └───────────────────┘ │
76+│ ┌───────────────────┐ │ └─────────────────────────┘
77+│ │ Per-IP Limiters │ │
78+│ │ (per_ip_rps) │ │
79+│ └───────────────────┘ │
80+└─────────────────────────┘
81+```
82+
83+**Registry Limiter** (for `/v2/*` and `/v2/token`):
84+- Checks global limit first (all clients combined)
85+- Then checks per-IP limit
86+- Both must pass for the request to proceed
87+
88+**Admin Limiter** (for `/admin/*`):
89+- Per-IP limit only
90+- No global limit (admin traffic is typically low volume)
91+- Separate from registry to avoid CI/CD traffic affecting admin access
92+
93+## Option Details
94+
95+### `enabled`
96+
97+Master switch for rate limiting. When `false`, all requests are allowed without throttling.
98+
99+```toml
100+[api.rate_limit]
101+enabled = false # Disable rate limiting (not recommended for production)
102+```
103+
104+### `global_rps`
105+
106+The maximum number of requests per second that Gordon will accept from **all clients combined**. This is a hard cap that protects against distributed attacks where many IPs each send moderate traffic.
107+
108+```toml
109+[api.rate_limit]
110+global_rps = 500 # 500 requests/second total
111+```
112+
113+**Sizing guidance:**
114+- **Small deployments** (1-10 apps): `100-500` RPS
115+- **Medium deployments** (10-50 apps): `500-2000` RPS
116+- **Large deployments** (50+ apps): `2000-10000` RPS
117+
118+Consider your CI/CD patterns—parallel builds pushing multiple images can generate bursts of traffic.
119+
120+### `per_ip_rps`
121+
122+The maximum requests per second allowed from a **single client IP**. This prevents any single client from monopolizing Gordon's resources.
123+
124+```toml
125+[api.rate_limit]
126+per_ip_rps = 50 # 50 requests/second per IP
127+```
128+
129+**Sizing guidance:**
130+- Normal Docker operations (pull, push) rarely exceed 10-20 RPS
131+- CI/CD pipelines with parallel jobs may need 50-100 RPS
132+- Automated tooling (vulnerability scanners, etc.) may need higher limits
133+
134+### `burst`
135+
136+The token bucket burst size. Allows clients to temporarily exceed the rate limit for short bursts, which is useful for legitimate traffic patterns like:
137+- Initial connection setup (multiple manifest/blob requests)
138+- Parallel layer downloads
139+- Health check bursts
140+
141+```toml
142+[api.rate_limit]
143+burst = 100 # Allow bursts of up to 100 requests
144+```
145+
146+A burst of `100` with `per_ip_rps = 50` means a client can send 100 requests instantly, then must wait ~2 seconds for the bucket to refill before another burst.
147+
148+### `trusted_proxies`
149+
150+**Critical for correct IP detection in production.**
151+
152+Gordon is a reverse proxy that routes requests to your containers, but it **does not handle TLS termination**. For HTTPS support, you must place Gordon behind a TLS-terminating proxy like Cloudflare, nginx, or a load balancer.
153+
154+```
155+Internet → [Cloudflare/nginx] → Gordon → Containers
156+ (HTTPS) (HTTP)
157+```
158+
159+In this setup, Gordon sees all connections coming from your front proxy's IP, not the real clients. The proxy sets `X-Forwarded-For` to communicate the original client IP.
160+
161+**The problem:** `X-Forwarded-For` can be spoofed. If Gordon trusted this header unconditionally, attackers could:
162+- Bypass per-IP rate limits by sending fake IPs
163+- Create unlimited rate limiter entries (memory exhaustion)
164+
165+**The solution:** Gordon only honors `X-Forwarded-For` from IPs listed in `trusted_proxies`. For all other connections, it uses the direct connection IP.
166+
167+```toml
168+[api.rate_limit]
169+trusted_proxies = ["127.0.0.1", "10.0.0.0/8"]
170+```
171+
172+**Supported formats:**
173+- Single IP: `"192.168.1.1"`
174+- CIDR range: `"10.0.0.0/8"`
175+- IPv6: `"::1"`, `"fd00::/8"`
176+
177+> **If `trusted_proxies` is empty or misconfigured**, all requests appear to come from your proxy's IP, making per-IP rate limiting ineffective (all clients share one limit).
178+
179+## Deployment Examples
180+
181+### Cloudflare (Recommended)
182+
183+Cloudflare provides free TLS termination and DDoS protection. This is the recommended setup for production.
184+
185+Cloudflare publishes their IP ranges at https://www.cloudflare.com/ips/
186+
187+```toml
188+[api.rate_limit]
189+# Cloudflare IPs (verified 2026-01-18 - check cloudflare.com/ips for updates)
190+trusted_proxies = [
191+ # IPv4
192+ "173.245.48.0/20",
193+ "103.21.244.0/22",
194+ "103.22.200.0/22",
195+ "103.31.4.0/22",
196+ "141.101.64.0/18",
197+ "108.162.192.0/18",
198+ "190.93.240.0/20",
199+ "188.114.96.0/20",
200+ "197.234.240.0/22",
201+ "198.41.128.0/17",
202+ "162.158.0.0/15",
203+ "104.16.0.0/13",
204+ "104.24.0.0/14",
205+ "172.64.0.0/13",
206+ "131.0.72.0/22",
207+ # IPv6
208+ "2400:cb00::/32",
209+ "2606:4700::/32",
210+ "2803:f800::/32",
211+ "2405:b500::/32",
212+ "2405:8100::/32",
213+ "2a06:98c0::/29",
214+ "2c0f:f248::/32",
215+]
216+```
217+
218+> **Note:** Cloudflare IPs may change. Verify at https://www.cloudflare.com/ips/ periodically.
219+
220+### Local Development (No TLS)
221+
222+For local development only, Gordon can be accessed directly without a proxy:
223+
224+```toml
225+[api.rate_limit]
226+trusted_proxies = [] # Empty = use RemoteAddr directly
227+```
228+
229+> **Warning:** Never expose Gordon directly to the internet without TLS. Use this configuration only for local development.
230+
231+### Behind nginx (Same Host)
232+
233+When nginx runs on the same machine as Gordon:
234+
235+```toml
236+[api.rate_limit]
237+trusted_proxies = ["127.0.0.1", "::1"]
238+```
239+
240+### Behind nginx (Separate Host)
241+
242+When nginx runs on a different server:
243+
244+```toml
245+[api.rate_limit]
246+trusted_proxies = ["10.0.1.5"] # nginx server IP
247+```
248+
249+### Behind AWS Application Load Balancer (ALB)
250+
251+ALB terminates TLS and forwards requests to your instances. Unlike Cloudflare, ALB IPs are dynamic and change as AWS scales the load balancer. You need to trust your VPC's private IP range (CIDR) since ALB connects from within your VPC.
252+
253+```toml
254+[api.rate_limit]
255+trusted_proxies = ["10.0.0.0/16"] # Your VPC CIDR
256+```
257+
258+For detailed setup instructions, see the [AWS ALB Guide](../../wiki/guides/aws-alb.md).
259+
260+### Behind Kubernetes Ingress
261+
262+When running Gordon in Kubernetes with an ingress controller (nginx-ingress, Traefik, etc.), you need to trust the pod network CIDR. The ingress controller must also be configured to forward client IPs via `X-Forwarded-For`.
263+
264+```toml
265+[api.rate_limit]
266+trusted_proxies = ["10.244.0.0/16"] # Your pod network CIDR
267+```
268+
269+For detailed setup instructions, see the [Kubernetes Ingress Guide](../../wiki/guides/kubernetes-ingress.md).
270+
271+## Rate Limit Response
272+
273+When a client exceeds the rate limit, Gordon returns:
274+
275+```
276+HTTP/1.1 429 Too Many Requests
277+Content-Type: application/json
278+Docker-Distribution-API-Version: registry/2.0
279+Retry-After: 1
280+
281+{
282+ "errors": [
283+ {
284+ "code": "TOOMANYREQUESTS",
285+ "message": "rate limit exceeded"
286+ }
287+ ]
288+}
289+```
290+
291+The `Retry-After` header indicates when the client can retry (in seconds).
292+
293+## Monitoring
294+
295+Rate-limited requests are logged at `info` level:
296+
297+```
298+level=info msg="rate limit exceeded" client_ip=203.0.113.50 path=/v2/myapp/manifests/latest
299+```
300+
301+To see rate limiting in action, enable debug logging:
302+
303+```toml
304+[logging]
305+level = "debug"
306+```
307+
308+## Security Considerations
309+
310+### IP Spoofing Prevention
311+
312+If `trusted_proxies` is misconfigured, attackers can:
313+1. Send requests with fake `X-Forwarded-For: 1.2.3.4`
314+2. Each request appears to come from a different IP
315+3. Per-IP rate limiting becomes ineffective
316+4. Attackers create unbounded limiter entries (memory exhaustion)
317+
318+**Always verify your proxy configuration** by checking logs to ensure client IPs are detected correctly.
319+
320+### Endpoint Coverage
321+
322+All authenticated endpoints are protected:
323+
324+| Endpoint | Rate Limiter | Limits Applied |
325+|----------|--------------|----------------|
326+| `/v2/*` (registry) | Registry limiter | Global RPS + Per-IP RPS |
327+| `/v2/token` | Registry limiter | Global RPS + Per-IP RPS |
328+| `/admin/*` | Admin limiter | Per-IP RPS only |
329+
330+The token endpoint shares the registry limiter, preventing brute force attacks on credentials. The admin API uses a separate per-IP limiter to avoid interference with registry traffic.
331+
332+### Recommended Production Settings
333+
334+```toml
335+[api.rate_limit]
336+enabled = true
337+global_rps = 1000 # Adjust based on your traffic
338+per_ip_rps = 30 # Stricter per-IP limit
339+burst = 50 # Smaller burst for tighter control
340+trusted_proxies = [] # Configure based on your proxy setup
341+```
342+
343+## Troubleshooting
344+
345+### "I'm getting rate limited but traffic is low"
346+
347+1. Check if `trusted_proxies` is configured correctly
348+2. All traffic may appear to come from your proxy's IP
349+3. Add your proxy to `trusted_proxies` to see real client IPs
350+
351+### "Rate limiting doesn't seem to work"
352+
353+1. Verify `enabled = true`
354+2. Check that requests go through Gordon (not cached by CDN)
355+3. Ensure `global_rps` and `per_ip_rps` are set appropriately
356+
357+### "I need to whitelist certain IPs"
358+
359+Rate limiting doesn't support IP whitelisting. Consider:
360+- Increasing `per_ip_rps` for legitimate high-traffic clients
361+- Using a reverse proxy with its own rate limiting rules
362+- Implementing IP-based access control at the network level
363+
364+## Related
365+
366+- [Authentication](./auth.md)
367+- [Configuration Reference](./reference.md)
368+- [Logging](./logging.md)
M docs/config/reference.md +29 -0
@@ -26,6 +26,16 @@ password_hash = "" # Path in secrets backend to bcrypt
2626 token_secret = "" # Path in secrets backend to JWT signing key (REQUIRED for token type)
2727 token_expiry = "720h" # Token expiry duration (720h = 30 days)
2828
29+# =============================================================================
30+# API (applies to both Registry and Admin endpoints)
31+# =============================================================================
32+[api.rate_limit]
33+enabled = true # Enable rate limiting (default: true)
34+global_rps = 500 # Max requests/second globally
35+per_ip_rps = 50 # Max requests/second per client IP
36+burst = 100 # Burst size for rate limiters
37+trusted_proxies = [] # IPs/CIDRs trusted to set X-Forwarded-For
38+
2939 # =============================================================================
3040 # LOGGING
3141 # =============================================================================
@@ -119,6 +129,11 @@ preserve = true # Keep volumes when containers are
119129 | `auth.type` | `"password"` | Auth type |
120130 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
121131 | `auth.token_expiry` | `"720h"` | 30 days |
132+| `api.rate_limit.enabled` | `true` | Enable rate limiting |
133+| `api.rate_limit.global_rps` | `500` | Global requests/second |
134+| `api.rate_limit.per_ip_rps` | `50` | Per-IP requests/second |
135+| `api.rate_limit.burst` | `100` | Burst size |
136+| `api.rate_limit.trusted_proxies` | `[]` | IPs/CIDRs trusted for X-Forwarded-For |
122137 | `logging.level` | `"info"` | Log level |
123138 | `logging.format` | `"console"` | Log format |
124139 | `logging.file.enabled` | `false` | File logging disabled |
@@ -163,6 +178,20 @@ GORDON_LOGGING_FILE_MAX_SIZE=200
163178 GORDON_LOGGING_CONTAINER_LOGS_ENABLED=false
164179 ```
165180
181+### Security Environment Variables
182+
183+These special environment variables take priority over config file values:
184+
185+| Variable | Description |
186+|----------|-------------|
187+| `GORDON_AUTH_TOKEN_SECRET` | JWT signing secret (avoids storing secret on disk) |
188+
189+Example:
190+```bash
191+export GORDON_AUTH_TOKEN_SECRET="your-secure-32-char-secret-here"
192+gordon serve
193+```
194+
166195 ## Pull Policy Options
167196
168197 | Value | Behavior |
@@ -26,6 +26,16 @@ password_hash = "" # Path in secrets backend to bcrypt
26 token_secret = "" # Path in secrets backend to JWT signing key (REQUIRED for token type)26 token_secret = "" # Path in secrets backend to JWT signing key (REQUIRED for token type)
27 token_expiry = "720h" # Token expiry duration (720h = 30 days)27 token_expiry = "720h" # Token expiry duration (720h = 30 days)
28 28
29+# =============================================================================
30+# API (applies to both Registry and Admin endpoints)
31+# =============================================================================
32+[api.rate_limit]
33+enabled = true # Enable rate limiting (default: true)
34+global_rps = 500 # Max requests/second globally
35+per_ip_rps = 50 # Max requests/second per client IP
36+burst = 100 # Burst size for rate limiters
37+trusted_proxies = [] # IPs/CIDRs trusted to set X-Forwarded-For
38+
29 # =============================================================================39 # =============================================================================
30 # LOGGING40 # LOGGING
31 # =============================================================================41 # =============================================================================
@@ -119,6 +129,11 @@ preserve = true # Keep volumes when containers are
119 | `auth.type` | `"password"` | Auth type |129 | `auth.type` | `"password"` | Auth type |
120 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |130 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
121 | `auth.token_expiry` | `"720h"` | 30 days |131 | `auth.token_expiry` | `"720h"` | 30 days |
132+| `api.rate_limit.enabled` | `true` | Enable rate limiting |
133+| `api.rate_limit.global_rps` | `500` | Global requests/second |
134+| `api.rate_limit.per_ip_rps` | `50` | Per-IP requests/second |
135+| `api.rate_limit.burst` | `100` | Burst size |
136+| `api.rate_limit.trusted_proxies` | `[]` | IPs/CIDRs trusted for X-Forwarded-For |
122 | `logging.level` | `"info"` | Log level |137 | `logging.level` | `"info"` | Log level |
123 | `logging.format` | `"console"` | Log format |138 | `logging.format` | `"console"` | Log format |
124 | `logging.file.enabled` | `false` | File logging disabled |139 | `logging.file.enabled` | `false` | File logging disabled |
@@ -163,6 +178,20 @@ GORDON_LOGGING_FILE_MAX_SIZE=200
163 GORDON_LOGGING_CONTAINER_LOGS_ENABLED=false178 GORDON_LOGGING_CONTAINER_LOGS_ENABLED=false
164 ```179 ```
165 180
181+### Security Environment Variables
182+
183+These special environment variables take priority over config file values:
184+
185+| Variable | Description |
186+|----------|-------------|
187+| `GORDON_AUTH_TOKEN_SECRET` | JWT signing secret (avoids storing secret on disk) |
188+
189+Example:
190+```bash
191+export GORDON_AUTH_TOKEN_SECRET="your-secure-32-char-secret-here"
192+gordon serve
193+```
194+
166 ## Pull Policy Options195 ## Pull Policy Options
167 196
168 | Value | Behavior |197 | Value | Behavior |
M internal/adapters/in/http/middleware/auth.go +16 -6
@@ -74,13 +74,13 @@ func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string,
7474 authenticated := usernameMatch && passwordMatch
7575 if !authenticated {
7676 log.Debug().
77- Str("provided_username", username).
77+ Str("provided_username", redactUsername(username)).
7878 Str(zerowrap.FieldMethod, r.Method).
7979 Str(zerowrap.FieldPath, r.URL.Path).
8080 Msg("authentication failed")
8181 } else {
8282 log.Debug().
83- Str("username", username).
83+ Str("username", redactUsername(username)).
8484 Str(zerowrap.FieldMethod, r.Method).
8585 Str(zerowrap.FieldPath, r.URL.Path).
8686 Msg("authentication successful")
@@ -89,6 +89,16 @@ func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string,
8989 return authenticated
9090 }
9191
92+// redactUsername partially redacts a username for logging.
93+// Shows first and last character with asterisks in between.
94+// SECURITY: Prevents full username exposure in logs.
95+func redactUsername(username string) string {
96+ if len(username) <= 2 {
97+ return "***"
98+ }
99+ return username[:1] + "***" + username[len(username)-1:]
100+}
101+
92102 // InternalRegistryAuth holds the credentials used for loopback-only registry access.
93103 // These are generated per Gordon instance and are never exposed in config.
94104 type InternalRegistryAuth struct {
@@ -191,7 +201,7 @@ func authenticatePassword(ctx context.Context, r *http.Request, authSvc in.AuthS
191201 }
192202
193203 log.Debug().
194- Str("provided_username", username).
204+ Str("provided_username", redactUsername(username)).
195205 Str(zerowrap.FieldMethod, r.Method).
196206 Str(zerowrap.FieldPath, r.URL.Path).
197207 Msg("password authentication failed")
@@ -240,7 +250,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
240250 if err != nil {
241251 log.Debug().
242252 Err(err).
243- Str("provided_username", username).
253+ Str("provided_username", redactUsername(username)).
244254 Str(zerowrap.FieldMethod, r.Method).
245255 Str(zerowrap.FieldPath, r.URL.Path).
246256 Msg("token-as-password validation failed")
@@ -250,8 +260,8 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
250260 // Verify the username matches the token subject
251261 if claims.Subject != username {
252262 log.Debug().
253- Str("provided_username", username).
254- Str("token_subject", claims.Subject).
263+ Str("provided_username", redactUsername(username)).
264+ Str("token_subject", redactUsername(claims.Subject)).
255265 Str(zerowrap.FieldMethod, r.Method).
256266 Str(zerowrap.FieldPath, r.URL.Path).
257267 Msg("username does not match token subject")
@@ -74,13 +74,13 @@ func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string,
74 authenticated := usernameMatch && passwordMatch74 authenticated := usernameMatch && passwordMatch
75 if !authenticated {75 if !authenticated {
76 log.Debug().76 log.Debug().
77- Str("provided_username", username).77+ Str("provided_username", redactUsername(username)).
78 Str(zerowrap.FieldMethod, r.Method).78 Str(zerowrap.FieldMethod, r.Method).
79 Str(zerowrap.FieldPath, r.URL.Path).79 Str(zerowrap.FieldPath, r.URL.Path).
80 Msg("authentication failed")80 Msg("authentication failed")
81 } else {81 } else {
82 log.Debug().82 log.Debug().
83- Str("username", username).83+ Str("username", redactUsername(username)).
84 Str(zerowrap.FieldMethod, r.Method).84 Str(zerowrap.FieldMethod, r.Method).
85 Str(zerowrap.FieldPath, r.URL.Path).85 Str(zerowrap.FieldPath, r.URL.Path).
86 Msg("authentication successful")86 Msg("authentication successful")
@@ -89,6 +89,16 @@ func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string,
89 return authenticated89 return authenticated
90 }90 }
91 91
92+// redactUsername partially redacts a username for logging.
93+// Shows first and last character with asterisks in between.
94+// SECURITY: Prevents full username exposure in logs.
95+func redactUsername(username string) string {
96+ if len(username) <= 2 {
97+ return "***"
98+ }
99+ return username[:1] + "***" + username[len(username)-1:]
100+}
101+
92 // InternalRegistryAuth holds the credentials used for loopback-only registry access.102 // InternalRegistryAuth holds the credentials used for loopback-only registry access.
93 // These are generated per Gordon instance and are never exposed in config.103 // These are generated per Gordon instance and are never exposed in config.
94 type InternalRegistryAuth struct {104 type InternalRegistryAuth struct {
@@ -191,7 +201,7 @@ func authenticatePassword(ctx context.Context, r *http.Request, authSvc in.AuthS
191 }201 }
192 202
193 log.Debug().203 log.Debug().
194- Str("provided_username", username).204+ Str("provided_username", redactUsername(username)).
195 Str(zerowrap.FieldMethod, r.Method).205 Str(zerowrap.FieldMethod, r.Method).
196 Str(zerowrap.FieldPath, r.URL.Path).206 Str(zerowrap.FieldPath, r.URL.Path).
197 Msg("password authentication failed")207 Msg("password authentication failed")
@@ -240,7 +250,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
240 if err != nil {250 if err != nil {
241 log.Debug().251 log.Debug().
242 Err(err).252 Err(err).
243- Str("provided_username", username).253+ Str("provided_username", redactUsername(username)).
244 Str(zerowrap.FieldMethod, r.Method).254 Str(zerowrap.FieldMethod, r.Method).
245 Str(zerowrap.FieldPath, r.URL.Path).255 Str(zerowrap.FieldPath, r.URL.Path).
246 Msg("token-as-password validation failed")256 Msg("token-as-password validation failed")
@@ -250,8 +260,8 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
250 // Verify the username matches the token subject260 // Verify the username matches the token subject
251 if claims.Subject != username {261 if claims.Subject != username {
252 log.Debug().262 log.Debug().
253- Str("provided_username", username).263+ Str("provided_username", redactUsername(username)).
254- Str("token_subject", claims.Subject).264+ Str("token_subject", redactUsername(claims.Subject)).
255 Str(zerowrap.FieldMethod, r.Method).265 Str(zerowrap.FieldMethod, r.Method).
256 Str(zerowrap.FieldPath, r.URL.Path).266 Str(zerowrap.FieldPath, r.URL.Path).
257 Msg("username does not match token subject")267 Msg("username does not match token subject")
A internal/adapters/in/http/middleware/security.go +28 -0
@@ -0,0 +1,28 @@
1+package middleware
2+
3+import (
4+ "net/http"
5+)
6+
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 {
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+ next.ServeHTTP(w, r)
27+ })
28+}
@@ -0,0 +1,28 @@
1+package middleware
2+
3+import (
4+ "net/http"
5+)
6+
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 {
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+ next.ServeHTTP(w, r)
27+ })
28+}
A internal/adapters/in/http/registry/ratelimit.go +210 -0
@@ -0,0 +1,210 @@
1+// Package registry implements the HTTP adapter for the registry API.
2+package registry
3+
4+import (
5+ "encoding/json"
6+ "net"
7+ "net/http"
8+ "strings"
9+ "sync"
10+
11+ "golang.org/x/time/rate"
12+
13+ "gordon/internal/adapters/dto"
14+)
15+
16+// RateLimitConfig holds rate limiting configuration for the registry API.
17+// These settings help prevent DoS attacks and brute force attempts.
18+type RateLimitConfig struct {
19+ // Enabled controls whether rate limiting is active.
20+ // Default: true
21+ Enabled bool
22+
23+ // GlobalRPS is the maximum requests per second across all clients.
24+ // Default: 500
25+ GlobalRPS float64
26+
27+ // PerIPRPS is the maximum requests per second per client IP.
28+ // Default: 50
29+ PerIPRPS float64
30+
31+ // Burst is the maximum burst size for rate limiters.
32+ // Default: 100
33+ Burst int
34+
35+ // TrustedProxies is a list of IP addresses or CIDR ranges that are trusted
36+ // to set X-Forwarded-For and X-Real-IP headers. If empty, these headers are
37+ // ignored and RemoteAddr is always used (secure default for direct exposure).
38+ // Example: ["127.0.0.1", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
39+ TrustedProxies []string
40+}
41+
42+// DefaultRateLimitConfig returns sensible default rate limiting configuration.
43+func DefaultRateLimitConfig() RateLimitConfig {
44+ return RateLimitConfig{
45+ Enabled: true,
46+ GlobalRPS: 500, // 500 requests/second globally
47+ PerIPRPS: 50, // 50 requests/second per IP
48+ Burst: 100, // Allow burst of 100 requests
49+ }
50+}
51+
52+// ipRateLimiter manages per-IP rate limiters.
53+type ipRateLimiter struct {
54+ limiters map[string]*rate.Limiter
55+ mu sync.RWMutex
56+ rps float64
57+ burst int
58+}
59+
60+func newIPRateLimiter(rps float64, burst int) *ipRateLimiter {
61+ return &ipRateLimiter{
62+ limiters: make(map[string]*rate.Limiter),
63+ rps: rps,
64+ burst: burst,
65+ }
66+}
67+
68+func (l *ipRateLimiter) getLimiter(ip string) *rate.Limiter {
69+ l.mu.RLock()
70+ limiter, exists := l.limiters[ip]
71+ l.mu.RUnlock()
72+
73+ if exists {
74+ return limiter
75+ }
76+
77+ l.mu.Lock()
78+ defer l.mu.Unlock()
79+
80+ // Double-check after acquiring write lock
81+ if limiter, exists = l.limiters[ip]; exists {
82+ return limiter
83+ }
84+
85+ limiter = rate.NewLimiter(rate.Limit(l.rps), l.burst)
86+ l.limiters[ip] = limiter
87+ return limiter
88+}
89+
90+// RateLimitMiddleware creates rate limiting middleware for the registry API.
91+// Returns nil if rate limiting is disabled.
92+func RateLimitMiddleware(config RateLimitConfig) func(http.Handler) http.Handler {
93+ if !config.Enabled {
94+ return func(next http.Handler) http.Handler {
95+ return next // Pass through if disabled
96+ }
97+ }
98+
99+ globalLimiter := rate.NewLimiter(rate.Limit(config.GlobalRPS), config.Burst)
100+ ipLimiter := newIPRateLimiter(config.PerIPRPS, config.Burst)
101+
102+ // Parse trusted proxy CIDRs once at middleware creation
103+ trustedNets := parseTrustedProxies(config.TrustedProxies)
104+
105+ return func(next http.Handler) http.Handler {
106+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
107+ // Check global rate limit
108+ if !globalLimiter.Allow() {
109+ sendRateLimitError(w)
110+ return
111+ }
112+
113+ // Check per-IP rate limit
114+ ip := getClientIP(r, trustedNets)
115+ if !ipLimiter.getLimiter(ip).Allow() {
116+ sendRateLimitError(w)
117+ return
118+ }
119+
120+ next.ServeHTTP(w, r)
121+ })
122+ }
123+}
124+
125+// parseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet.
126+func parseTrustedProxies(proxies []string) []*net.IPNet {
127+ var nets []*net.IPNet
128+ for _, proxy := range proxies {
129+ // Try parsing as CIDR
130+ _, ipNet, err := net.ParseCIDR(proxy)
131+ if err == nil {
132+ nets = append(nets, ipNet)
133+ continue
134+ }
135+ // Try parsing as single IP
136+ ip := net.ParseIP(proxy)
137+ if ip != nil {
138+ // Convert single IP to /32 or /128 CIDR
139+ bits := 32
140+ if ip.To4() == nil {
141+ bits = 128
142+ }
143+ nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
144+ }
145+ }
146+ return nets
147+}
148+
149+// isTrustedProxy checks if the given IP is from a trusted proxy.
150+func isTrustedProxy(ip string, trustedNets []*net.IPNet) bool {
151+ if len(trustedNets) == 0 {
152+ return false
153+ }
154+ parsedIP := net.ParseIP(ip)
155+ if parsedIP == nil {
156+ return false
157+ }
158+ for _, ipNet := range trustedNets {
159+ if ipNet.Contains(parsedIP) {
160+ return true
161+ }
162+ }
163+ return false
164+}
165+
166+// sendRateLimitError sends an HTTP 429 response in Docker Registry API format.
167+func sendRateLimitError(w http.ResponseWriter) {
168+ w.Header().Set("Content-Type", "application/json")
169+ w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
170+ w.Header().Set("Retry-After", "1")
171+ w.WriteHeader(http.StatusTooManyRequests)
172+ _ = json.NewEncoder(w).Encode(dto.RegistryErrorResponse{
173+ Errors: []dto.RegistryErrorItem{{
174+ Code: "TOOMANYREQUESTS",
175+ Message: "rate limit exceeded",
176+ }},
177+ })
178+}
179+
180+// getClientIP extracts the client IP address from the request.
181+// It only honors X-Forwarded-For and X-Real-IP headers when the request
182+// originates from a trusted proxy. This prevents attackers from spoofing
183+// their IP address when Gordon is exposed directly without a reverse proxy.
184+func getClientIP(r *http.Request, trustedNets []*net.IPNet) string {
185+ // Extract the direct connection IP from RemoteAddr
186+ remoteIP, _, err := net.SplitHostPort(r.RemoteAddr)
187+ if err != nil {
188+ remoteIP = r.RemoteAddr
189+ }
190+
191+ // Only honor proxy headers if the request comes from a trusted proxy
192+ if isTrustedProxy(remoteIP, trustedNets) {
193+ // Check X-Forwarded-For for proxied requests
194+ if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
195+ // Take first IP in chain (original client)
196+ if first, _, found := strings.Cut(xff, ","); found {
197+ return strings.TrimSpace(first)
198+ }
199+ return strings.TrimSpace(xff)
200+ }
201+
202+ // Check X-Real-IP
203+ if xri := r.Header.Get("X-Real-IP"); xri != "" {
204+ return xri
205+ }
206+ }
207+
208+ // Fall back to RemoteAddr (direct connection IP)
209+ return remoteIP
210+}
@@ -0,0 +1,210 @@
1+// Package registry implements the HTTP adapter for the registry API.
2+package registry
3+
4+import (
5+ "encoding/json"
6+ "net"
7+ "net/http"
8+ "strings"
9+ "sync"
10+
11+ "golang.org/x/time/rate"
12+
13+ "gordon/internal/adapters/dto"
14+)
15+
16+// RateLimitConfig holds rate limiting configuration for the registry API.
17+// These settings help prevent DoS attacks and brute force attempts.
18+type RateLimitConfig struct {
19+ // Enabled controls whether rate limiting is active.
20+ // Default: true
21+ Enabled bool
22+
23+ // GlobalRPS is the maximum requests per second across all clients.
24+ // Default: 500
25+ GlobalRPS float64
26+
27+ // PerIPRPS is the maximum requests per second per client IP.
28+ // Default: 50
29+ PerIPRPS float64
30+
31+ // Burst is the maximum burst size for rate limiters.
32+ // Default: 100
33+ Burst int
34+
35+ // TrustedProxies is a list of IP addresses or CIDR ranges that are trusted
36+ // to set X-Forwarded-For and X-Real-IP headers. If empty, these headers are
37+ // ignored and RemoteAddr is always used (secure default for direct exposure).
38+ // Example: ["127.0.0.1", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
39+ TrustedProxies []string
40+}
41+
42+// DefaultRateLimitConfig returns sensible default rate limiting configuration.
43+func DefaultRateLimitConfig() RateLimitConfig {
44+ return RateLimitConfig{
45+ Enabled: true,
46+ GlobalRPS: 500, // 500 requests/second globally
47+ PerIPRPS: 50, // 50 requests/second per IP
48+ Burst: 100, // Allow burst of 100 requests
49+ }
50+}
51+
52+// ipRateLimiter manages per-IP rate limiters.
53+type ipRateLimiter struct {
54+ limiters map[string]*rate.Limiter
55+ mu sync.RWMutex
56+ rps float64
57+ burst int
58+}
59+
60+func newIPRateLimiter(rps float64, burst int) *ipRateLimiter {
61+ return &ipRateLimiter{
62+ limiters: make(map[string]*rate.Limiter),
63+ rps: rps,
64+ burst: burst,
65+ }
66+}
67+
68+func (l *ipRateLimiter) getLimiter(ip string) *rate.Limiter {
69+ l.mu.RLock()
70+ limiter, exists := l.limiters[ip]
71+ l.mu.RUnlock()
72+
73+ if exists {
74+ return limiter
75+ }
76+
77+ l.mu.Lock()
78+ defer l.mu.Unlock()
79+
80+ // Double-check after acquiring write lock
81+ if limiter, exists = l.limiters[ip]; exists {
82+ return limiter
83+ }
84+
85+ limiter = rate.NewLimiter(rate.Limit(l.rps), l.burst)
86+ l.limiters[ip] = limiter
87+ return limiter
88+}
89+
90+// RateLimitMiddleware creates rate limiting middleware for the registry API.
91+// Returns nil if rate limiting is disabled.
92+func RateLimitMiddleware(config RateLimitConfig) func(http.Handler) http.Handler {
93+ if !config.Enabled {
94+ return func(next http.Handler) http.Handler {
95+ return next // Pass through if disabled
96+ }
97+ }
98+
99+ globalLimiter := rate.NewLimiter(rate.Limit(config.GlobalRPS), config.Burst)
100+ ipLimiter := newIPRateLimiter(config.PerIPRPS, config.Burst)
101+
102+ // Parse trusted proxy CIDRs once at middleware creation
103+ trustedNets := parseTrustedProxies(config.TrustedProxies)
104+
105+ return func(next http.Handler) http.Handler {
106+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
107+ // Check global rate limit
108+ if !globalLimiter.Allow() {
109+ sendRateLimitError(w)
110+ return
111+ }
112+
113+ // Check per-IP rate limit
114+ ip := getClientIP(r, trustedNets)
115+ if !ipLimiter.getLimiter(ip).Allow() {
116+ sendRateLimitError(w)
117+ return
118+ }
119+
120+ next.ServeHTTP(w, r)
121+ })
122+ }
123+}
124+
125+// parseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet.
126+func parseTrustedProxies(proxies []string) []*net.IPNet {
127+ var nets []*net.IPNet
128+ for _, proxy := range proxies {
129+ // Try parsing as CIDR
130+ _, ipNet, err := net.ParseCIDR(proxy)
131+ if err == nil {
132+ nets = append(nets, ipNet)
133+ continue
134+ }
135+ // Try parsing as single IP
136+ ip := net.ParseIP(proxy)
137+ if ip != nil {
138+ // Convert single IP to /32 or /128 CIDR
139+ bits := 32
140+ if ip.To4() == nil {
141+ bits = 128
142+ }
143+ nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
144+ }
145+ }
146+ return nets
147+}
148+
149+// isTrustedProxy checks if the given IP is from a trusted proxy.
150+func isTrustedProxy(ip string, trustedNets []*net.IPNet) bool {
151+ if len(trustedNets) == 0 {
152+ return false
153+ }
154+ parsedIP := net.ParseIP(ip)
155+ if parsedIP == nil {
156+ return false
157+ }
158+ for _, ipNet := range trustedNets {
159+ if ipNet.Contains(parsedIP) {
160+ return true
161+ }
162+ }
163+ return false
164+}
165+
166+// sendRateLimitError sends an HTTP 429 response in Docker Registry API format.
167+func sendRateLimitError(w http.ResponseWriter) {
168+ w.Header().Set("Content-Type", "application/json")
169+ w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
170+ w.Header().Set("Retry-After", "1")
171+ w.WriteHeader(http.StatusTooManyRequests)
172+ _ = json.NewEncoder(w).Encode(dto.RegistryErrorResponse{
173+ Errors: []dto.RegistryErrorItem{{
174+ Code: "TOOMANYREQUESTS",
175+ Message: "rate limit exceeded",
176+ }},
177+ })
178+}
179+
180+// getClientIP extracts the client IP address from the request.
181+// It only honors X-Forwarded-For and X-Real-IP headers when the request
182+// originates from a trusted proxy. This prevents attackers from spoofing
183+// their IP address when Gordon is exposed directly without a reverse proxy.
184+func getClientIP(r *http.Request, trustedNets []*net.IPNet) string {
185+ // Extract the direct connection IP from RemoteAddr
186+ remoteIP, _, err := net.SplitHostPort(r.RemoteAddr)
187+ if err != nil {
188+ remoteIP = r.RemoteAddr
189+ }
190+
191+ // Only honor proxy headers if the request comes from a trusted proxy
192+ if isTrustedProxy(remoteIP, trustedNets) {
193+ // Check X-Forwarded-For for proxied requests
194+ if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
195+ // Take first IP in chain (original client)
196+ if first, _, found := strings.Cut(xff, ","); found {
197+ return strings.TrimSpace(first)
198+ }
199+ return strings.TrimSpace(xff)
200+ }
201+
202+ // Check X-Real-IP
203+ if xri := r.Header.Get("X-Real-IP"); xri != "" {
204+ return xri
205+ }
206+ }
207+
208+ // Fall back to RemoteAddr (direct connection IP)
209+ return remoteIP
210+}
A internal/adapters/in/http/registry/ratelimit_test.go +297 -0
@@ -0,0 +1,297 @@
1+package registry
2+
3+import (
4+ "net"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestRateLimitMiddleware_Disabled(t *testing.T) {
14+ config := RateLimitConfig{
15+ Enabled: false,
16+ }
17+
18+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19+ w.WriteHeader(http.StatusOK)
20+ })
21+
22+ middleware := RateLimitMiddleware(config)
23+ wrappedHandler := middleware(handler)
24+
25+ // Should pass through without rate limiting
26+ for i := 0; i < 1000; i++ {
27+ req := httptest.NewRequest(http.MethodGet, "/v2/", nil)
28+ rec := httptest.NewRecorder()
29+ wrappedHandler.ServeHTTP(rec, req)
30+ assert.Equal(t, http.StatusOK, rec.Code)
31+ }
32+}
33+
34+func TestRateLimitMiddleware_PerIPLimit(t *testing.T) {
35+ config := RateLimitConfig{
36+ Enabled: true,
37+ GlobalRPS: 1000, // High global limit
38+ PerIPRPS: 1, // Very low per-IP limit for testing
39+ Burst: 100, // High burst to not interfere
40+ }
41+
42+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
43+ w.WriteHeader(http.StatusOK)
44+ })
45+
46+ middleware := RateLimitMiddleware(config)
47+ wrappedHandler := middleware(handler)
48+
49+ // First request should succeed
50+ req1 := httptest.NewRequest(http.MethodGet, "/v2/", nil)
51+ req1.RemoteAddr = "192.168.1.100:12345"
52+ rec1 := httptest.NewRecorder()
53+ wrappedHandler.ServeHTTP(rec1, req1)
54+ assert.Equal(t, http.StatusOK, rec1.Code)
55+
56+ // Request from different IP should succeed (independent rate limiter)
57+ req2 := httptest.NewRequest(http.MethodGet, "/v2/", nil)
58+ req2.RemoteAddr = "192.168.1.101:12345"
59+ rec2 := httptest.NewRecorder()
60+ wrappedHandler.ServeHTTP(rec2, req2)
61+ assert.Equal(t, http.StatusOK, rec2.Code)
62+}
63+
64+func TestRateLimitMiddleware_GlobalLimit(t *testing.T) {
65+ config := RateLimitConfig{
66+ Enabled: true,
67+ GlobalRPS: 1, // Very low global limit for testing
68+ PerIPRPS: 100,
69+ Burst: 1,
70+ }
71+
72+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
73+ w.WriteHeader(http.StatusOK)
74+ })
75+
76+ middleware := RateLimitMiddleware(config)
77+ wrappedHandler := middleware(handler)
78+
79+ // First request should succeed
80+ req1 := httptest.NewRequest(http.MethodGet, "/v2/", nil)
81+ req1.RemoteAddr = "192.168.1.100:12345"
82+ rec1 := httptest.NewRecorder()
83+ wrappedHandler.ServeHTTP(rec1, req1)
84+ assert.Equal(t, http.StatusOK, rec1.Code)
85+
86+ // Second request should hit global limit even from different IP
87+ req2 := httptest.NewRequest(http.MethodGet, "/v2/", nil)
88+ req2.RemoteAddr = "192.168.1.101:12345"
89+ rec2 := httptest.NewRecorder()
90+ wrappedHandler.ServeHTTP(rec2, req2)
91+ assert.Equal(t, http.StatusTooManyRequests, rec2.Code)
92+}
93+
94+func TestRateLimitMiddleware_ResponseFormat(t *testing.T) {
95+ config := RateLimitConfig{
96+ Enabled: true,
97+ GlobalRPS: 1,
98+ PerIPRPS: 1,
99+ Burst: 1,
100+ }
101+
102+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
103+ w.WriteHeader(http.StatusOK)
104+ })
105+
106+ middleware := RateLimitMiddleware(config)
107+ wrappedHandler := middleware(handler)
108+
109+ // First request succeeds
110+ req1 := httptest.NewRequest(http.MethodGet, "/v2/", nil)
111+ req1.RemoteAddr = "192.168.1.100:12345"
112+ rec1 := httptest.NewRecorder()
113+ wrappedHandler.ServeHTTP(rec1, req1)
114+
115+ // Second request gets rate limited
116+ req2 := httptest.NewRequest(http.MethodGet, "/v2/", nil)
117+ req2.RemoteAddr = "192.168.1.100:12346"
118+ rec2 := httptest.NewRecorder()
119+ wrappedHandler.ServeHTTP(rec2, req2)
120+
121+ // Verify response format
122+ assert.Equal(t, http.StatusTooManyRequests, rec2.Code)
123+ assert.Equal(t, "application/json", rec2.Header().Get("Content-Type"))
124+ assert.Equal(t, "registry/2.0", rec2.Header().Get("Docker-Distribution-API-Version"))
125+ assert.Equal(t, "1", rec2.Header().Get("Retry-After"))
126+ assert.Contains(t, rec2.Body.String(), "TOOMANYREQUESTS")
127+}
128+
129+func TestGetClientIP(t *testing.T) {
130+ // Parse trusted proxies for tests
131+ trustedNets := parseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"})
132+ noTrustedNets := []*net.IPNet{}
133+
134+ tests := []struct {
135+ name string
136+ remoteAddr string
137+ xff string
138+ xRealIP string
139+ trustedNets []*net.IPNet
140+ wantIP string
141+ }{
142+ {
143+ name: "from RemoteAddr with port",
144+ remoteAddr: "192.168.1.100:12345",
145+ trustedNets: noTrustedNets,
146+ wantIP: "192.168.1.100",
147+ },
148+ {
149+ name: "from RemoteAddr without port",
150+ remoteAddr: "192.168.1.100",
151+ trustedNets: noTrustedNets,
152+ wantIP: "192.168.1.100",
153+ },
154+ {
155+ name: "XFF ignored when no trusted proxies",
156+ remoteAddr: "192.168.1.100:12345",
157+ xff: "203.0.113.50",
158+ trustedNets: noTrustedNets,
159+ wantIP: "192.168.1.100",
160+ },
161+ {
162+ name: "XFF ignored when remote is not trusted",
163+ remoteAddr: "192.168.1.100:12345",
164+ xff: "203.0.113.50",
165+ trustedNets: trustedNets, // trusts 127.0.0.1 and 10.0.0.0/8
166+ wantIP: "192.168.1.100",
167+ },
168+ {
169+ name: "XFF honored from trusted proxy (single IP)",
170+ remoteAddr: "127.0.0.1:12345",
171+ xff: "203.0.113.50",
172+ trustedNets: trustedNets,
173+ wantIP: "203.0.113.50",
174+ },
175+ {
176+ name: "XFF honored from trusted proxy (multiple IPs)",
177+ remoteAddr: "127.0.0.1:12345",
178+ xff: "203.0.113.50, 10.0.0.1, 172.16.0.1",
179+ trustedNets: trustedNets,
180+ wantIP: "203.0.113.50",
181+ },
182+ {
183+ name: "XFF honored from trusted CIDR",
184+ remoteAddr: "10.1.2.3:12345",
185+ xff: "203.0.113.50",
186+ trustedNets: trustedNets,
187+ wantIP: "203.0.113.50",
188+ },
189+ {
190+ name: "X-Real-IP honored from trusted proxy",
191+ remoteAddr: "127.0.0.1:12345",
192+ xRealIP: "203.0.113.50",
193+ trustedNets: trustedNets,
194+ wantIP: "203.0.113.50",
195+ },
196+ {
197+ name: "X-Real-IP ignored when not trusted",
198+ remoteAddr: "192.168.1.100:12345",
199+ xRealIP: "203.0.113.50",
200+ trustedNets: trustedNets,
201+ wantIP: "192.168.1.100",
202+ },
203+ {
204+ name: "XFF takes precedence over X-Real-IP",
205+ remoteAddr: "127.0.0.1:12345",
206+ xff: "203.0.113.50",
207+ xRealIP: "203.0.113.60",
208+ trustedNets: trustedNets,
209+ wantIP: "203.0.113.50",
210+ },
211+ }
212+
213+ for _, tt := range tests {
214+ t.Run(tt.name, func(t *testing.T) {
215+ req := httptest.NewRequest(http.MethodGet, "/", nil)
216+ req.RemoteAddr = tt.remoteAddr
217+ if tt.xff != "" {
218+ req.Header.Set("X-Forwarded-For", tt.xff)
219+ }
220+ if tt.xRealIP != "" {
221+ req.Header.Set("X-Real-IP", tt.xRealIP)
222+ }
223+
224+ ip := getClientIP(req, tt.trustedNets)
225+ assert.Equal(t, tt.wantIP, ip)
226+ })
227+ }
228+}
229+
230+func TestParseTrustedProxies(t *testing.T) {
231+ tests := []struct {
232+ name string
233+ proxies []string
234+ testIP string
235+ want bool
236+ }{
237+ {
238+ name: "empty list",
239+ proxies: []string{},
240+ testIP: "192.168.1.1",
241+ want: false,
242+ },
243+ {
244+ name: "single IP match",
245+ proxies: []string{"192.168.1.1"},
246+ testIP: "192.168.1.1",
247+ want: true,
248+ },
249+ {
250+ name: "single IP no match",
251+ proxies: []string{"192.168.1.1"},
252+ testIP: "192.168.1.2",
253+ want: false,
254+ },
255+ {
256+ name: "CIDR match",
257+ proxies: []string{"10.0.0.0/8"},
258+ testIP: "10.1.2.3",
259+ want: true,
260+ },
261+ {
262+ name: "CIDR no match",
263+ proxies: []string{"10.0.0.0/8"},
264+ testIP: "192.168.1.1",
265+ want: false,
266+ },
267+ {
268+ name: "mixed IP and CIDR",
269+ proxies: []string{"127.0.0.1", "10.0.0.0/8", "172.16.0.0/12"},
270+ testIP: "172.20.1.1",
271+ want: true,
272+ },
273+ {
274+ name: "invalid entries ignored",
275+ proxies: []string{"not-an-ip", "10.0.0.0/8"},
276+ testIP: "10.1.2.3",
277+ want: true,
278+ },
279+ }
280+
281+ for _, tt := range tests {
282+ t.Run(tt.name, func(t *testing.T) {
283+ nets := parseTrustedProxies(tt.proxies)
284+ got := isTrustedProxy(tt.testIP, nets)
285+ assert.Equal(t, tt.want, got)
286+ })
287+ }
288+}
289+
290+func TestDefaultRateLimitConfig(t *testing.T) {
291+ config := DefaultRateLimitConfig()
292+
293+ require.True(t, config.Enabled)
294+ assert.Equal(t, float64(500), config.GlobalRPS)
295+ assert.Equal(t, float64(50), config.PerIPRPS)
296+ assert.Equal(t, 100, config.Burst)
297+}
@@ -0,0 +1,297 @@
1+package registry
2+
3+import (
4+ "net"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestRateLimitMiddleware_Disabled(t *testing.T) {
14+ config := RateLimitConfig{
15+ Enabled: false,
16+ }
17+
18+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19+ w.WriteHeader(http.StatusOK)
20+ })
21+
22+ middleware := RateLimitMiddleware(config)
23+ wrappedHandler := middleware(handler)
24+
25+ // Should pass through without rate limiting
26+ for i := 0; i < 1000; i++ {
27+ req := httptest.NewRequest(http.MethodGet, "/v2/", nil)
28+ rec := httptest.NewRecorder()
29+ wrappedHandler.ServeHTTP(rec, req)
30+ assert.Equal(t, http.StatusOK, rec.Code)
31+ }
32+}
33+
34+func TestRateLimitMiddleware_PerIPLimit(t *testing.T) {
35+ config := RateLimitConfig{
36+ Enabled: true,
37+ GlobalRPS: 1000, // High global limit
38+ PerIPRPS: 1, // Very low per-IP limit for testing
39+ Burst: 100, // High burst to not interfere
40+ }
41+
42+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
43+ w.WriteHeader(http.StatusOK)
44+ })
45+
46+ middleware := RateLimitMiddleware(config)
47+ wrappedHandler := middleware(handler)
48+
49+ // First request should succeed
50+ req1 := httptest.NewRequest(http.MethodGet, "/v2/", nil)
51+ req1.RemoteAddr = "192.168.1.100:12345"
52+ rec1 := httptest.NewRecorder()
53+ wrappedHandler.ServeHTTP(rec1, req1)
54+ assert.Equal(t, http.StatusOK, rec1.Code)
55+
56+ // Request from different IP should succeed (independent rate limiter)
57+ req2 := httptest.NewRequest(http.MethodGet, "/v2/", nil)
58+ req2.RemoteAddr = "192.168.1.101:12345"
59+ rec2 := httptest.NewRecorder()
60+ wrappedHandler.ServeHTTP(rec2, req2)
61+ assert.Equal(t, http.StatusOK, rec2.Code)
62+}
63+
64+func TestRateLimitMiddleware_GlobalLimit(t *testing.T) {
65+ config := RateLimitConfig{
66+ Enabled: true,
67+ GlobalRPS: 1, // Very low global limit for testing
68+ PerIPRPS: 100,
69+ Burst: 1,
70+ }
71+
72+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
73+ w.WriteHeader(http.StatusOK)
74+ })
75+
76+ middleware := RateLimitMiddleware(config)
77+ wrappedHandler := middleware(handler)
78+
79+ // First request should succeed
80+ req1 := httptest.NewRequest(http.MethodGet, "/v2/", nil)
81+ req1.RemoteAddr = "192.168.1.100:12345"
82+ rec1 := httptest.NewRecorder()
83+ wrappedHandler.ServeHTTP(rec1, req1)
84+ assert.Equal(t, http.StatusOK, rec1.Code)
85+
86+ // Second request should hit global limit even from different IP
87+ req2 := httptest.NewRequest(http.MethodGet, "/v2/", nil)
88+ req2.RemoteAddr = "192.168.1.101:12345"
89+ rec2 := httptest.NewRecorder()
90+ wrappedHandler.ServeHTTP(rec2, req2)
91+ assert.Equal(t, http.StatusTooManyRequests, rec2.Code)
92+}
93+
94+func TestRateLimitMiddleware_ResponseFormat(t *testing.T) {
95+ config := RateLimitConfig{
96+ Enabled: true,
97+ GlobalRPS: 1,
98+ PerIPRPS: 1,
99+ Burst: 1,
100+ }
101+
102+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
103+ w.WriteHeader(http.StatusOK)
104+ })
105+
106+ middleware := RateLimitMiddleware(config)
107+ wrappedHandler := middleware(handler)
108+
109+ // First request succeeds
110+ req1 := httptest.NewRequest(http.MethodGet, "/v2/", nil)
111+ req1.RemoteAddr = "192.168.1.100:12345"
112+ rec1 := httptest.NewRecorder()
113+ wrappedHandler.ServeHTTP(rec1, req1)
114+
115+ // Second request gets rate limited
116+ req2 := httptest.NewRequest(http.MethodGet, "/v2/", nil)
117+ req2.RemoteAddr = "192.168.1.100:12346"
118+ rec2 := httptest.NewRecorder()
119+ wrappedHandler.ServeHTTP(rec2, req2)
120+
121+ // Verify response format
122+ assert.Equal(t, http.StatusTooManyRequests, rec2.Code)
123+ assert.Equal(t, "application/json", rec2.Header().Get("Content-Type"))
124+ assert.Equal(t, "registry/2.0", rec2.Header().Get("Docker-Distribution-API-Version"))
125+ assert.Equal(t, "1", rec2.Header().Get("Retry-After"))
126+ assert.Contains(t, rec2.Body.String(), "TOOMANYREQUESTS")
127+}
128+
129+func TestGetClientIP(t *testing.T) {
130+ // Parse trusted proxies for tests
131+ trustedNets := parseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"})
132+ noTrustedNets := []*net.IPNet{}
133+
134+ tests := []struct {
135+ name string
136+ remoteAddr string
137+ xff string
138+ xRealIP string
139+ trustedNets []*net.IPNet
140+ wantIP string
141+ }{
142+ {
143+ name: "from RemoteAddr with port",
144+ remoteAddr: "192.168.1.100:12345",
145+ trustedNets: noTrustedNets,
146+ wantIP: "192.168.1.100",
147+ },
148+ {
149+ name: "from RemoteAddr without port",
150+ remoteAddr: "192.168.1.100",
151+ trustedNets: noTrustedNets,
152+ wantIP: "192.168.1.100",
153+ },
154+ {
155+ name: "XFF ignored when no trusted proxies",
156+ remoteAddr: "192.168.1.100:12345",
157+ xff: "203.0.113.50",
158+ trustedNets: noTrustedNets,
159+ wantIP: "192.168.1.100",
160+ },
161+ {
162+ name: "XFF ignored when remote is not trusted",
163+ remoteAddr: "192.168.1.100:12345",
164+ xff: "203.0.113.50",
165+ trustedNets: trustedNets, // trusts 127.0.0.1 and 10.0.0.0/8
166+ wantIP: "192.168.1.100",
167+ },
168+ {
169+ name: "XFF honored from trusted proxy (single IP)",
170+ remoteAddr: "127.0.0.1:12345",
171+ xff: "203.0.113.50",
172+ trustedNets: trustedNets,
173+ wantIP: "203.0.113.50",
174+ },
175+ {
176+ name: "XFF honored from trusted proxy (multiple IPs)",
177+ remoteAddr: "127.0.0.1:12345",
178+ xff: "203.0.113.50, 10.0.0.1, 172.16.0.1",
179+ trustedNets: trustedNets,
180+ wantIP: "203.0.113.50",
181+ },
182+ {
183+ name: "XFF honored from trusted CIDR",
184+ remoteAddr: "10.1.2.3:12345",
185+ xff: "203.0.113.50",
186+ trustedNets: trustedNets,
187+ wantIP: "203.0.113.50",
188+ },
189+ {
190+ name: "X-Real-IP honored from trusted proxy",
191+ remoteAddr: "127.0.0.1:12345",
192+ xRealIP: "203.0.113.50",
193+ trustedNets: trustedNets,
194+ wantIP: "203.0.113.50",
195+ },
196+ {
197+ name: "X-Real-IP ignored when not trusted",
198+ remoteAddr: "192.168.1.100:12345",
199+ xRealIP: "203.0.113.50",
200+ trustedNets: trustedNets,
201+ wantIP: "192.168.1.100",
202+ },
203+ {
204+ name: "XFF takes precedence over X-Real-IP",
205+ remoteAddr: "127.0.0.1:12345",
206+ xff: "203.0.113.50",
207+ xRealIP: "203.0.113.60",
208+ trustedNets: trustedNets,
209+ wantIP: "203.0.113.50",
210+ },
211+ }
212+
213+ for _, tt := range tests {
214+ t.Run(tt.name, func(t *testing.T) {
215+ req := httptest.NewRequest(http.MethodGet, "/", nil)
216+ req.RemoteAddr = tt.remoteAddr
217+ if tt.xff != "" {
218+ req.Header.Set("X-Forwarded-For", tt.xff)
219+ }
220+ if tt.xRealIP != "" {
221+ req.Header.Set("X-Real-IP", tt.xRealIP)
222+ }
223+
224+ ip := getClientIP(req, tt.trustedNets)
225+ assert.Equal(t, tt.wantIP, ip)
226+ })
227+ }
228+}
229+
230+func TestParseTrustedProxies(t *testing.T) {
231+ tests := []struct {
232+ name string
233+ proxies []string
234+ testIP string
235+ want bool
236+ }{
237+ {
238+ name: "empty list",
239+ proxies: []string{},
240+ testIP: "192.168.1.1",
241+ want: false,
242+ },
243+ {
244+ name: "single IP match",
245+ proxies: []string{"192.168.1.1"},
246+ testIP: "192.168.1.1",
247+ want: true,
248+ },
249+ {
250+ name: "single IP no match",
251+ proxies: []string{"192.168.1.1"},
252+ testIP: "192.168.1.2",
253+ want: false,
254+ },
255+ {
256+ name: "CIDR match",
257+ proxies: []string{"10.0.0.0/8"},
258+ testIP: "10.1.2.3",
259+ want: true,
260+ },
261+ {
262+ name: "CIDR no match",
263+ proxies: []string{"10.0.0.0/8"},
264+ testIP: "192.168.1.1",
265+ want: false,
266+ },
267+ {
268+ name: "mixed IP and CIDR",
269+ proxies: []string{"127.0.0.1", "10.0.0.0/8", "172.16.0.0/12"},
270+ testIP: "172.20.1.1",
271+ want: true,
272+ },
273+ {
274+ name: "invalid entries ignored",
275+ proxies: []string{"not-an-ip", "10.0.0.0/8"},
276+ testIP: "10.1.2.3",
277+ want: true,
278+ },
279+ }
280+
281+ for _, tt := range tests {
282+ t.Run(tt.name, func(t *testing.T) {
283+ nets := parseTrustedProxies(tt.proxies)
284+ got := isTrustedProxy(tt.testIP, nets)
285+ assert.Equal(t, tt.want, got)
286+ })
287+ }
288+}
289+
290+func TestDefaultRateLimitConfig(t *testing.T) {
291+ config := DefaultRateLimitConfig()
292+
293+ require.True(t, config.Enabled)
294+ assert.Equal(t, float64(500), config.GlobalRPS)
295+ assert.Equal(t, float64(50), config.PerIPRPS)
296+ assert.Equal(t, 100, config.Burst)
297+}
M internal/adapters/out/domainsecrets/store.go +90 -16
@@ -6,9 +6,12 @@ import (
66 "fmt"
77 "os"
88 "path/filepath"
9+ "regexp"
910 "strings"
1011
1112 "github.com/bnema/zerowrap"
13+
14+ "gordon/internal/domain"
1215 )
1316
1417 // FileStore implements the DomainSecretStore interface using filesystem-based env files.
@@ -39,8 +42,11 @@ func NewFileStore(envDir string, log zerowrap.Logger) (*FileStore, error) {
3942 }
4043
4144 // ListKeys returns the list of secret keys for a domain (not values).
42-func (s *FileStore) ListKeys(domain string) ([]string, error) {
43- envFile := s.getEnvFilePath(domain)
45+func (s *FileStore) ListKeys(domainName string) ([]string, error) {
46+ envFile, err := s.validateEnvFilePath(domainName)
47+ if err != nil {
48+ return nil, err
49+ }
4450
4551 file, err := os.Open(envFile)
4652 if err != nil {
@@ -73,8 +79,11 @@ func (s *FileStore) ListKeys(domain string) ([]string, error) {
7379 }
7480
7581 // GetAll returns all secrets for a domain as a key-value map.
76-func (s *FileStore) GetAll(domain string) (map[string]string, error) {
77- envFile := s.getEnvFilePath(domain)
82+func (s *FileStore) GetAll(domainName string) (map[string]string, error) {
83+ envFile, err := s.validateEnvFilePath(domainName)
84+ if err != nil {
85+ return nil, err
86+ }
7887
7988 file, err := os.Open(envFile)
8089 if err != nil {
@@ -109,14 +118,19 @@ func (s *FileStore) GetAll(domain string) (map[string]string, error) {
109118 }
110119
111120 // Set sets or updates multiple secrets for a domain, merging with existing.
112-func (s *FileStore) Set(domain string, secrets map[string]string) error {
121+func (s *FileStore) Set(domainName string, secrets map[string]string) error {
122+ // Validate domain first
123+ if _, err := s.validateEnvFilePath(domainName); err != nil {
124+ return err
125+ }
126+
113127 // Ensure env directory exists
114128 if err := os.MkdirAll(s.envDir, 0700); err != nil {
115129 return fmt.Errorf("failed to create env directory: %w", err)
116130 }
117131
118132 // Read existing secrets
119- existing, err := s.GetAll(domain)
133+ existing, err := s.GetAll(domainName)
120134 if err != nil {
121135 return err
122136 }
@@ -127,13 +141,18 @@ func (s *FileStore) Set(domain string, secrets map[string]string) error {
127141 }
128142
129143 // Write back atomically
130- return s.writeSecretsAtomic(domain, existing)
144+ return s.writeSecretsAtomic(domainName, existing)
131145 }
132146
133147 // Delete removes a specific secret key from a domain.
134-func (s *FileStore) Delete(domain, key string) error {
148+func (s *FileStore) Delete(domainName, key string) error {
149+ // Validate domain first
150+ if _, err := s.validateEnvFilePath(domainName); err != nil {
151+ return err
152+ }
153+
135154 // Read existing secrets
136- existing, err := s.GetAll(domain)
155+ existing, err := s.GetAll(domainName)
137156 if err != nil {
138157 return err
139158 }
@@ -142,13 +161,16 @@ func (s *FileStore) Delete(domain, key string) error {
142161 delete(existing, key)
143162
144163 // Write back atomically
145- return s.writeSecretsAtomic(domain, existing)
164+ return s.writeSecretsAtomic(domainName, existing)
146165 }
147166
148167 // writeSecretsAtomic writes all secrets to the domain's env file atomically.
149168 // It writes to a temporary file first, syncs it, then renames to the final path.
150-func (s *FileStore) writeSecretsAtomic(domain string, secrets map[string]string) error {
151- envFile := s.getEnvFilePath(domain)
169+func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error {
170+ envFile, err := s.validateEnvFilePath(domainName)
171+ if err != nil {
172+ return err
173+ }
152174 tmpFile := envFile + ".tmp"
153175
154176 file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
@@ -157,7 +179,7 @@ func (s *FileStore) writeSecretsAtomic(domain string, secrets map[string]string)
157179 }
158180
159181 // Write header comment
160- if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domain); err != nil {
182+ if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domainName); err != nil {
161183 file.Close()
162184 os.Remove(tmpFile)
163185 return fmt.Errorf("failed to write header: %w", err)
@@ -198,12 +220,64 @@ func (s *FileStore) writeSecretsAtomic(domain string, secrets map[string]string)
198220 return nil
199221 }
200222
223+// domainRegex validates domain names to prevent path injection.
224+// Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths).
225+var domainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)
226+
201227 // getEnvFilePath converts a domain to its env file path.
202228 // This must match the naming convention in envloader.FileLoader.getEnvFilePath.
203-func (s *FileStore) getEnvFilePath(domain string) string {
229+//
230+// SECURITY: Validates domain and ensures the resulting path stays within envDir.
231+func (s *FileStore) getEnvFilePath(domainName string) string {
232+ // SECURITY: Reject domains that look like path traversal attempts
233+ if strings.Contains(domainName, "..") {
234+ s.log.Warn().
235+ Str(zerowrap.FieldLayer, "adapter").
236+ Str(zerowrap.FieldAdapter, "domainsecrets").
237+ Str("domain", domainName).
238+ Msg("rejected domain with path traversal sequence")
239+ return ""
240+ }
241+
242+ // SECURITY: Validate domain format
243+ if !domainRegex.MatchString(domainName) {
244+ s.log.Warn().
245+ Str(zerowrap.FieldLayer, "adapter").
246+ Str(zerowrap.FieldAdapter, "domainsecrets").
247+ Str("domain", domainName).
248+ Msg("rejected invalid domain format")
249+ return ""
250+ }
251+
204252 // Create domain-safe filename (replace dots and other chars with underscores)
205- safeDomain := strings.ReplaceAll(domain, ".", "_")
253+ safeDomain := strings.ReplaceAll(domainName, ".", "_")
206254 safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
207255 safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
208- return filepath.Join(s.envDir, safeDomain+".env")
256+
257+ fullPath := filepath.Join(s.envDir, safeDomain+".env")
258+
259+ // SECURITY: Final validation - ensure path stays within envDir
260+ cleanPath := filepath.Clean(fullPath)
261+ cleanEnvDir := filepath.Clean(s.envDir)
262+ if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) {
263+ s.log.Error().
264+ Str(zerowrap.FieldLayer, "adapter").
265+ Str(zerowrap.FieldAdapter, "domainsecrets").
266+ Str("domain", domainName).
267+ Str("attempted_path", fullPath).
268+ Msg("path traversal attempt blocked - path escapes env directory")
269+ return ""
270+ }
271+
272+ return fullPath
273+}
274+
275+// validateEnvFilePath validates that a domain produces a valid env file path.
276+// Returns an error if the domain is invalid or would result in path traversal.
277+func (s *FileStore) validateEnvFilePath(domainName string) (string, error) {
278+ path := s.getEnvFilePath(domainName)
279+ if path == "" {
280+ return "", domain.ErrPathTraversal
281+ }
282+ return path, nil
209283 }
@@ -6,9 +6,12 @@ import (
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9+ "regexp"
9 "strings"10 "strings"
10 11
11 "github.com/bnema/zerowrap"12 "github.com/bnema/zerowrap"
13+
14+ "gordon/internal/domain"
12 )15 )
13 16
14 // FileStore implements the DomainSecretStore interface using filesystem-based env files.17 // FileStore implements the DomainSecretStore interface using filesystem-based env files.
@@ -39,8 +42,11 @@ func NewFileStore(envDir string, log zerowrap.Logger) (*FileStore, error) {
39 }42 }
40 43
41 // ListKeys returns the list of secret keys for a domain (not values).44 // ListKeys returns the list of secret keys for a domain (not values).
42-func (s *FileStore) ListKeys(domain string) ([]string, error) {45+func (s *FileStore) ListKeys(domainName string) ([]string, error) {
43- envFile := s.getEnvFilePath(domain)46+ envFile, err := s.validateEnvFilePath(domainName)
47+ if err != nil {
48+ return nil, err
49+ }
44 50
45 file, err := os.Open(envFile)51 file, err := os.Open(envFile)
46 if err != nil {52 if err != nil {
@@ -73,8 +79,11 @@ func (s *FileStore) ListKeys(domain string) ([]string, error) {
73 }79 }
74 80
75 // GetAll returns all secrets for a domain as a key-value map.81 // GetAll returns all secrets for a domain as a key-value map.
76-func (s *FileStore) GetAll(domain string) (map[string]string, error) {82+func (s *FileStore) GetAll(domainName string) (map[string]string, error) {
77- envFile := s.getEnvFilePath(domain)83+ envFile, err := s.validateEnvFilePath(domainName)
84+ if err != nil {
85+ return nil, err
86+ }
78 87
79 file, err := os.Open(envFile)88 file, err := os.Open(envFile)
80 if err != nil {89 if err != nil {
@@ -109,14 +118,19 @@ func (s *FileStore) GetAll(domain string) (map[string]string, error) {
109 }118 }
110 119
111 // Set sets or updates multiple secrets for a domain, merging with existing.120 // Set sets or updates multiple secrets for a domain, merging with existing.
112-func (s *FileStore) Set(domain string, secrets map[string]string) error {121+func (s *FileStore) Set(domainName string, secrets map[string]string) error {
122+ // Validate domain first
123+ if _, err := s.validateEnvFilePath(domainName); err != nil {
124+ return err
125+ }
126+
113 // Ensure env directory exists127 // Ensure env directory exists
114 if err := os.MkdirAll(s.envDir, 0700); err != nil {128 if err := os.MkdirAll(s.envDir, 0700); err != nil {
115 return fmt.Errorf("failed to create env directory: %w", err)129 return fmt.Errorf("failed to create env directory: %w", err)
116 }130 }
117 131
118 // Read existing secrets132 // Read existing secrets
119- existing, err := s.GetAll(domain)133+ existing, err := s.GetAll(domainName)
120 if err != nil {134 if err != nil {
121 return err135 return err
122 }136 }
@@ -127,13 +141,18 @@ func (s *FileStore) Set(domain string, secrets map[string]string) error {
127 }141 }
128 142
129 // Write back atomically143 // Write back atomically
130- return s.writeSecretsAtomic(domain, existing)144+ return s.writeSecretsAtomic(domainName, existing)
131 }145 }
132 146
133 // Delete removes a specific secret key from a domain.147 // Delete removes a specific secret key from a domain.
134-func (s *FileStore) Delete(domain, key string) error {148+func (s *FileStore) Delete(domainName, key string) error {
149+ // Validate domain first
150+ if _, err := s.validateEnvFilePath(domainName); err != nil {
151+ return err
152+ }
153+
135 // Read existing secrets154 // Read existing secrets
136- existing, err := s.GetAll(domain)155+ existing, err := s.GetAll(domainName)
137 if err != nil {156 if err != nil {
138 return err157 return err
139 }158 }
@@ -142,13 +161,16 @@ func (s *FileStore) Delete(domain, key string) error {
142 delete(existing, key)161 delete(existing, key)
143 162
144 // Write back atomically163 // Write back atomically
145- return s.writeSecretsAtomic(domain, existing)164+ return s.writeSecretsAtomic(domainName, existing)
146 }165 }
147 166
148 // writeSecretsAtomic writes all secrets to the domain's env file atomically.167 // writeSecretsAtomic writes all secrets to the domain's env file atomically.
149 // It writes to a temporary file first, syncs it, then renames to the final path.168 // It writes to a temporary file first, syncs it, then renames to the final path.
150-func (s *FileStore) writeSecretsAtomic(domain string, secrets map[string]string) error {169+func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error {
151- envFile := s.getEnvFilePath(domain)170+ envFile, err := s.validateEnvFilePath(domainName)
171+ if err != nil {
172+ return err
173+ }
152 tmpFile := envFile + ".tmp"174 tmpFile := envFile + ".tmp"
153 175
154 file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)176 file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
@@ -157,7 +179,7 @@ func (s *FileStore) writeSecretsAtomic(domain string, secrets map[string]string)
157 }179 }
158 180
159 // Write header comment181 // Write header comment
160- if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domain); err != nil {182+ if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domainName); err != nil {
161 file.Close()183 file.Close()
162 os.Remove(tmpFile)184 os.Remove(tmpFile)
163 return fmt.Errorf("failed to write header: %w", err)185 return fmt.Errorf("failed to write header: %w", err)
@@ -198,12 +220,64 @@ func (s *FileStore) writeSecretsAtomic(domain string, secrets map[string]string)
198 return nil220 return nil
199 }221 }
200 222
223+// domainRegex validates domain names to prevent path injection.
224+// Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths).
225+var domainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)
226+
201 // getEnvFilePath converts a domain to its env file path.227 // getEnvFilePath converts a domain to its env file path.
202 // This must match the naming convention in envloader.FileLoader.getEnvFilePath.228 // This must match the naming convention in envloader.FileLoader.getEnvFilePath.
203-func (s *FileStore) getEnvFilePath(domain string) string {229+//
230+// SECURITY: Validates domain and ensures the resulting path stays within envDir.
231+func (s *FileStore) getEnvFilePath(domainName string) string {
232+ // SECURITY: Reject domains that look like path traversal attempts
233+ if strings.Contains(domainName, "..") {
234+ s.log.Warn().
235+ Str(zerowrap.FieldLayer, "adapter").
236+ Str(zerowrap.FieldAdapter, "domainsecrets").
237+ Str("domain", domainName).
238+ Msg("rejected domain with path traversal sequence")
239+ return ""
240+ }
241+
242+ // SECURITY: Validate domain format
243+ if !domainRegex.MatchString(domainName) {
244+ s.log.Warn().
245+ Str(zerowrap.FieldLayer, "adapter").
246+ Str(zerowrap.FieldAdapter, "domainsecrets").
247+ Str("domain", domainName).
248+ Msg("rejected invalid domain format")
249+ return ""
250+ }
251+
204 // Create domain-safe filename (replace dots and other chars with underscores)252 // Create domain-safe filename (replace dots and other chars with underscores)
205- safeDomain := strings.ReplaceAll(domain, ".", "_")253+ safeDomain := strings.ReplaceAll(domainName, ".", "_")
206 safeDomain = strings.ReplaceAll(safeDomain, ":", "_")254 safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
207 safeDomain = strings.ReplaceAll(safeDomain, "/", "_")255 safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
208- return filepath.Join(s.envDir, safeDomain+".env")256+
257+ fullPath := filepath.Join(s.envDir, safeDomain+".env")
258+
259+ // SECURITY: Final validation - ensure path stays within envDir
260+ cleanPath := filepath.Clean(fullPath)
261+ cleanEnvDir := filepath.Clean(s.envDir)
262+ if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) {
263+ s.log.Error().
264+ Str(zerowrap.FieldLayer, "adapter").
265+ Str(zerowrap.FieldAdapter, "domainsecrets").
266+ Str("domain", domainName).
267+ Str("attempted_path", fullPath).
268+ Msg("path traversal attempt blocked - path escapes env directory")
269+ return ""
270+ }
271+
272+ return fullPath
273+}
274+
275+// validateEnvFilePath validates that a domain produces a valid env file path.
276+// Returns an error if the domain is invalid or would result in path traversal.
277+func (s *FileStore) validateEnvFilePath(domainName string) (string, error) {
278+ path := s.getEnvFilePath(domainName)
279+ if path == "" {
280+ return "", domain.ErrPathTraversal
281+ }
282+ return path, nil
209 }283 }
A internal/adapters/out/domainsecrets/store_test.go +200 -0
@@ -0,0 +1,200 @@
1+package domainsecrets
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "gordon/internal/domain"
14+)
15+
16+func testLogger() zerowrap.Logger {
17+ return zerowrap.Default()
18+}
19+
20+func TestFileStore_PathTraversal(t *testing.T) {
21+ tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
22+ require.NoError(t, err)
23+ defer os.RemoveAll(tmpDir)
24+
25+ store, err := NewFileStore(tmpDir, testLogger())
26+ require.NoError(t, err)
27+
28+ tests := []struct {
29+ name string
30+ domain string
31+ wantErr bool
32+ }{
33+ // Valid domains
34+ {"simple domain", "example.com", false},
35+ {"subdomain", "sub.example.com", false},
36+ {"domain with port", "example.com:8080", false},
37+ {"domain with path", "example.com/path", false},
38+ {"hyphenated domain", "my-app.example.com", false},
39+ {"single char", "a", false},
40+
41+ // Path traversal attempts - should fail
42+ {"path traversal with dots", "../../../etc/passwd", true},
43+ {"path traversal in middle", "example.com/../../../etc/passwd", true},
44+ {"double dots only", "..", true},
45+ {"path traversal at end", "example.com/..", true},
46+
47+ // Invalid format - should fail
48+ {"empty domain", "", true},
49+ {"starts with dot", ".example.com", true},
50+ {"ends with dot", "example.com.", true},
51+ {"special chars semicolon", "example;rm -rf /", true},
52+ {"null byte", "example\x00.com", true},
53+ {"newline", "example\n.com", true},
54+ }
55+
56+ for _, tt := range tests {
57+ t.Run(tt.name, func(t *testing.T) {
58+ // Test ListKeys
59+ _, err := store.ListKeys(tt.domain)
60+ if tt.wantErr {
61+ require.Error(t, err)
62+ assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
63+ } else {
64+ // No error, or file not found (which is fine)
65+ if err != nil {
66+ assert.False(t, errors.Is(err, domain.ErrPathTraversal), "unexpected ErrPathTraversal")
67+ }
68+ }
69+
70+ // Test Set
71+ err = store.Set(tt.domain, map[string]string{"KEY": "value"})
72+ if tt.wantErr {
73+ require.Error(t, err)
74+ assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
75+ } else {
76+ assert.NoError(t, err)
77+ }
78+
79+ // Test GetAll
80+ _, err = store.GetAll(tt.domain)
81+ if tt.wantErr {
82+ require.Error(t, err)
83+ assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
84+ }
85+
86+ // Test Delete
87+ err = store.Delete(tt.domain, "KEY")
88+ if tt.wantErr {
89+ require.Error(t, err)
90+ assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
91+ }
92+ })
93+ }
94+}
95+
96+func TestFileStore_PathContainment(t *testing.T) {
97+ tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
98+ require.NoError(t, err)
99+ defer os.RemoveAll(tmpDir)
100+
101+ store, err := NewFileStore(tmpDir, testLogger())
102+ require.NoError(t, err)
103+
104+ // Valid domain - file should be created inside tmpDir
105+ err = store.Set("example.com", map[string]string{"KEY": "value"})
106+ require.NoError(t, err)
107+
108+ // Verify file exists inside tmpDir
109+ expectedFile := filepath.Join(tmpDir, "example_com.env")
110+ _, err = os.Stat(expectedFile)
111+ assert.NoError(t, err, "expected env file to exist at %s", expectedFile)
112+
113+ // Verify no file was created outside tmpDir
114+ entries, err := filepath.Glob(tmpDir + "/../*")
115+ require.NoError(t, err)
116+ for _, entry := range entries {
117+ if filepath.Base(entry) != filepath.Base(tmpDir) {
118+ // Check it's not our test file escaped
119+ assert.NotContains(t, entry, ".env", "unexpected .env file outside tmpDir: %s", entry)
120+ }
121+ }
122+}
123+
124+func TestFileStore_ValidDomainOperations(t *testing.T) {
125+ tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
126+ require.NoError(t, err)
127+ defer os.RemoveAll(tmpDir)
128+
129+ store, err := NewFileStore(tmpDir, testLogger())
130+ require.NoError(t, err)
131+
132+ domain := "test.example.com"
133+
134+ // Set secrets
135+ err = store.Set(domain, map[string]string{
136+ "DB_HOST": "localhost",
137+ "DB_PASSWORD": "secret123",
138+ })
139+ require.NoError(t, err)
140+
141+ // Get all secrets
142+ secrets, err := store.GetAll(domain)
143+ require.NoError(t, err)
144+ assert.Equal(t, "localhost", secrets["DB_HOST"])
145+ assert.Equal(t, "secret123", secrets["DB_PASSWORD"])
146+
147+ // List keys
148+ keys, err := store.ListKeys(domain)
149+ require.NoError(t, err)
150+ assert.Len(t, keys, 2)
151+ assert.Contains(t, keys, "DB_HOST")
152+ assert.Contains(t, keys, "DB_PASSWORD")
153+
154+ // Delete a key
155+ err = store.Delete(domain, "DB_PASSWORD")
156+ require.NoError(t, err)
157+
158+ // Verify deletion
159+ secrets, err = store.GetAll(domain)
160+ require.NoError(t, err)
161+ assert.Len(t, secrets, 1)
162+ assert.Equal(t, "localhost", secrets["DB_HOST"])
163+ _, exists := secrets["DB_PASSWORD"]
164+ assert.False(t, exists)
165+}
166+
167+func TestGetEnvFilePath(t *testing.T) {
168+ tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
169+ require.NoError(t, err)
170+ defer os.RemoveAll(tmpDir)
171+
172+ store, err := NewFileStore(tmpDir, testLogger())
173+ require.NoError(t, err)
174+
175+ tests := []struct {
176+ domain string
177+ wantEmpty bool
178+ wantFilename string
179+ }{
180+ {"example.com", false, "example_com.env"},
181+ {"sub.example.com", false, "sub_example_com.env"},
182+ {"example.com:8080", false, "example_com_8080.env"},
183+ {"example.com/path", false, "example_com_path.env"},
184+ {"../evil", true, ""},
185+ {"", true, ""},
186+ }
187+
188+ for _, tt := range tests {
189+ t.Run(tt.domain, func(t *testing.T) {
190+ path := store.getEnvFilePath(tt.domain)
191+
192+ if tt.wantEmpty {
193+ assert.Empty(t, path)
194+ } else {
195+ assert.NotEmpty(t, path)
196+ assert.Equal(t, filepath.Join(tmpDir, tt.wantFilename), path)
197+ }
198+ })
199+ }
200+}
@@ -0,0 +1,200 @@
1+package domainsecrets
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "gordon/internal/domain"
14+)
15+
16+func testLogger() zerowrap.Logger {
17+ return zerowrap.Default()
18+}
19+
20+func TestFileStore_PathTraversal(t *testing.T) {
21+ tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
22+ require.NoError(t, err)
23+ defer os.RemoveAll(tmpDir)
24+
25+ store, err := NewFileStore(tmpDir, testLogger())
26+ require.NoError(t, err)
27+
28+ tests := []struct {
29+ name string
30+ domain string
31+ wantErr bool
32+ }{
33+ // Valid domains
34+ {"simple domain", "example.com", false},
35+ {"subdomain", "sub.example.com", false},
36+ {"domain with port", "example.com:8080", false},
37+ {"domain with path", "example.com/path", false},
38+ {"hyphenated domain", "my-app.example.com", false},
39+ {"single char", "a", false},
40+
41+ // Path traversal attempts - should fail
42+ {"path traversal with dots", "../../../etc/passwd", true},
43+ {"path traversal in middle", "example.com/../../../etc/passwd", true},
44+ {"double dots only", "..", true},
45+ {"path traversal at end", "example.com/..", true},
46+
47+ // Invalid format - should fail
48+ {"empty domain", "", true},
49+ {"starts with dot", ".example.com", true},
50+ {"ends with dot", "example.com.", true},
51+ {"special chars semicolon", "example;rm -rf /", true},
52+ {"null byte", "example\x00.com", true},
53+ {"newline", "example\n.com", true},
54+ }
55+
56+ for _, tt := range tests {
57+ t.Run(tt.name, func(t *testing.T) {
58+ // Test ListKeys
59+ _, err := store.ListKeys(tt.domain)
60+ if tt.wantErr {
61+ require.Error(t, err)
62+ assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
63+ } else {
64+ // No error, or file not found (which is fine)
65+ if err != nil {
66+ assert.False(t, errors.Is(err, domain.ErrPathTraversal), "unexpected ErrPathTraversal")
67+ }
68+ }
69+
70+ // Test Set
71+ err = store.Set(tt.domain, map[string]string{"KEY": "value"})
72+ if tt.wantErr {
73+ require.Error(t, err)
74+ assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
75+ } else {
76+ assert.NoError(t, err)
77+ }
78+
79+ // Test GetAll
80+ _, err = store.GetAll(tt.domain)
81+ if tt.wantErr {
82+ require.Error(t, err)
83+ assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
84+ }
85+
86+ // Test Delete
87+ err = store.Delete(tt.domain, "KEY")
88+ if tt.wantErr {
89+ require.Error(t, err)
90+ assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
91+ }
92+ })
93+ }
94+}
95+
96+func TestFileStore_PathContainment(t *testing.T) {
97+ tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
98+ require.NoError(t, err)
99+ defer os.RemoveAll(tmpDir)
100+
101+ store, err := NewFileStore(tmpDir, testLogger())
102+ require.NoError(t, err)
103+
104+ // Valid domain - file should be created inside tmpDir
105+ err = store.Set("example.com", map[string]string{"KEY": "value"})
106+ require.NoError(t, err)
107+
108+ // Verify file exists inside tmpDir
109+ expectedFile := filepath.Join(tmpDir, "example_com.env")
110+ _, err = os.Stat(expectedFile)
111+ assert.NoError(t, err, "expected env file to exist at %s", expectedFile)
112+
113+ // Verify no file was created outside tmpDir
114+ entries, err := filepath.Glob(tmpDir + "/../*")
115+ require.NoError(t, err)
116+ for _, entry := range entries {
117+ if filepath.Base(entry) != filepath.Base(tmpDir) {
118+ // Check it's not our test file escaped
119+ assert.NotContains(t, entry, ".env", "unexpected .env file outside tmpDir: %s", entry)
120+ }
121+ }
122+}
123+
124+func TestFileStore_ValidDomainOperations(t *testing.T) {
125+ tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
126+ require.NoError(t, err)
127+ defer os.RemoveAll(tmpDir)
128+
129+ store, err := NewFileStore(tmpDir, testLogger())
130+ require.NoError(t, err)
131+
132+ domain := "test.example.com"
133+
134+ // Set secrets
135+ err = store.Set(domain, map[string]string{
136+ "DB_HOST": "localhost",
137+ "DB_PASSWORD": "secret123",
138+ })
139+ require.NoError(t, err)
140+
141+ // Get all secrets
142+ secrets, err := store.GetAll(domain)
143+ require.NoError(t, err)
144+ assert.Equal(t, "localhost", secrets["DB_HOST"])
145+ assert.Equal(t, "secret123", secrets["DB_PASSWORD"])
146+
147+ // List keys
148+ keys, err := store.ListKeys(domain)
149+ require.NoError(t, err)
150+ assert.Len(t, keys, 2)
151+ assert.Contains(t, keys, "DB_HOST")
152+ assert.Contains(t, keys, "DB_PASSWORD")
153+
154+ // Delete a key
155+ err = store.Delete(domain, "DB_PASSWORD")
156+ require.NoError(t, err)
157+
158+ // Verify deletion
159+ secrets, err = store.GetAll(domain)
160+ require.NoError(t, err)
161+ assert.Len(t, secrets, 1)
162+ assert.Equal(t, "localhost", secrets["DB_HOST"])
163+ _, exists := secrets["DB_PASSWORD"]
164+ assert.False(t, exists)
165+}
166+
167+func TestGetEnvFilePath(t *testing.T) {
168+ tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
169+ require.NoError(t, err)
170+ defer os.RemoveAll(tmpDir)
171+
172+ store, err := NewFileStore(tmpDir, testLogger())
173+ require.NoError(t, err)
174+
175+ tests := []struct {
176+ domain string
177+ wantEmpty bool
178+ wantFilename string
179+ }{
180+ {"example.com", false, "example_com.env"},
181+ {"sub.example.com", false, "sub_example_com.env"},
182+ {"example.com:8080", false, "example_com_8080.env"},
183+ {"example.com/path", false, "example_com_path.env"},
184+ {"../evil", true, ""},
185+ {"", true, ""},
186+ }
187+
188+ for _, tt := range tests {
189+ t.Run(tt.domain, func(t *testing.T) {
190+ path := store.getEnvFilePath(tt.domain)
191+
192+ if tt.wantEmpty {
193+ assert.Empty(t, path)
194+ } else {
195+ assert.NotEmpty(t, path)
196+ assert.Equal(t, filepath.Join(tmpDir, tt.wantFilename), path)
197+ }
198+ })
199+ }
200+}
M internal/adapters/out/secrets/pass.go +45 -0
@@ -5,12 +5,20 @@ import (
55 "context"
66 "fmt"
77 "os/exec"
8+ "regexp"
89 "strings"
910 "time"
1011
1112 "github.com/bnema/zerowrap"
13+
14+ "gordon/internal/domain"
1215 )
1316
17+// passPathRegex validates pass paths.
18+// Allows alphanumeric characters, forward slashes, underscores, dots, and hyphens.
19+// This prevents command injection via shell metacharacters.
20+var passPathRegex = regexp.MustCompile(`^[a-zA-Z0-9/_.-]+$`)
21+
1422 // PassProvider implements the SecretProvider interface using the pass password manager.
1523 type PassProvider struct {
1624 timeout time.Duration
@@ -30,8 +38,45 @@ func (p *PassProvider) Name() string {
3038 return "pass"
3139 }
3240
41+// ValidatePath validates a pass path to prevent command injection and path traversal.
42+// Returns an error if the path is invalid.
43+func ValidatePath(path string) error {
44+ if path == "" {
45+ return fmt.Errorf("path cannot be empty")
46+ }
47+
48+ // SECURITY: Validate path format to prevent command injection
49+ if !passPathRegex.MatchString(path) {
50+ return fmt.Errorf("invalid path: must contain only alphanumeric characters, /, _, ., -")
51+ }
52+
53+ // SECURITY: Prevent path traversal
54+ if strings.Contains(path, "..") {
55+ return domain.ErrPathTraversal
56+ }
57+
58+ // SECURITY: Reject absolute paths
59+ if strings.HasPrefix(path, "/") {
60+ return fmt.Errorf("invalid path: absolute paths not allowed")
61+ }
62+
63+ return nil
64+}
65+
3366 // GetSecret retrieves a secret from pass by path.
3467 func (p *PassProvider) GetSecret(ctx context.Context, path string) (string, error) {
68+ // SECURITY: Validate path before executing command
69+ if err := ValidatePath(path); err != nil {
70+ p.log.Warn().
71+ Str(zerowrap.FieldLayer, "adapter").
72+ Str(zerowrap.FieldAdapter, "secrets").
73+ Str("provider", "pass").
74+ Str("path", path).
75+ Err(err).
76+ Msg("rejected invalid pass path")
77+ return "", err
78+ }
79+
3580 ctx, cancel := context.WithTimeout(ctx, p.timeout)
3681 defer cancel()
3782
@@ -5,12 +5,20 @@ import (
5 "context"5 "context"
6 "fmt"6 "fmt"
7 "os/exec"7 "os/exec"
8+ "regexp"
8 "strings"9 "strings"
9 "time"10 "time"
10 11
11 "github.com/bnema/zerowrap"12 "github.com/bnema/zerowrap"
13+
14+ "gordon/internal/domain"
12 )15 )
13 16
17+// passPathRegex validates pass paths.
18+// Allows alphanumeric characters, forward slashes, underscores, dots, and hyphens.
19+// This prevents command injection via shell metacharacters.
20+var passPathRegex = regexp.MustCompile(`^[a-zA-Z0-9/_.-]+$`)
21+
14 // PassProvider implements the SecretProvider interface using the pass password manager.22 // PassProvider implements the SecretProvider interface using the pass password manager.
15 type PassProvider struct {23 type PassProvider struct {
16 timeout time.Duration24 timeout time.Duration
@@ -30,8 +38,45 @@ func (p *PassProvider) Name() string {
30 return "pass"38 return "pass"
31 }39 }
32 40
41+// ValidatePath validates a pass path to prevent command injection and path traversal.
42+// Returns an error if the path is invalid.
43+func ValidatePath(path string) error {
44+ if path == "" {
45+ return fmt.Errorf("path cannot be empty")
46+ }
47+
48+ // SECURITY: Validate path format to prevent command injection
49+ if !passPathRegex.MatchString(path) {
50+ return fmt.Errorf("invalid path: must contain only alphanumeric characters, /, _, ., -")
51+ }
52+
53+ // SECURITY: Prevent path traversal
54+ if strings.Contains(path, "..") {
55+ return domain.ErrPathTraversal
56+ }
57+
58+ // SECURITY: Reject absolute paths
59+ if strings.HasPrefix(path, "/") {
60+ return fmt.Errorf("invalid path: absolute paths not allowed")
61+ }
62+
63+ return nil
64+}
65+
33 // GetSecret retrieves a secret from pass by path.66 // GetSecret retrieves a secret from pass by path.
34 func (p *PassProvider) GetSecret(ctx context.Context, path string) (string, error) {67 func (p *PassProvider) GetSecret(ctx context.Context, path string) (string, error) {
68+ // SECURITY: Validate path before executing command
69+ if err := ValidatePath(path); err != nil {
70+ p.log.Warn().
71+ Str(zerowrap.FieldLayer, "adapter").
72+ Str(zerowrap.FieldAdapter, "secrets").
73+ Str("provider", "pass").
74+ Str("path", path).
75+ Err(err).
76+ Msg("rejected invalid pass path")
77+ return "", err
78+ }
79+
35 ctx, cancel := context.WithTimeout(ctx, p.timeout)80 ctx, cancel := context.WithTimeout(ctx, p.timeout)
36 defer cancel()81 defer cancel()
37 82
A internal/adapters/out/secrets/pass_test.go +203 -0
@@ -0,0 +1,203 @@
1+package secrets
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "gordon/internal/domain"
13+)
14+
15+func TestValidatePath(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ path string
19+ wantErr bool
20+ errType error
21+ }{
22+ // Valid paths
23+ {
24+ name: "simple path",
25+ path: "gordon/registry/secret",
26+ wantErr: false,
27+ },
28+ {
29+ name: "path with dots in name",
30+ path: "my.secret.key",
31+ wantErr: false,
32+ },
33+ {
34+ name: "path with hyphens",
35+ path: "my-app/api-key",
36+ wantErr: false,
37+ },
38+ {
39+ name: "path with underscores",
40+ path: "my_app/api_key",
41+ wantErr: false,
42+ },
43+ {
44+ name: "nested path",
45+ path: "org/team/app/secret",
46+ wantErr: false,
47+ },
48+
49+ // Invalid paths - path traversal
50+ {
51+ name: "path traversal with double dots",
52+ path: "../../../etc/passwd",
53+ wantErr: true,
54+ errType: domain.ErrPathTraversal,
55+ },
56+ {
57+ name: "path traversal in middle",
58+ path: "foo/../../../etc/passwd",
59+ wantErr: true,
60+ errType: domain.ErrPathTraversal,
61+ },
62+ {
63+ name: "path traversal at end",
64+ path: "foo/bar/..",
65+ wantErr: true,
66+ errType: domain.ErrPathTraversal,
67+ },
68+ {
69+ name: "encoded path traversal",
70+ path: "..%2f..%2fetc/passwd",
71+ wantErr: true, // % is not allowed by regex
72+ },
73+
74+ // Invalid paths - absolute paths
75+ {
76+ name: "absolute path",
77+ path: "/etc/passwd",
78+ wantErr: true,
79+ },
80+ {
81+ name: "absolute path with valid chars",
82+ path: "/gordon/secret",
83+ wantErr: true,
84+ },
85+
86+ // Invalid paths - special characters (command injection)
87+ {
88+ name: "semicolon injection",
89+ path: "secret;rm -rf /",
90+ wantErr: true,
91+ },
92+ {
93+ name: "pipe injection",
94+ path: "secret|cat /etc/passwd",
95+ wantErr: true,
96+ },
97+ {
98+ name: "backtick injection",
99+ path: "secret`whoami`",
100+ wantErr: true,
101+ },
102+ {
103+ name: "dollar injection",
104+ path: "secret$(whoami)",
105+ wantErr: true,
106+ },
107+ {
108+ name: "ampersand injection",
109+ path: "secret&rm -rf /",
110+ wantErr: true,
111+ },
112+ {
113+ name: "newline injection",
114+ path: "secret\nrm -rf /",
115+ wantErr: true,
116+ },
117+ {
118+ name: "space in path",
119+ path: "secret with space",
120+ wantErr: true,
121+ },
122+ {
123+ name: "null byte injection",
124+ path: "secret\x00evil",
125+ wantErr: true,
126+ },
127+
128+ // Edge cases
129+ {
130+ name: "empty path",
131+ path: "",
132+ wantErr: true,
133+ },
134+ {
135+ name: "single dot",
136+ path: ".",
137+ wantErr: false, // single dot is valid, not traversal
138+ },
139+ }
140+
141+ for _, tt := range tests {
142+ t.Run(tt.name, func(t *testing.T) {
143+ err := ValidatePath(tt.path)
144+
145+ if tt.wantErr {
146+ require.Error(t, err, "expected error for path: %q", tt.path)
147+ if tt.errType != nil {
148+ assert.True(t, errors.Is(err, tt.errType), "expected error type %v, got %v", tt.errType, err)
149+ }
150+ } else {
151+ assert.NoError(t, err, "unexpected error for path: %q", tt.path)
152+ }
153+ })
154+ }
155+}
156+
157+func testLogger() zerowrap.Logger {
158+ return zerowrap.Default()
159+}
160+
161+func TestPassProvider_GetSecret_PathValidation(t *testing.T) {
162+ provider := NewPassProvider(testLogger())
163+
164+ tests := []struct {
165+ name string
166+ path string
167+ wantErr bool
168+ }{
169+ {
170+ name: "valid path rejected due to pass not installed",
171+ path: "gordon/test/secret",
172+ wantErr: true, // Will fail because pass command doesn't exist in test, but path is valid
173+ },
174+ {
175+ name: "path traversal rejected before command execution",
176+ path: "../../../etc/passwd",
177+ wantErr: true,
178+ },
179+ {
180+ name: "command injection rejected before command execution",
181+ path: "secret;whoami",
182+ wantErr: true,
183+ },
184+ }
185+
186+ for _, tt := range tests {
187+ t.Run(tt.name, func(t *testing.T) {
188+ _, err := provider.GetSecret(context.Background(), tt.path)
189+
190+ if tt.wantErr {
191+ require.Error(t, err)
192+ }
193+
194+ // For security-related rejections, verify the error is from validation, not command execution
195+ if tt.path == "../../../etc/passwd" {
196+ assert.True(t, errors.Is(err, domain.ErrPathTraversal))
197+ }
198+ if tt.path == "secret;whoami" {
199+ assert.Contains(t, err.Error(), "invalid path")
200+ }
201+ })
202+ }
203+}
@@ -0,0 +1,203 @@
1+package secrets
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "gordon/internal/domain"
13+)
14+
15+func TestValidatePath(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ path string
19+ wantErr bool
20+ errType error
21+ }{
22+ // Valid paths
23+ {
24+ name: "simple path",
25+ path: "gordon/registry/secret",
26+ wantErr: false,
27+ },
28+ {
29+ name: "path with dots in name",
30+ path: "my.secret.key",
31+ wantErr: false,
32+ },
33+ {
34+ name: "path with hyphens",
35+ path: "my-app/api-key",
36+ wantErr: false,
37+ },
38+ {
39+ name: "path with underscores",
40+ path: "my_app/api_key",
41+ wantErr: false,
42+ },
43+ {
44+ name: "nested path",
45+ path: "org/team/app/secret",
46+ wantErr: false,
47+ },
48+
49+ // Invalid paths - path traversal
50+ {
51+ name: "path traversal with double dots",
52+ path: "../../../etc/passwd",
53+ wantErr: true,
54+ errType: domain.ErrPathTraversal,
55+ },
56+ {
57+ name: "path traversal in middle",
58+ path: "foo/../../../etc/passwd",
59+ wantErr: true,
60+ errType: domain.ErrPathTraversal,
61+ },
62+ {
63+ name: "path traversal at end",
64+ path: "foo/bar/..",
65+ wantErr: true,
66+ errType: domain.ErrPathTraversal,
67+ },
68+ {
69+ name: "encoded path traversal",
70+ path: "..%2f..%2fetc/passwd",
71+ wantErr: true, // % is not allowed by regex
72+ },
73+
74+ // Invalid paths - absolute paths
75+ {
76+ name: "absolute path",
77+ path: "/etc/passwd",
78+ wantErr: true,
79+ },
80+ {
81+ name: "absolute path with valid chars",
82+ path: "/gordon/secret",
83+ wantErr: true,
84+ },
85+
86+ // Invalid paths - special characters (command injection)
87+ {
88+ name: "semicolon injection",
89+ path: "secret;rm -rf /",
90+ wantErr: true,
91+ },
92+ {
93+ name: "pipe injection",
94+ path: "secret|cat /etc/passwd",
95+ wantErr: true,
96+ },
97+ {
98+ name: "backtick injection",
99+ path: "secret`whoami`",
100+ wantErr: true,
101+ },
102+ {
103+ name: "dollar injection",
104+ path: "secret$(whoami)",
105+ wantErr: true,
106+ },
107+ {
108+ name: "ampersand injection",
109+ path: "secret&rm -rf /",
110+ wantErr: true,
111+ },
112+ {
113+ name: "newline injection",
114+ path: "secret\nrm -rf /",
115+ wantErr: true,
116+ },
117+ {
118+ name: "space in path",
119+ path: "secret with space",
120+ wantErr: true,
121+ },
122+ {
123+ name: "null byte injection",
124+ path: "secret\x00evil",
125+ wantErr: true,
126+ },
127+
128+ // Edge cases
129+ {
130+ name: "empty path",
131+ path: "",
132+ wantErr: true,
133+ },
134+ {
135+ name: "single dot",
136+ path: ".",
137+ wantErr: false, // single dot is valid, not traversal
138+ },
139+ }
140+
141+ for _, tt := range tests {
142+ t.Run(tt.name, func(t *testing.T) {
143+ err := ValidatePath(tt.path)
144+
145+ if tt.wantErr {
146+ require.Error(t, err, "expected error for path: %q", tt.path)
147+ if tt.errType != nil {
148+ assert.True(t, errors.Is(err, tt.errType), "expected error type %v, got %v", tt.errType, err)
149+ }
150+ } else {
151+ assert.NoError(t, err, "unexpected error for path: %q", tt.path)
152+ }
153+ })
154+ }
155+}
156+
157+func testLogger() zerowrap.Logger {
158+ return zerowrap.Default()
159+}
160+
161+func TestPassProvider_GetSecret_PathValidation(t *testing.T) {
162+ provider := NewPassProvider(testLogger())
163+
164+ tests := []struct {
165+ name string
166+ path string
167+ wantErr bool
168+ }{
169+ {
170+ name: "valid path rejected due to pass not installed",
171+ path: "gordon/test/secret",
172+ wantErr: true, // Will fail because pass command doesn't exist in test, but path is valid
173+ },
174+ {
175+ name: "path traversal rejected before command execution",
176+ path: "../../../etc/passwd",
177+ wantErr: true,
178+ },
179+ {
180+ name: "command injection rejected before command execution",
181+ path: "secret;whoami",
182+ wantErr: true,
183+ },
184+ }
185+
186+ for _, tt := range tests {
187+ t.Run(tt.name, func(t *testing.T) {
188+ _, err := provider.GetSecret(context.Background(), tt.path)
189+
190+ if tt.wantErr {
191+ require.Error(t, err)
192+ }
193+
194+ // For security-related rejections, verify the error is from validation, not command execution
195+ if tt.path == "../../../etc/passwd" {
196+ assert.True(t, errors.Is(err, domain.ErrPathTraversal))
197+ }
198+ if tt.path == "secret;whoami" {
199+ assert.Contains(t, err.Error(), "invalid path")
200+ }
201+ })
202+ }
203+}
M internal/adapters/out/tokenstore/store.go +1 -1
@@ -28,7 +28,7 @@ func NewStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger
2828 if dataDir == "" {
2929 return nil, fmt.Errorf("data_dir is required for unsafe backend")
3030 }
31- return NewUnsafeStore(dataDir, log), nil
31+ return NewUnsafeStore(dataDir, log)
3232
3333 default:
3434 return nil, fmt.Errorf("unknown secrets backend: %s", backend)
@@ -28,7 +28,7 @@ func NewStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger
28 if dataDir == "" {28 if dataDir == "" {
29 return nil, fmt.Errorf("data_dir is required for unsafe backend")29 return nil, fmt.Errorf("data_dir is required for unsafe backend")
30 }30 }
31- return NewUnsafeStore(dataDir, log), nil31+ return NewUnsafeStore(dataDir, log)
32 32
33 default:33 default:
34 return nil, fmt.Errorf("unknown secrets backend: %s", backend)34 return nil, fmt.Errorf("unknown secrets backend: %s", backend)
M internal/adapters/out/tokenstore/unsafe.go +5 -2
@@ -38,7 +38,10 @@ type UnsafeStore struct {
3838
3939 // NewUnsafeStore creates a new file-based token store.
4040 // dataDir is the base directory for storing secrets (typically gordon's data_dir).
41-func NewUnsafeStore(dataDir string, log zerowrap.Logger) *UnsafeStore {
41+//
42+// WARNING: Secrets are stored in plain text. The user has explicitly chosen
43+// secrets_backend = "unsafe" in config, so they accept this tradeoff.
44+func NewUnsafeStore(dataDir string, log zerowrap.Logger) (*UnsafeStore, error) {
4245 store := &UnsafeStore{
4346 dataDir: dataDir,
4447 log: log,
@@ -51,7 +54,7 @@ func NewUnsafeStore(dataDir string, log zerowrap.Logger) *UnsafeStore {
5154 Str("data_dir", dataDir).
5255 Msg("using unsafe secrets backend - secrets are stored in plain text")
5356
54- return store
57+ return store, nil
5558 }
5659
5760 // unsafeTokenData holds both JWT and metadata in a single file.
@@ -38,7 +38,10 @@ type UnsafeStore struct {
38 38
39 // NewUnsafeStore creates a new file-based token store.39 // NewUnsafeStore creates a new file-based token store.
40 // dataDir is the base directory for storing secrets (typically gordon's data_dir).40 // dataDir is the base directory for storing secrets (typically gordon's data_dir).
41-func NewUnsafeStore(dataDir string, log zerowrap.Logger) *UnsafeStore {41+//
42+// WARNING: Secrets are stored in plain text. The user has explicitly chosen
43+// secrets_backend = "unsafe" in config, so they accept this tradeoff.
44+func NewUnsafeStore(dataDir string, log zerowrap.Logger) (*UnsafeStore, error) {
42 store := &UnsafeStore{45 store := &UnsafeStore{
43 dataDir: dataDir,46 dataDir: dataDir,
44 log: log,47 log: log,
@@ -51,7 +54,7 @@ func NewUnsafeStore(dataDir string, log zerowrap.Logger) *UnsafeStore {
51 Str("data_dir", dataDir).54 Str("data_dir", dataDir).
52 Msg("using unsafe secrets backend - secrets are stored in plain text")55 Msg("using unsafe secrets backend - secrets are stored in plain text")
53 56
54- return store57+ return store, nil
55 }58 }
56 59
57 // unsafeTokenData holds both JWT and metadata in a single file.60 // unsafeTokenData holds both JWT and metadata in a single file.
M internal/app/run.go +123 -15
@@ -100,6 +100,16 @@ type Config struct {
100100 TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
101101 TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"
102102 } `mapstructure:"auth"`
103+
104+ API struct {
105+ RateLimit struct {
106+ Enabled bool `mapstructure:"enabled"`
107+ GlobalRPS float64 `mapstructure:"global_rps"`
108+ PerIPRPS float64 `mapstructure:"per_ip_rps"`
109+ Burst int `mapstructure:"burst"`
110+ TrustedProxies []string `mapstructure:"trusted_proxies"`
111+ } `mapstructure:"rate_limit"`
112+ } `mapstructure:"api"`
103113 }
104114
105115 // services holds all the services used by the application.
@@ -467,13 +477,44 @@ type InternalCredentials struct {
467477 Password string `json:"password"`
468478 }
469479
480+// getSecureRuntimeDir returns a secure directory for runtime files.
481+// Priority: XDG_RUNTIME_DIR > ~/.gordon/run
482+func getSecureRuntimeDir() (string, error) {
483+ // Try XDG_RUNTIME_DIR first (typically /run/user/<uid> on Linux)
484+ if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
485+ gordonDir := filepath.Join(runtimeDir, "gordon")
486+ if err := os.MkdirAll(gordonDir, 0700); err == nil {
487+ return gordonDir, nil
488+ }
489+ }
490+
491+ // Fall back to ~/.gordon/run
492+ homeDir, err := os.UserHomeDir()
493+ if err != nil {
494+ return "", fmt.Errorf("failed to get home directory: %w", err)
495+ }
496+
497+ gordonDir := filepath.Join(homeDir, ".gordon", "run")
498+ if err := os.MkdirAll(gordonDir, 0700); err != nil {
499+ return "", fmt.Errorf("failed to create runtime directory: %w", err)
500+ }
501+
502+ return gordonDir, nil
503+}
504+
470505 // getInternalCredentialsFile returns the path to the internal credentials file.
506+// SECURITY: Credentials are stored in a secure location with restricted permissions.
471507 func getInternalCredentialsFile() string {
472- return filepath.Join(os.TempDir(), "gordon-internal-creds.json")
508+ runtimeDir, err := getSecureRuntimeDir()
509+ if err != nil {
510+ // Fall back to temp dir if we can't get secure dir (shouldn't happen)
511+ return filepath.Join(os.TempDir(), "gordon-internal-creds.json")
512+ }
513+ return filepath.Join(runtimeDir, "internal-creds.json")
473514 }
474515
475-// persistInternalCredentials saves the internal registry credentials to a temp file.
476-// Security note: Credentials are stored in the system temp directory with 0600 permissions.
516+// persistInternalCredentials saves the internal registry credentials to a secure file.
517+// SECURITY: Credentials are stored in XDG_RUNTIME_DIR or ~/.gordon/run with 0600 permissions.
477518 // The file is cleaned up on graceful shutdown but may persist if Gordon crashes.
478519 // These credentials are for internal loopback communication only and are regenerated on each start.
479520 func persistInternalCredentials(username, password string) error {
@@ -485,7 +526,15 @@ func persistInternalCredentials(username, password string) error {
485526 if err != nil {
486527 return fmt.Errorf("failed to marshal credentials: %w", err)
487528 }
529+
488530 credFile := getInternalCredentialsFile()
531+
532+ // Ensure parent directory exists with secure permissions
533+ if err := os.MkdirAll(filepath.Dir(credFile), 0700); err != nil {
534+ return fmt.Errorf("failed to create credentials directory: %w", err)
535+ }
536+
537+ // Write file with restrictive permissions (owner read/write only)
489538 if err := os.WriteFile(credFile, data, 0600); err != nil {
490539 return fmt.Errorf("failed to write credentials file: %w", err)
491540 }
@@ -646,9 +695,25 @@ func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBack
646695 return secret, expiry, nil
647696 }
648697
698+// TokenSecretEnvVar is the environment variable for the JWT signing secret.
699+// SECURITY: This takes priority over config file to allow secure secret injection.
700+const TokenSecretEnvVar = "GORDON_AUTH_TOKEN_SECRET" //nolint:gosec // This is an env var name, not a credential
701+
649702 func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) {
703+ // SECURITY: Priority order for token secret:
704+ // 1. Environment variable (most secure - no disk exposure)
705+ // 2. Secrets backend (pass/sops - encrypted)
706+ // 3. Config file path (least preferred)
707+
708+ // Check environment variable first
709+ if envSecret := os.Getenv(TokenSecretEnvVar); envSecret != "" {
710+ log.Debug().Msg("using token secret from environment variable")
711+ return []byte(envSecret), nil
712+ }
713+
714+ // Fall back to config-specified path via secrets backend
650715 if cfg.Auth.TokenSecret == "" {
651- return nil, fmt.Errorf("token_secret is required for token authentication")
716+ return nil, fmt.Errorf("token_secret is required for token authentication; set %s environment variable or configure auth.token_secret", TokenSecretEnvVar)
652717 }
653718
654719 secret, err := loadSecret(ctx, backend, cfg.Auth.TokenSecret, dataDir, log)
@@ -808,6 +873,17 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
808873 middleware.RequestLogger(log),
809874 }
810875
876+ // Add rate limiting middleware
877+ rateLimitConfig := registry.RateLimitConfig{
878+ Enabled: cfg.API.RateLimit.Enabled,
879+ GlobalRPS: cfg.API.RateLimit.GlobalRPS,
880+ PerIPRPS: cfg.API.RateLimit.PerIPRPS,
881+ Burst: cfg.API.RateLimit.Burst,
882+ TrustedProxies: cfg.API.RateLimit.TrustedProxies,
883+ }
884+ rateLimitMiddleware := registry.RateLimitMiddleware(rateLimitConfig)
885+ registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)
886+
811887 if cfg.Auth.Enabled && svc.authSvc != nil {
812888 internalAuth := middleware.InternalRegistryAuth{
813889 Username: svc.internalRegUser,
@@ -824,11 +900,13 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
824900 registryMux := http.NewServeMux()
825901 if svc.tokenHandler != nil {
826902 // Token endpoint is NOT protected by auth - it's where clients get tokens
827- tokenWithLogging := middleware.Chain(
903+ // but it still needs rate limiting to prevent brute force attacks
904+ tokenWithMiddleware := middleware.Chain(
828905 middleware.PanicRecovery(log),
829906 middleware.RequestLogger(log),
907+ rateLimitMiddleware,
830908 )(svc.tokenHandler)
831- registryMux.Handle("/v2/token", tokenWithLogging)
909+ registryMux.Handle("/v2/token", tokenWithMiddleware)
832910 }
833911 registryMux.Handle("/v2/", registryWithMiddleware)
834912
@@ -840,8 +918,11 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
840918 }
841919 // Add admin auth middleware if auth is enabled
842920 if svc.authSvc != nil {
843- // Rate limiter for admin API: 10 req/s with burst of 20
844- adminLimiter := rate.NewLimiter(rate.Limit(10), 20)
921+ // Rate limiter for admin API uses per-IP rate from config
922+ var adminLimiter *rate.Limiter
923+ if cfg.API.RateLimit.Enabled {
924+ adminLimiter = rate.NewLimiter(rate.Limit(cfg.API.RateLimit.PerIPRPS), cfg.API.RateLimit.Burst)
925+ }
845926 adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, adminLimiter, log))
846927 }
847928 adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
@@ -1079,19 +1160,32 @@ func readDeployRequest() (string, error) {
10791160 }
10801161
10811162 // createPidFile creates a PID file for the Gordon process.
1163+// SECURITY: Prefers secure locations (XDG_RUNTIME_DIR, ~/.gordon/run) over /tmp
1164+// to prevent symlink attacks and unauthorized access.
10821165 func createPidFile(log zerowrap.Logger) string {
10831166 pid := os.Getpid()
10841167
1085- locations := []string{
1086- "/tmp/gordon.pid",
1087- filepath.Join(os.TempDir(), "gordon.pid"),
1168+ // SECURITY: Prioritize secure locations over /tmp
1169+ var locations []string
1170+
1171+ // Try secure runtime directory first
1172+ if runtimeDir, err := getSecureRuntimeDir(); err == nil {
1173+ locations = append(locations, filepath.Join(runtimeDir, "gordon.pid"))
10881174 }
10891175
1176+ // Fall back to home directory
10901177 if homeDir, err := os.UserHomeDir(); err == nil {
1091- locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))
1178+ locations = append(locations, filepath.Join(homeDir, ".gordon", "gordon.pid"))
10921179 }
10931180
1181+ // Last resort: /tmp (least secure due to world-writable)
1182+ locations = append(locations, filepath.Join(os.TempDir(), "gordon.pid"))
1183+
10941184 for _, location := range locations {
1185+ // Ensure parent directory exists with secure permissions
1186+ if err := os.MkdirAll(filepath.Dir(location), 0700); err != nil {
1187+ continue
1188+ }
10951189 if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0600); err == nil {
10961190 log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file")
10971191 return location
@@ -1112,16 +1206,26 @@ func removePidFile(pidFile string, log zerowrap.Logger) {
11121206 }
11131207
11141208 // findPidFile finds the Gordon PID file.
1209+// Checks secure locations first, then falls back to legacy /tmp location.
11151210 func findPidFile() string {
1116- locations := []string{
1117- "/tmp/gordon.pid",
1118- filepath.Join(os.TempDir(), "gordon.pid"),
1211+ var locations []string
1212+
1213+ // Check secure runtime directory first
1214+ if runtimeDir, err := getSecureRuntimeDir(); err == nil {
1215+ locations = append(locations, filepath.Join(runtimeDir, "gordon.pid"))
11191216 }
11201217
1218+ // Check home directory
11211219 if homeDir, err := os.UserHomeDir(); err == nil {
1220+ locations = append(locations, filepath.Join(homeDir, ".gordon", "gordon.pid"))
1221+ // Legacy location for backward compatibility
11221222 locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))
11231223 }
11241224
1225+ // Legacy /tmp locations for backward compatibility
1226+ locations = append(locations, filepath.Join(os.TempDir(), "gordon.pid"))
1227+ locations = append(locations, "/tmp/gordon.pid")
1228+
11251229 for _, location := range locations {
11261230 if _, err := os.Stat(location); err == nil {
11271231 return location
@@ -1152,6 +1256,10 @@ func loadConfig(v *viper.Viper, configPath string) error {
11521256 v.SetDefault("auth.type", "password")
11531257 v.SetDefault("auth.secrets_backend", "unsafe")
11541258 v.SetDefault("auth.token_expiry", "720h")
1259+ v.SetDefault("api.rate_limit.enabled", true)
1260+ v.SetDefault("api.rate_limit.global_rps", 500)
1261+ v.SetDefault("api.rate_limit.per_ip_rps", 50)
1262+ v.SetDefault("api.rate_limit.burst", 100)
11551263 v.SetDefault("auto_route.enabled", false)
11561264 v.SetDefault("network_isolation.enabled", false)
11571265 v.SetDefault("network_isolation.network_prefix", "gordon")
@@ -100,6 +100,16 @@ type Config struct {
100 TokenSecret string `mapstructure:"token_secret"` // path in secrets backend100 TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
101 TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"101 TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"
102 } `mapstructure:"auth"`102 } `mapstructure:"auth"`
103+
104+ API struct {
105+ RateLimit struct {
106+ Enabled bool `mapstructure:"enabled"`
107+ GlobalRPS float64 `mapstructure:"global_rps"`
108+ PerIPRPS float64 `mapstructure:"per_ip_rps"`
109+ Burst int `mapstructure:"burst"`
110+ TrustedProxies []string `mapstructure:"trusted_proxies"`
111+ } `mapstructure:"rate_limit"`
112+ } `mapstructure:"api"`
103 }113 }
104 114
105 // services holds all the services used by the application.115 // services holds all the services used by the application.
@@ -467,13 +477,44 @@ type InternalCredentials struct {
467 Password string `json:"password"`477 Password string `json:"password"`
468 }478 }
469 479
480+// getSecureRuntimeDir returns a secure directory for runtime files.
481+// Priority: XDG_RUNTIME_DIR > ~/.gordon/run
482+func getSecureRuntimeDir() (string, error) {
483+ // Try XDG_RUNTIME_DIR first (typically /run/user/<uid> on Linux)
484+ if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
485+ gordonDir := filepath.Join(runtimeDir, "gordon")
486+ if err := os.MkdirAll(gordonDir, 0700); err == nil {
487+ return gordonDir, nil
488+ }
489+ }
490+
491+ // Fall back to ~/.gordon/run
492+ homeDir, err := os.UserHomeDir()
493+ if err != nil {
494+ return "", fmt.Errorf("failed to get home directory: %w", err)
495+ }
496+
497+ gordonDir := filepath.Join(homeDir, ".gordon", "run")
498+ if err := os.MkdirAll(gordonDir, 0700); err != nil {
499+ return "", fmt.Errorf("failed to create runtime directory: %w", err)
500+ }
501+
502+ return gordonDir, nil
503+}
504+
470 // getInternalCredentialsFile returns the path to the internal credentials file.505 // getInternalCredentialsFile returns the path to the internal credentials file.
506+// SECURITY: Credentials are stored in a secure location with restricted permissions.
471 func getInternalCredentialsFile() string {507 func getInternalCredentialsFile() string {
472- return filepath.Join(os.TempDir(), "gordon-internal-creds.json")508+ runtimeDir, err := getSecureRuntimeDir()
509+ if err != nil {
510+ // Fall back to temp dir if we can't get secure dir (shouldn't happen)
511+ return filepath.Join(os.TempDir(), "gordon-internal-creds.json")
512+ }
513+ return filepath.Join(runtimeDir, "internal-creds.json")
473 }514 }
474 515
475-// persistInternalCredentials saves the internal registry credentials to a temp file.516+// persistInternalCredentials saves the internal registry credentials to a secure file.
476-// Security note: Credentials are stored in the system temp directory with 0600 permissions.517+// SECURITY: Credentials are stored in XDG_RUNTIME_DIR or ~/.gordon/run with 0600 permissions.
477 // The file is cleaned up on graceful shutdown but may persist if Gordon crashes.518 // The file is cleaned up on graceful shutdown but may persist if Gordon crashes.
478 // These credentials are for internal loopback communication only and are regenerated on each start.519 // These credentials are for internal loopback communication only and are regenerated on each start.
479 func persistInternalCredentials(username, password string) error {520 func persistInternalCredentials(username, password string) error {
@@ -485,7 +526,15 @@ func persistInternalCredentials(username, password string) error {
485 if err != nil {526 if err != nil {
486 return fmt.Errorf("failed to marshal credentials: %w", err)527 return fmt.Errorf("failed to marshal credentials: %w", err)
487 }528 }
529+
488 credFile := getInternalCredentialsFile()530 credFile := getInternalCredentialsFile()
531+
532+ // Ensure parent directory exists with secure permissions
533+ if err := os.MkdirAll(filepath.Dir(credFile), 0700); err != nil {
534+ return fmt.Errorf("failed to create credentials directory: %w", err)
535+ }
536+
537+ // Write file with restrictive permissions (owner read/write only)
489 if err := os.WriteFile(credFile, data, 0600); err != nil {538 if err := os.WriteFile(credFile, data, 0600); err != nil {
490 return fmt.Errorf("failed to write credentials file: %w", err)539 return fmt.Errorf("failed to write credentials file: %w", err)
491 }540 }
@@ -646,9 +695,25 @@ func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBack
646 return secret, expiry, nil695 return secret, expiry, nil
647 }696 }
648 697
698+// TokenSecretEnvVar is the environment variable for the JWT signing secret.
699+// SECURITY: This takes priority over config file to allow secure secret injection.
700+const TokenSecretEnvVar = "GORDON_AUTH_TOKEN_SECRET" //nolint:gosec // This is an env var name, not a credential
701+
649 func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) {702 func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) {
703+ // SECURITY: Priority order for token secret:
704+ // 1. Environment variable (most secure - no disk exposure)
705+ // 2. Secrets backend (pass/sops - encrypted)
706+ // 3. Config file path (least preferred)
707+
708+ // Check environment variable first
709+ if envSecret := os.Getenv(TokenSecretEnvVar); envSecret != "" {
710+ log.Debug().Msg("using token secret from environment variable")
711+ return []byte(envSecret), nil
712+ }
713+
714+ // Fall back to config-specified path via secrets backend
650 if cfg.Auth.TokenSecret == "" {715 if cfg.Auth.TokenSecret == "" {
651- return nil, fmt.Errorf("token_secret is required for token authentication")716+ return nil, fmt.Errorf("token_secret is required for token authentication; set %s environment variable or configure auth.token_secret", TokenSecretEnvVar)
652 }717 }
653 718
654 secret, err := loadSecret(ctx, backend, cfg.Auth.TokenSecret, dataDir, log)719 secret, err := loadSecret(ctx, backend, cfg.Auth.TokenSecret, dataDir, log)
@@ -808,6 +873,17 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
808 middleware.RequestLogger(log),873 middleware.RequestLogger(log),
809 }874 }
810 875
876+ // Add rate limiting middleware
877+ rateLimitConfig := registry.RateLimitConfig{
878+ Enabled: cfg.API.RateLimit.Enabled,
879+ GlobalRPS: cfg.API.RateLimit.GlobalRPS,
880+ PerIPRPS: cfg.API.RateLimit.PerIPRPS,
881+ Burst: cfg.API.RateLimit.Burst,
882+ TrustedProxies: cfg.API.RateLimit.TrustedProxies,
883+ }
884+ rateLimitMiddleware := registry.RateLimitMiddleware(rateLimitConfig)
885+ registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware)
886+
811 if cfg.Auth.Enabled && svc.authSvc != nil {887 if cfg.Auth.Enabled && svc.authSvc != nil {
812 internalAuth := middleware.InternalRegistryAuth{888 internalAuth := middleware.InternalRegistryAuth{
813 Username: svc.internalRegUser,889 Username: svc.internalRegUser,
@@ -824,11 +900,13 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
824 registryMux := http.NewServeMux()900 registryMux := http.NewServeMux()
825 if svc.tokenHandler != nil {901 if svc.tokenHandler != nil {
826 // Token endpoint is NOT protected by auth - it's where clients get tokens902 // Token endpoint is NOT protected by auth - it's where clients get tokens
827- tokenWithLogging := middleware.Chain(903+ // but it still needs rate limiting to prevent brute force attacks
904+ tokenWithMiddleware := middleware.Chain(
828 middleware.PanicRecovery(log),905 middleware.PanicRecovery(log),
829 middleware.RequestLogger(log),906 middleware.RequestLogger(log),
907+ rateLimitMiddleware,
830 )(svc.tokenHandler)908 )(svc.tokenHandler)
831- registryMux.Handle("/v2/token", tokenWithLogging)909+ registryMux.Handle("/v2/token", tokenWithMiddleware)
832 }910 }
833 registryMux.Handle("/v2/", registryWithMiddleware)911 registryMux.Handle("/v2/", registryWithMiddleware)
834 912
@@ -840,8 +918,11 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
840 }918 }
841 // Add admin auth middleware if auth is enabled919 // Add admin auth middleware if auth is enabled
842 if svc.authSvc != nil {920 if svc.authSvc != nil {
843- // Rate limiter for admin API: 10 req/s with burst of 20921+ // Rate limiter for admin API uses per-IP rate from config
844- adminLimiter := rate.NewLimiter(rate.Limit(10), 20)922+ var adminLimiter *rate.Limiter
923+ if cfg.API.RateLimit.Enabled {
924+ adminLimiter = rate.NewLimiter(rate.Limit(cfg.API.RateLimit.PerIPRPS), cfg.API.RateLimit.Burst)
925+ }
845 adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, adminLimiter, log))926 adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, adminLimiter, log))
846 }927 }
847 adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)928 adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
@@ -1079,19 +1160,32 @@ func readDeployRequest() (string, error) {
1079 }1160 }
1080 1161
1081 // createPidFile creates a PID file for the Gordon process.1162 // createPidFile creates a PID file for the Gordon process.
1163+// SECURITY: Prefers secure locations (XDG_RUNTIME_DIR, ~/.gordon/run) over /tmp
1164+// to prevent symlink attacks and unauthorized access.
1082 func createPidFile(log zerowrap.Logger) string {1165 func createPidFile(log zerowrap.Logger) string {
1083 pid := os.Getpid()1166 pid := os.Getpid()
1084 1167
1085- locations := []string{1168+ // SECURITY: Prioritize secure locations over /tmp
1086- "/tmp/gordon.pid",1169+ var locations []string
1087- filepath.Join(os.TempDir(), "gordon.pid"),1170+
1171+ // Try secure runtime directory first
1172+ if runtimeDir, err := getSecureRuntimeDir(); err == nil {
1173+ locations = append(locations, filepath.Join(runtimeDir, "gordon.pid"))
1088 }1174 }
1089 1175
1176+ // Fall back to home directory
1090 if homeDir, err := os.UserHomeDir(); err == nil {1177 if homeDir, err := os.UserHomeDir(); err == nil {
1091- locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))1178+ locations = append(locations, filepath.Join(homeDir, ".gordon", "gordon.pid"))
1092 }1179 }
1093 1180
1181+ // Last resort: /tmp (least secure due to world-writable)
1182+ locations = append(locations, filepath.Join(os.TempDir(), "gordon.pid"))
1183+
1094 for _, location := range locations {1184 for _, location := range locations {
1185+ // Ensure parent directory exists with secure permissions
1186+ if err := os.MkdirAll(filepath.Dir(location), 0700); err != nil {
1187+ continue
1188+ }
1095 if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0600); err == nil {1189 if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0600); err == nil {
1096 log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file")1190 log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file")
1097 return location1191 return location
@@ -1112,16 +1206,26 @@ func removePidFile(pidFile string, log zerowrap.Logger) {
1112 }1206 }
1113 1207
1114 // findPidFile finds the Gordon PID file.1208 // findPidFile finds the Gordon PID file.
1209+// Checks secure locations first, then falls back to legacy /tmp location.
1115 func findPidFile() string {1210 func findPidFile() string {
1116- locations := []string{1211+ var locations []string
1117- "/tmp/gordon.pid",1212+
1118- filepath.Join(os.TempDir(), "gordon.pid"),1213+ // Check secure runtime directory first
1214+ if runtimeDir, err := getSecureRuntimeDir(); err == nil {
1215+ locations = append(locations, filepath.Join(runtimeDir, "gordon.pid"))
1119 }1216 }
1120 1217
1218+ // Check home directory
1121 if homeDir, err := os.UserHomeDir(); err == nil {1219 if homeDir, err := os.UserHomeDir(); err == nil {
1220+ locations = append(locations, filepath.Join(homeDir, ".gordon", "gordon.pid"))
1221+ // Legacy location for backward compatibility
1122 locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))1222 locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))
1123 }1223 }
1124 1224
1225+ // Legacy /tmp locations for backward compatibility
1226+ locations = append(locations, filepath.Join(os.TempDir(), "gordon.pid"))
1227+ locations = append(locations, "/tmp/gordon.pid")
1228+
1125 for _, location := range locations {1229 for _, location := range locations {
1126 if _, err := os.Stat(location); err == nil {1230 if _, err := os.Stat(location); err == nil {
1127 return location1231 return location
@@ -1152,6 +1256,10 @@ func loadConfig(v *viper.Viper, configPath string) error {
1152 v.SetDefault("auth.type", "password")1256 v.SetDefault("auth.type", "password")
1153 v.SetDefault("auth.secrets_backend", "unsafe")1257 v.SetDefault("auth.secrets_backend", "unsafe")
1154 v.SetDefault("auth.token_expiry", "720h")1258 v.SetDefault("auth.token_expiry", "720h")
1259+ v.SetDefault("api.rate_limit.enabled", true)
1260+ v.SetDefault("api.rate_limit.global_rps", 500)
1261+ v.SetDefault("api.rate_limit.per_ip_rps", 50)
1262+ v.SetDefault("api.rate_limit.burst", 100)
1155 v.SetDefault("auto_route.enabled", false)1263 v.SetDefault("auto_route.enabled", false)
1156 v.SetDefault("network_isolation.enabled", false)1264 v.SetDefault("network_isolation.enabled", false)
1157 v.SetDefault("network_isolation.network_prefix", "gordon")1265 v.SetDefault("network_isolation.network_prefix", "gordon")
M internal/domain/errors.go +1 -0
@@ -62,6 +62,7 @@ var (
6262 ErrInvalidPath = errors.New("invalid path format")
6363 ErrInsufficientScope = errors.New("insufficient scope for operation")
6464 ErrInvalidScope = errors.New("invalid scope format")
65+ ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed")
6566
6667 // Attachment errors
6768 ErrAttachmentNotFound = errors.New("attachment not found")
@@ -62,6 +62,7 @@ var (
62 ErrInvalidPath = errors.New("invalid path format")62 ErrInvalidPath = errors.New("invalid path format")
63 ErrInsufficientScope = errors.New("insufficient scope for operation")63 ErrInsufficientScope = errors.New("insufficient scope for operation")
64 ErrInvalidScope = errors.New("invalid scope format")64 ErrInvalidScope = errors.New("invalid scope format")
65+ ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed")
65 66
66 // Attachment errors67 // Attachment errors
67 ErrAttachmentNotFound = errors.New("attachment not found")68 ErrAttachmentNotFound = errors.New("attachment not found")
M internal/usecase/auth/service.go +2 -0
@@ -292,6 +292,7 @@ func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []st
292292 "sub": subject,
293293 "iss": TokenIssuer,
294294 "iat": now.Unix(),
295+ "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection
295296 "scopes": scopes,
296297 }
297298
@@ -351,6 +352,7 @@ func (s *Service) GenerateAccessToken(ctx context.Context, subject string, scope
351352 "sub": subject,
352353 "iss": TokenIssuer,
353354 "iat": now.Unix(),
355+ "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection
354356 "scopes": scopes,
355357 "exp": now.Add(expiry).Unix(),
356358 }
@@ -292,6 +292,7 @@ func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []st
292 "sub": subject,292 "sub": subject,
293 "iss": TokenIssuer,293 "iss": TokenIssuer,
294 "iat": now.Unix(),294 "iat": now.Unix(),
295+ "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection
295 "scopes": scopes,296 "scopes": scopes,
296 }297 }
297 298
@@ -351,6 +352,7 @@ func (s *Service) GenerateAccessToken(ctx context.Context, subject string, scope
351 "sub": subject,352 "sub": subject,
352 "iss": TokenIssuer,353 "iss": TokenIssuer,
353 "iat": now.Unix(),354 "iat": now.Unix(),
355+ "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection
354 "scopes": scopes,356 "scopes": scopes,
355 "exp": now.Add(expiry).Unix(),357 "exp": now.Add(expiry).Unix(),
356 }358 }
M internal/usecase/auth/service_test.go +61 -0
@@ -755,3 +755,64 @@ func TestService_GeneratePasswordHash_DifferentHashesForSamePassword(t *testing.
755755 assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash1), []byte(password)))
756756 assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash2), []byte(password)))
757757 }
758+
759+func TestService_GenerateToken_HasNbfClaim(t *testing.T) {
760+ // Test that generated tokens include the nbf (not-before) claim
761+ // This is a security requirement per SEC-MED-006
762+ tokenStore := mocks.NewMockTokenStore(t)
763+
764+ tokenStore.EXPECT().
765+ SaveToken(mock.Anything, mock.Anything, mock.Anything).
766+ Return(nil)
767+
768+ svc := NewService(Config{
769+ Enabled: true,
770+ AuthType: domain.AuthTypeToken,
771+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
772+ }, tokenStore, zerowrap.Default())
773+
774+ ctx := testContext()
775+ tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push"}, time.Hour)
776+ require.NoError(t, err)
777+
778+ // Parse the token to verify nbf claim
779+ claims, err := svc.parseTokenClaims(tokenStr)
780+ require.NoError(t, err)
781+
782+ // Verify nbf claim exists and is valid
783+ nbf, ok := claims["nbf"].(float64)
784+ require.True(t, ok, "nbf claim should exist and be a number")
785+
786+ iat, ok := claims["iat"].(float64)
787+ require.True(t, ok, "iat claim should exist")
788+
789+ // nbf should equal iat for immediate validity
790+ assert.Equal(t, iat, nbf, "nbf should equal iat")
791+ assert.InDelta(t, time.Now().Unix(), int64(nbf), 5, "nbf should be current time")
792+}
793+
794+func TestService_GenerateAccessToken_HasNbfClaim(t *testing.T) {
795+ // Test that access tokens also include the nbf claim
796+ svc := NewService(Config{
797+ Enabled: true,
798+ AuthType: domain.AuthTypeToken,
799+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
800+ }, nil, zerowrap.Default())
801+
802+ ctx := testContext()
803+ tokenStr, err := svc.GenerateAccessToken(ctx, "testsubject", []string{"pull"}, time.Minute)
804+ require.NoError(t, err)
805+
806+ // Parse the token to verify nbf claim
807+ claims, err := svc.parseTokenClaims(tokenStr)
808+ require.NoError(t, err)
809+
810+ // Verify nbf claim exists
811+ nbf, ok := claims["nbf"].(float64)
812+ require.True(t, ok, "nbf claim should exist and be a number")
813+
814+ iat, ok := claims["iat"].(float64)
815+ require.True(t, ok, "iat claim should exist")
816+
817+ assert.Equal(t, iat, nbf, "nbf should equal iat for access tokens")
818+}
@@ -755,3 +755,64 @@ func TestService_GeneratePasswordHash_DifferentHashesForSamePassword(t *testing.
755 assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash1), []byte(password)))755 assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash1), []byte(password)))
756 assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash2), []byte(password)))756 assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash2), []byte(password)))
757 }757 }
758+
759+func TestService_GenerateToken_HasNbfClaim(t *testing.T) {
760+ // Test that generated tokens include the nbf (not-before) claim
761+ // This is a security requirement per SEC-MED-006
762+ tokenStore := mocks.NewMockTokenStore(t)
763+
764+ tokenStore.EXPECT().
765+ SaveToken(mock.Anything, mock.Anything, mock.Anything).
766+ Return(nil)
767+
768+ svc := NewService(Config{
769+ Enabled: true,
770+ AuthType: domain.AuthTypeToken,
771+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
772+ }, tokenStore, zerowrap.Default())
773+
774+ ctx := testContext()
775+ tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push"}, time.Hour)
776+ require.NoError(t, err)
777+
778+ // Parse the token to verify nbf claim
779+ claims, err := svc.parseTokenClaims(tokenStr)
780+ require.NoError(t, err)
781+
782+ // Verify nbf claim exists and is valid
783+ nbf, ok := claims["nbf"].(float64)
784+ require.True(t, ok, "nbf claim should exist and be a number")
785+
786+ iat, ok := claims["iat"].(float64)
787+ require.True(t, ok, "iat claim should exist")
788+
789+ // nbf should equal iat for immediate validity
790+ assert.Equal(t, iat, nbf, "nbf should equal iat")
791+ assert.InDelta(t, time.Now().Unix(), int64(nbf), 5, "nbf should be current time")
792+}
793+
794+func TestService_GenerateAccessToken_HasNbfClaim(t *testing.T) {
795+ // Test that access tokens also include the nbf claim
796+ svc := NewService(Config{
797+ Enabled: true,
798+ AuthType: domain.AuthTypeToken,
799+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
800+ }, nil, zerowrap.Default())
801+
802+ ctx := testContext()
803+ tokenStr, err := svc.GenerateAccessToken(ctx, "testsubject", []string{"pull"}, time.Minute)
804+ require.NoError(t, err)
805+
806+ // Parse the token to verify nbf claim
807+ claims, err := svc.parseTokenClaims(tokenStr)
808+ require.NoError(t, err)
809+
810+ // Verify nbf claim exists
811+ nbf, ok := claims["nbf"].(float64)
812+ require.True(t, ok, "nbf claim should exist and be a number")
813+
814+ iat, ok := claims["iat"].(float64)
815+ require.True(t, ok, "iat claim should exist")
816+
817+ assert.Equal(t, iat, nbf, "nbf should equal iat for access tokens")
818+}
M internal/usecase/proxy/service.go +18 -1
@@ -39,6 +39,9 @@ var proxyTransport = &http.Transport{
3939 type Config struct {
4040 RegistryDomain string
4141 RegistryPort int
42+ // ExposeContainerID controls whether to add X-Container-ID header to responses.
43+ // Default: false (disabled for security - prevents container enumeration)
44+ ExposeContainerID bool
4245 }
4346
4447 // Service implements the ProxyService interface.
@@ -140,6 +143,16 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
140143 return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
141144 }
142145
146+ // SECURITY: Validate target is not an internal/blocked network (SSRF protection)
147+ if err := ValidateExternalRouteTarget(host); err != nil {
148+ log.Warn().
149+ Err(err).
150+ Str("host", host).
151+ Str("domain", domainName).
152+ Msg("SSRF protection: blocked external route to internal network")
153+ return nil, err
154+ }
155+
143156 target := &domain.ProxyTarget{
144157 Host: host,
145158 Port: port,
@@ -312,7 +325,11 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *
312325 },
313326 func(resp *http.Response) error {
314327 resp.Header.Set("X-Proxied-By", "Gordon")
315- resp.Header.Set("X-Container-ID", target.ContainerID)
328+ // SECURITY: Only expose container ID if explicitly enabled
329+ // This prevents container enumeration attacks
330+ if s.config.ExposeContainerID && target.ContainerID != "" {
331+ resp.Header.Set("X-Container-ID", target.ContainerID)
332+ }
316333 return nil
317334 },
318335 )
@@ -39,6 +39,9 @@ var proxyTransport = &http.Transport{
39 type Config struct {39 type Config struct {
40 RegistryDomain string40 RegistryDomain string
41 RegistryPort int41 RegistryPort int
42+ // ExposeContainerID controls whether to add X-Container-ID header to responses.
43+ // Default: false (disabled for security - prevents container enumeration)
44+ ExposeContainerID bool
42 }45 }
43 46
44 // Service implements the ProxyService interface.47 // Service implements the ProxyService interface.
@@ -140,6 +143,16 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro
140 return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})143 return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
141 }144 }
142 145
146+ // SECURITY: Validate target is not an internal/blocked network (SSRF protection)
147+ if err := ValidateExternalRouteTarget(host); err != nil {
148+ log.Warn().
149+ Err(err).
150+ Str("host", host).
151+ Str("domain", domainName).
152+ Msg("SSRF protection: blocked external route to internal network")
153+ return nil, err
154+ }
155+
143 target := &domain.ProxyTarget{156 target := &domain.ProxyTarget{
144 Host: host,157 Host: host,
145 Port: port,158 Port: port,
@@ -312,7 +325,11 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *
312 },325 },
313 func(resp *http.Response) error {326 func(resp *http.Response) error {
314 resp.Header.Set("X-Proxied-By", "Gordon")327 resp.Header.Set("X-Proxied-By", "Gordon")
315- resp.Header.Set("X-Container-ID", target.ContainerID)328+ // SECURITY: Only expose container ID if explicitly enabled
329+ // This prevents container enumeration attacks
330+ if s.config.ExposeContainerID && target.ContainerID != "" {
331+ resp.Header.Set("X-Container-ID", target.ContainerID)
332+ }
316 return nil333 return nil
317 },334 },
318 )335 )
M internal/usecase/proxy/service_test.go +44 -6
@@ -73,15 +73,15 @@ func TestService_GetTarget_ExternalRoute(t *testing.T) {
7373 svc := NewService(runtime, containerSvc, configSvc, Config{})
7474 ctx := testContext()
7575
76- // Mock external routes
76+ // Mock external routes - use public IP to pass SSRF check
7777 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
78- "reg.example.com": "localhost:5000",
78+ "reg.example.com": "203.0.113.10:5000", // TEST-NET-3 (RFC 5737) - documentation range
7979 })
8080
8181 result, err := svc.GetTarget(ctx, "reg.example.com")
8282
8383 assert.NoError(t, err)
84- assert.Equal(t, "localhost", result.Host)
84+ assert.Equal(t, "203.0.113.10", result.Host)
8585 assert.Equal(t, 5000, result.Port)
8686 assert.Equal(t, "", result.ContainerID)
8787 assert.Equal(t, "http", result.Scheme)
@@ -95,14 +95,14 @@ func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) {
9595 svc := NewService(runtime, containerSvc, configSvc, Config{})
9696 ctx := testContext()
9797
98- // First call - should resolve external route
98+ // First call - should resolve external route (use public IP)
9999 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
100- "reg.example.com": "localhost:5000",
100+ "reg.example.com": "203.0.113.10:5000",
101101 }).Once()
102102
103103 result1, err := svc.GetTarget(ctx, "reg.example.com")
104104 assert.NoError(t, err)
105- assert.Equal(t, "localhost", result1.Host)
105+ assert.Equal(t, "203.0.113.10", result1.Host)
106106
107107 // Second call - should return from cache (no mock call needed)
108108 result2, err := svc.GetTarget(ctx, "reg.example.com")
@@ -110,6 +110,44 @@ func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) {
110110 assert.Equal(t, result1, result2)
111111 }
112112
113+func TestService_GetTarget_ExternalRoute_SSRFBlocked(t *testing.T) {
114+ runtime := outmocks.NewMockContainerRuntime(t)
115+ containerSvc := inmocks.NewMockContainerService(t)
116+ configSvc := inmocks.NewMockConfigService(t)
117+
118+ svc := NewService(runtime, containerSvc, configSvc, Config{})
119+ ctx := testContext()
120+
121+ tests := []struct {
122+ name string
123+ target string
124+ }{
125+ {"localhost", "localhost:5000"},
126+ {"loopback IP", "127.0.0.1:5000"},
127+ {"private network 10.x", "10.0.0.1:5000"},
128+ {"private network 172.x", "172.16.0.1:5000"},
129+ {"private network 192.168.x", "192.168.1.1:5000"},
130+ {"AWS metadata", "169.254.169.254:80"},
131+ }
132+
133+ for _, tt := range tests {
134+ t.Run(tt.name, func(t *testing.T) {
135+ // Clear cache
136+ svc.targets = make(map[string]*domain.ProxyTarget)
137+
138+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
139+ "ssrf.example.com": tt.target,
140+ }).Once()
141+
142+ result, err := svc.GetTarget(ctx, "ssrf.example.com")
143+
144+ assert.Error(t, err)
145+ assert.ErrorIs(t, err, domain.ErrSSRFBlocked)
146+ assert.Nil(t, result)
147+ })
148+ }
149+}
150+
113151 func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) {
114152 runtime := outmocks.NewMockContainerRuntime(t)
115153 containerSvc := inmocks.NewMockContainerService(t)
@@ -73,15 +73,15 @@ func TestService_GetTarget_ExternalRoute(t *testing.T) {
73 svc := NewService(runtime, containerSvc, configSvc, Config{})73 svc := NewService(runtime, containerSvc, configSvc, Config{})
74 ctx := testContext()74 ctx := testContext()
75 75
76- // Mock external routes76+ // Mock external routes - use public IP to pass SSRF check
77 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{77 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
78- "reg.example.com": "localhost:5000",78+ "reg.example.com": "203.0.113.10:5000", // TEST-NET-3 (RFC 5737) - documentation range
79 })79 })
80 80
81 result, err := svc.GetTarget(ctx, "reg.example.com")81 result, err := svc.GetTarget(ctx, "reg.example.com")
82 82
83 assert.NoError(t, err)83 assert.NoError(t, err)
84- assert.Equal(t, "localhost", result.Host)84+ assert.Equal(t, "203.0.113.10", result.Host)
85 assert.Equal(t, 5000, result.Port)85 assert.Equal(t, 5000, result.Port)
86 assert.Equal(t, "", result.ContainerID)86 assert.Equal(t, "", result.ContainerID)
87 assert.Equal(t, "http", result.Scheme)87 assert.Equal(t, "http", result.Scheme)
@@ -95,14 +95,14 @@ func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) {
95 svc := NewService(runtime, containerSvc, configSvc, Config{})95 svc := NewService(runtime, containerSvc, configSvc, Config{})
96 ctx := testContext()96 ctx := testContext()
97 97
98- // First call - should resolve external route98+ // First call - should resolve external route (use public IP)
99 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{99 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
100- "reg.example.com": "localhost:5000",100+ "reg.example.com": "203.0.113.10:5000",
101 }).Once()101 }).Once()
102 102
103 result1, err := svc.GetTarget(ctx, "reg.example.com")103 result1, err := svc.GetTarget(ctx, "reg.example.com")
104 assert.NoError(t, err)104 assert.NoError(t, err)
105- assert.Equal(t, "localhost", result1.Host)105+ assert.Equal(t, "203.0.113.10", result1.Host)
106 106
107 // Second call - should return from cache (no mock call needed)107 // Second call - should return from cache (no mock call needed)
108 result2, err := svc.GetTarget(ctx, "reg.example.com")108 result2, err := svc.GetTarget(ctx, "reg.example.com")
@@ -110,6 +110,44 @@ func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) {
110 assert.Equal(t, result1, result2)110 assert.Equal(t, result1, result2)
111 }111 }
112 112
113+func TestService_GetTarget_ExternalRoute_SSRFBlocked(t *testing.T) {
114+ runtime := outmocks.NewMockContainerRuntime(t)
115+ containerSvc := inmocks.NewMockContainerService(t)
116+ configSvc := inmocks.NewMockConfigService(t)
117+
118+ svc := NewService(runtime, containerSvc, configSvc, Config{})
119+ ctx := testContext()
120+
121+ tests := []struct {
122+ name string
123+ target string
124+ }{
125+ {"localhost", "localhost:5000"},
126+ {"loopback IP", "127.0.0.1:5000"},
127+ {"private network 10.x", "10.0.0.1:5000"},
128+ {"private network 172.x", "172.16.0.1:5000"},
129+ {"private network 192.168.x", "192.168.1.1:5000"},
130+ {"AWS metadata", "169.254.169.254:80"},
131+ }
132+
133+ for _, tt := range tests {
134+ t.Run(tt.name, func(t *testing.T) {
135+ // Clear cache
136+ svc.targets = make(map[string]*domain.ProxyTarget)
137+
138+ configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
139+ "ssrf.example.com": tt.target,
140+ }).Once()
141+
142+ result, err := svc.GetTarget(ctx, "ssrf.example.com")
143+
144+ assert.Error(t, err)
145+ assert.ErrorIs(t, err, domain.ErrSSRFBlocked)
146+ assert.Nil(t, result)
147+ })
148+ }
149+}
150+
113 func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) {151 func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) {
114 runtime := outmocks.NewMockContainerRuntime(t)152 runtime := outmocks.NewMockContainerRuntime(t)
115 containerSvc := inmocks.NewMockContainerService(t)153 containerSvc := inmocks.NewMockContainerService(t)
A internal/usecase/proxy/ssrf.go +117 -0
@@ -0,0 +1,117 @@
1+// Package proxy implements the reverse proxy use case.
2+package proxy
3+
4+import (
5+ "fmt"
6+ "net"
7+ "sync"
8+
9+ "gordon/internal/domain"
10+)
11+
12+// blockedCIDRStrings contains CIDR blocks that should never be proxied to.
13+// This prevents SSRF attacks targeting internal services or cloud metadata endpoints.
14+var blockedCIDRStrings = []string{
15+ // IPv4 loopback
16+ "127.0.0.0/8",
17+ // IPv4 private networks (RFC 1918)
18+ "10.0.0.0/8",
19+ "172.16.0.0/12",
20+ "192.168.0.0/16",
21+ // IPv4 link-local (cloud metadata endpoints like AWS/GCP/Azure)
22+ "169.254.0.0/16",
23+ // IPv4 localhost alternative
24+ "0.0.0.0/8",
25+ // IPv6 loopback
26+ "::1/128",
27+ // IPv6 unique local addresses (RFC 4193)
28+ "fc00::/7",
29+ // IPv6 link-local
30+ "fe80::/10",
31+}
32+
33+var (
34+ blockedCIDRs []*net.IPNet
35+ blockedCIDRsOnce sync.Once
36+)
37+
38+// initBlockedCIDRs parses the blocked CIDR strings into net.IPNet.
39+// Called lazily on first use.
40+func initBlockedCIDRs() {
41+ blockedCIDRsOnce.Do(func() {
42+ blockedCIDRs = make([]*net.IPNet, 0, len(blockedCIDRStrings))
43+ for _, cidr := range blockedCIDRStrings {
44+ _, network, err := net.ParseCIDR(cidr)
45+ if err != nil {
46+ // This should never happen with our hardcoded CIDRs
47+ panic(fmt.Sprintf("invalid blocked CIDR: %s: %v", cidr, err))
48+ }
49+ blockedCIDRs = append(blockedCIDRs, network)
50+ }
51+ })
52+}
53+
54+// isBlockedIP checks if an IP address is in any of the blocked CIDR ranges.
55+func isBlockedIP(ip net.IP) bool {
56+ initBlockedCIDRs()
57+ for _, network := range blockedCIDRs {
58+ if network.Contains(ip) {
59+ return true
60+ }
61+ }
62+ return false
63+}
64+
65+// IsBlockedTarget checks if a target host is in a blocked network range.
66+// It handles both IP addresses and hostnames (via DNS resolution).
67+// Returns (blocked, error) where error indicates DNS resolution failure.
68+//
69+// SECURITY: This function is used to prevent SSRF attacks by blocking
70+// requests to internal networks, localhost, and cloud metadata endpoints.
71+func IsBlockedTarget(host string) (bool, error) {
72+ initBlockedCIDRs()
73+
74+ // First, try to parse as IP directly
75+ if ip := net.ParseIP(host); ip != nil {
76+ return isBlockedIP(ip), nil
77+ }
78+
79+ // Not an IP address, resolve DNS
80+ ips, err := net.LookupIP(host)
81+ if err != nil {
82+ // DNS resolution failed - block by default for safety
83+ // This prevents DNS rebinding attacks where an attacker controls
84+ // a domain that initially resolves to a public IP, then later
85+ // resolves to an internal IP.
86+ return true, fmt.Errorf("DNS resolution failed for %s: %w", host, err)
87+ }
88+
89+ if len(ips) == 0 {
90+ // No IPs resolved - block for safety
91+ return true, fmt.Errorf("no IP addresses found for %s", host)
92+ }
93+
94+ // Check all resolved IPs - block if ANY resolve to a blocked network
95+ for _, ip := range ips {
96+ if isBlockedIP(ip) {
97+ return true, nil
98+ }
99+ }
100+
101+ return false, nil
102+}
103+
104+// ValidateExternalRouteTarget validates that an external route target
105+// is safe to proxy to. Returns domain.ErrSSRFBlocked if the target
106+// resolves to an internal/blocked network.
107+func ValidateExternalRouteTarget(host string) error {
108+ blocked, err := IsBlockedTarget(host)
109+ if err != nil {
110+ // DNS error - treat as blocked for security
111+ return fmt.Errorf("%w: %v", domain.ErrSSRFBlocked, err)
112+ }
113+ if blocked {
114+ return domain.ErrSSRFBlocked
115+ }
116+ return nil
117+}
@@ -0,0 +1,117 @@
1+// Package proxy implements the reverse proxy use case.
2+package proxy
3+
4+import (
5+ "fmt"
6+ "net"
7+ "sync"
8+
9+ "gordon/internal/domain"
10+)
11+
12+// blockedCIDRStrings contains CIDR blocks that should never be proxied to.
13+// This prevents SSRF attacks targeting internal services or cloud metadata endpoints.
14+var blockedCIDRStrings = []string{
15+ // IPv4 loopback
16+ "127.0.0.0/8",
17+ // IPv4 private networks (RFC 1918)
18+ "10.0.0.0/8",
19+ "172.16.0.0/12",
20+ "192.168.0.0/16",
21+ // IPv4 link-local (cloud metadata endpoints like AWS/GCP/Azure)
22+ "169.254.0.0/16",
23+ // IPv4 localhost alternative
24+ "0.0.0.0/8",
25+ // IPv6 loopback
26+ "::1/128",
27+ // IPv6 unique local addresses (RFC 4193)
28+ "fc00::/7",
29+ // IPv6 link-local
30+ "fe80::/10",
31+}
32+
33+var (
34+ blockedCIDRs []*net.IPNet
35+ blockedCIDRsOnce sync.Once
36+)
37+
38+// initBlockedCIDRs parses the blocked CIDR strings into net.IPNet.
39+// Called lazily on first use.
40+func initBlockedCIDRs() {
41+ blockedCIDRsOnce.Do(func() {
42+ blockedCIDRs = make([]*net.IPNet, 0, len(blockedCIDRStrings))
43+ for _, cidr := range blockedCIDRStrings {
44+ _, network, err := net.ParseCIDR(cidr)
45+ if err != nil {
46+ // This should never happen with our hardcoded CIDRs
47+ panic(fmt.Sprintf("invalid blocked CIDR: %s: %v", cidr, err))
48+ }
49+ blockedCIDRs = append(blockedCIDRs, network)
50+ }
51+ })
52+}
53+
54+// isBlockedIP checks if an IP address is in any of the blocked CIDR ranges.
55+func isBlockedIP(ip net.IP) bool {
56+ initBlockedCIDRs()
57+ for _, network := range blockedCIDRs {
58+ if network.Contains(ip) {
59+ return true
60+ }
61+ }
62+ return false
63+}
64+
65+// IsBlockedTarget checks if a target host is in a blocked network range.
66+// It handles both IP addresses and hostnames (via DNS resolution).
67+// Returns (blocked, error) where error indicates DNS resolution failure.
68+//
69+// SECURITY: This function is used to prevent SSRF attacks by blocking
70+// requests to internal networks, localhost, and cloud metadata endpoints.
71+func IsBlockedTarget(host string) (bool, error) {
72+ initBlockedCIDRs()
73+
74+ // First, try to parse as IP directly
75+ if ip := net.ParseIP(host); ip != nil {
76+ return isBlockedIP(ip), nil
77+ }
78+
79+ // Not an IP address, resolve DNS
80+ ips, err := net.LookupIP(host)
81+ if err != nil {
82+ // DNS resolution failed - block by default for safety
83+ // This prevents DNS rebinding attacks where an attacker controls
84+ // a domain that initially resolves to a public IP, then later
85+ // resolves to an internal IP.
86+ return true, fmt.Errorf("DNS resolution failed for %s: %w", host, err)
87+ }
88+
89+ if len(ips) == 0 {
90+ // No IPs resolved - block for safety
91+ return true, fmt.Errorf("no IP addresses found for %s", host)
92+ }
93+
94+ // Check all resolved IPs - block if ANY resolve to a blocked network
95+ for _, ip := range ips {
96+ if isBlockedIP(ip) {
97+ return true, nil
98+ }
99+ }
100+
101+ return false, nil
102+}
103+
104+// ValidateExternalRouteTarget validates that an external route target
105+// is safe to proxy to. Returns domain.ErrSSRFBlocked if the target
106+// resolves to an internal/blocked network.
107+func ValidateExternalRouteTarget(host string) error {
108+ blocked, err := IsBlockedTarget(host)
109+ if err != nil {
110+ // DNS error - treat as blocked for security
111+ return fmt.Errorf("%w: %v", domain.ErrSSRFBlocked, err)
112+ }
113+ if blocked {
114+ return domain.ErrSSRFBlocked
115+ }
116+ return nil
117+}
A internal/usecase/proxy/ssrf_test.go +191 -0
@@ -0,0 +1,191 @@
1+package proxy
2+
3+import (
4+ "errors"
5+ "net"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "gordon/internal/domain"
12+)
13+
14+func TestIsBlockedTarget_IPAddresses(t *testing.T) {
15+ tests := []struct {
16+ name string
17+ host string
18+ blocked bool
19+ }{
20+ // IPv4 loopback - should block
21+ {"IPv4 loopback 127.0.0.1", "127.0.0.1", true},
22+ {"IPv4 loopback 127.0.0.2", "127.0.0.2", true},
23+ {"IPv4 loopback 127.255.255.255", "127.255.255.255", true},
24+
25+ // IPv4 private networks - should block
26+ {"10.0.0.0/8 network", "10.0.0.1", true},
27+ {"10.0.0.0/8 network edge", "10.255.255.255", true},
28+ {"172.16.0.0/12 network start", "172.16.0.1", true},
29+ {"172.16.0.0/12 network middle", "172.20.0.1", true},
30+ {"172.16.0.0/12 network end", "172.31.255.255", true},
31+ {"192.168.0.0/16 network", "192.168.1.1", true},
32+ {"192.168.0.0/16 network edge", "192.168.255.255", true},
33+
34+ // AWS/GCP/Azure metadata endpoint - should block
35+ {"AWS metadata endpoint", "169.254.169.254", true},
36+ {"Link-local address", "169.254.0.1", true},
37+
38+ // IPv6 loopback - should block
39+ {"IPv6 loopback", "::1", true},
40+
41+ // IPv6 unique local - should block
42+ {"IPv6 unique local fc00", "fc00::1", true},
43+ {"IPv6 unique local fd00", "fd00::1", true},
44+
45+ // IPv6 link-local - should block
46+ {"IPv6 link-local", "fe80::1", true},
47+
48+ // Public IPs - should allow
49+ {"Google DNS", "8.8.8.8", false},
50+ {"Cloudflare DNS", "1.1.1.1", false},
51+ {"Public IP", "203.0.113.1", false},
52+ {"Public IPv6", "2001:4860:4860::8888", false},
53+
54+ // Edge cases for 172.16.0.0/12
55+ {"Just outside 172.16.0.0/12", "172.15.255.255", false},
56+ {"Just outside 172.16.0.0/12 upper", "172.32.0.0", false},
57+ }
58+
59+ for _, tt := range tests {
60+ t.Run(tt.name, func(t *testing.T) {
61+ blocked, err := IsBlockedTarget(tt.host)
62+ require.NoError(t, err)
63+ assert.Equal(t, tt.blocked, blocked, "IsBlockedTarget(%q) = %v, want %v", tt.host, blocked, tt.blocked)
64+ })
65+ }
66+}
67+
68+func TestIsBlockedTarget_Hostnames(t *testing.T) {
69+ tests := []struct {
70+ name string
71+ host string
72+ wantBlocked bool
73+ wantErr bool
74+ }{
75+ // Localhost hostname - should block
76+ {
77+ name: "localhost",
78+ host: "localhost",
79+ wantBlocked: true,
80+ wantErr: false,
81+ },
82+
83+ // Public domains - should allow (assuming they resolve to public IPs)
84+ {
85+ name: "google.com",
86+ host: "google.com",
87+ wantBlocked: false,
88+ wantErr: false,
89+ },
90+
91+ // Non-existent domain - should error and block
92+ {
93+ name: "non-existent domain",
94+ host: "this-domain-definitely-does-not-exist-12345.invalid",
95+ wantBlocked: true,
96+ wantErr: true,
97+ },
98+ }
99+
100+ for _, tt := range tests {
101+ t.Run(tt.name, func(t *testing.T) {
102+ blocked, err := IsBlockedTarget(tt.host)
103+
104+ if tt.wantErr {
105+ assert.Error(t, err)
106+ }
107+
108+ assert.Equal(t, tt.wantBlocked, blocked, "IsBlockedTarget(%q) blocked = %v, want %v", tt.host, blocked, tt.wantBlocked)
109+ })
110+ }
111+}
112+
113+func TestValidateExternalRouteTarget(t *testing.T) {
114+ tests := []struct {
115+ name string
116+ host string
117+ wantErr error
118+ }{
119+ // Blocked targets
120+ {
121+ name: "loopback IP",
122+ host: "127.0.0.1",
123+ wantErr: domain.ErrSSRFBlocked,
124+ },
125+ {
126+ name: "private network",
127+ host: "10.0.0.1",
128+ wantErr: domain.ErrSSRFBlocked,
129+ },
130+ {
131+ name: "AWS metadata",
132+ host: "169.254.169.254",
133+ wantErr: domain.ErrSSRFBlocked,
134+ },
135+ {
136+ name: "localhost",
137+ host: "localhost",
138+ wantErr: domain.ErrSSRFBlocked,
139+ },
140+
141+ // Allowed targets
142+ {
143+ name: "public IP",
144+ host: "8.8.8.8",
145+ wantErr: nil,
146+ },
147+ }
148+
149+ for _, tt := range tests {
150+ t.Run(tt.name, func(t *testing.T) {
151+ err := ValidateExternalRouteTarget(tt.host)
152+
153+ if tt.wantErr != nil {
154+ require.Error(t, err)
155+ assert.True(t, errors.Is(err, tt.wantErr), "expected error %v, got %v", tt.wantErr, err)
156+ } else {
157+ assert.NoError(t, err)
158+ }
159+ })
160+ }
161+}
162+
163+func TestIsBlockedIP(t *testing.T) {
164+ tests := []struct {
165+ ip string
166+ blocked bool
167+ }{
168+ // Should block
169+ {"127.0.0.1", true},
170+ {"10.0.0.1", true},
171+ {"172.16.0.1", true},
172+ {"192.168.1.1", true},
173+ {"169.254.169.254", true},
174+ {"0.0.0.0", true},
175+
176+ // Should allow
177+ {"8.8.8.8", false},
178+ {"1.1.1.1", false},
179+ {"203.0.113.1", false},
180+ }
181+
182+ for _, tt := range tests {
183+ t.Run(tt.ip, func(t *testing.T) {
184+ ip := net.ParseIP(tt.ip)
185+ require.NotNil(t, ip, "failed to parse IP: %s", tt.ip)
186+
187+ blocked := isBlockedIP(ip)
188+ assert.Equal(t, tt.blocked, blocked, "isBlockedIP(%s) = %v, want %v", tt.ip, blocked, tt.blocked)
189+ })
190+ }
191+}
@@ -0,0 +1,191 @@
1+package proxy
2+
3+import (
4+ "errors"
5+ "net"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "gordon/internal/domain"
12+)
13+
14+func TestIsBlockedTarget_IPAddresses(t *testing.T) {
15+ tests := []struct {
16+ name string
17+ host string
18+ blocked bool
19+ }{
20+ // IPv4 loopback - should block
21+ {"IPv4 loopback 127.0.0.1", "127.0.0.1", true},
22+ {"IPv4 loopback 127.0.0.2", "127.0.0.2", true},
23+ {"IPv4 loopback 127.255.255.255", "127.255.255.255", true},
24+
25+ // IPv4 private networks - should block
26+ {"10.0.0.0/8 network", "10.0.0.1", true},
27+ {"10.0.0.0/8 network edge", "10.255.255.255", true},
28+ {"172.16.0.0/12 network start", "172.16.0.1", true},
29+ {"172.16.0.0/12 network middle", "172.20.0.1", true},
30+ {"172.16.0.0/12 network end", "172.31.255.255", true},
31+ {"192.168.0.0/16 network", "192.168.1.1", true},
32+ {"192.168.0.0/16 network edge", "192.168.255.255", true},
33+
34+ // AWS/GCP/Azure metadata endpoint - should block
35+ {"AWS metadata endpoint", "169.254.169.254", true},
36+ {"Link-local address", "169.254.0.1", true},
37+
38+ // IPv6 loopback - should block
39+ {"IPv6 loopback", "::1", true},
40+
41+ // IPv6 unique local - should block
42+ {"IPv6 unique local fc00", "fc00::1", true},
43+ {"IPv6 unique local fd00", "fd00::1", true},
44+
45+ // IPv6 link-local - should block
46+ {"IPv6 link-local", "fe80::1", true},
47+
48+ // Public IPs - should allow
49+ {"Google DNS", "8.8.8.8", false},
50+ {"Cloudflare DNS", "1.1.1.1", false},
51+ {"Public IP", "203.0.113.1", false},
52+ {"Public IPv6", "2001:4860:4860::8888", false},
53+
54+ // Edge cases for 172.16.0.0/12
55+ {"Just outside 172.16.0.0/12", "172.15.255.255", false},
56+ {"Just outside 172.16.0.0/12 upper", "172.32.0.0", false},
57+ }
58+
59+ for _, tt := range tests {
60+ t.Run(tt.name, func(t *testing.T) {
61+ blocked, err := IsBlockedTarget(tt.host)
62+ require.NoError(t, err)
63+ assert.Equal(t, tt.blocked, blocked, "IsBlockedTarget(%q) = %v, want %v", tt.host, blocked, tt.blocked)
64+ })
65+ }
66+}
67+
68+func TestIsBlockedTarget_Hostnames(t *testing.T) {
69+ tests := []struct {
70+ name string
71+ host string
72+ wantBlocked bool
73+ wantErr bool
74+ }{
75+ // Localhost hostname - should block
76+ {
77+ name: "localhost",
78+ host: "localhost",
79+ wantBlocked: true,
80+ wantErr: false,
81+ },
82+
83+ // Public domains - should allow (assuming they resolve to public IPs)
84+ {
85+ name: "google.com",
86+ host: "google.com",
87+ wantBlocked: false,
88+ wantErr: false,
89+ },
90+
91+ // Non-existent domain - should error and block
92+ {
93+ name: "non-existent domain",
94+ host: "this-domain-definitely-does-not-exist-12345.invalid",
95+ wantBlocked: true,
96+ wantErr: true,
97+ },
98+ }
99+
100+ for _, tt := range tests {
101+ t.Run(tt.name, func(t *testing.T) {
102+ blocked, err := IsBlockedTarget(tt.host)
103+
104+ if tt.wantErr {
105+ assert.Error(t, err)
106+ }
107+
108+ assert.Equal(t, tt.wantBlocked, blocked, "IsBlockedTarget(%q) blocked = %v, want %v", tt.host, blocked, tt.wantBlocked)
109+ })
110+ }
111+}
112+
113+func TestValidateExternalRouteTarget(t *testing.T) {
114+ tests := []struct {
115+ name string
116+ host string
117+ wantErr error
118+ }{
119+ // Blocked targets
120+ {
121+ name: "loopback IP",
122+ host: "127.0.0.1",
123+ wantErr: domain.ErrSSRFBlocked,
124+ },
125+ {
126+ name: "private network",
127+ host: "10.0.0.1",
128+ wantErr: domain.ErrSSRFBlocked,
129+ },
130+ {
131+ name: "AWS metadata",
132+ host: "169.254.169.254",
133+ wantErr: domain.ErrSSRFBlocked,
134+ },
135+ {
136+ name: "localhost",
137+ host: "localhost",
138+ wantErr: domain.ErrSSRFBlocked,
139+ },
140+
141+ // Allowed targets
142+ {
143+ name: "public IP",
144+ host: "8.8.8.8",
145+ wantErr: nil,
146+ },
147+ }
148+
149+ for _, tt := range tests {
150+ t.Run(tt.name, func(t *testing.T) {
151+ err := ValidateExternalRouteTarget(tt.host)
152+
153+ if tt.wantErr != nil {
154+ require.Error(t, err)
155+ assert.True(t, errors.Is(err, tt.wantErr), "expected error %v, got %v", tt.wantErr, err)
156+ } else {
157+ assert.NoError(t, err)
158+ }
159+ })
160+ }
161+}
162+
163+func TestIsBlockedIP(t *testing.T) {
164+ tests := []struct {
165+ ip string
166+ blocked bool
167+ }{
168+ // Should block
169+ {"127.0.0.1", true},
170+ {"10.0.0.1", true},
171+ {"172.16.0.1", true},
172+ {"192.168.1.1", true},
173+ {"169.254.169.254", true},
174+ {"0.0.0.0", true},
175+
176+ // Should allow
177+ {"8.8.8.8", false},
178+ {"1.1.1.1", false},
179+ {"203.0.113.1", false},
180+ }
181+
182+ for _, tt := range tests {
183+ t.Run(tt.ip, func(t *testing.T) {
184+ ip := net.ParseIP(tt.ip)
185+ require.NotNil(t, ip, "failed to parse IP: %s", tt.ip)
186+
187+ blocked := isBlockedIP(ip)
188+ assert.Equal(t, tt.blocked, blocked, "isBlockedIP(%s) = %v, want %v", tt.ip, blocked, tt.blocked)
189+ })
190+ }
191+}
M wiki/examples/index.md +4 -6
@@ -27,7 +27,7 @@ gordon_domain = "gordon.local"
2727 port = 8080
2828 gordon_domain = "gordon.local"
2929
30-[registry_auth]
30+[auth]
3131 enabled = false
3232
3333 [auto_route]
@@ -44,13 +44,11 @@ enabled = true
4444 port = 8080
4545 gordon_domain = "gordon.company.com"
4646
47-[secrets]
48-backend = "pass"
49-
50-[registry_auth]
47+[auth]
5148 enabled = true
5249 type = "token"
53-token_secret = "gordon/registry/token_secret"
50+secrets_backend = "pass"
51+token_secret = "gordon/auth/token_secret"
5452
5553 [network_isolation]
5654 enabled = true
@@ -27,7 +27,7 @@ gordon_domain = "gordon.local"
27 port = 808027 port = 8080
28 gordon_domain = "gordon.local"28 gordon_domain = "gordon.local"
29 29
30-[registry_auth]30+[auth]
31 enabled = false31 enabled = false
32 32
33 [auto_route]33 [auto_route]
@@ -44,13 +44,11 @@ enabled = true
44 port = 808044 port = 8080
45 gordon_domain = "gordon.company.com"45 gordon_domain = "gordon.company.com"
46 46
47-[secrets]47+[auth]
48-backend = "pass"
49-
50-[registry_auth]
51 enabled = true48 enabled = true
52 type = "token"49 type = "token"
53-token_secret = "gordon/registry/token_secret"50+secrets_backend = "pass"
51+token_secret = "gordon/auth/token_secret"
54 52
55 [network_isolation]53 [network_isolation]
56 enabled = true54 enabled = true
M wiki/examples/minimal.md +1 -1
@@ -19,7 +19,7 @@ registry_port = 5000
1919 gordon_domain = "gordon.local"
2020
2121 # Authentication disabled for simplicity
22-[registry_auth]
22+[auth]
2323 enabled = false
2424
2525 # Simple route
@@ -19,7 +19,7 @@ registry_port = 5000
19 gordon_domain = "gordon.local"19 gordon_domain = "gordon.local"
20 20
21 # Authentication disabled for simplicity21 # Authentication disabled for simplicity
22-[registry_auth]22+[auth]
23 enabled = false23 enabled = false
24 24
25 # Simple route25 # Simple route
A wiki/guides/aws-alb.md +161 -0
@@ -0,0 +1,161 @@
1+# AWS Application Load Balancer (ALB) Setup
2+
3+This guide covers deploying Gordon behind AWS Application Load Balancer for TLS termination.
4+
5+## Overview
6+
7+AWS ALB is a Layer 7 load balancer that:
8+- Terminates TLS connections
9+- Forwards requests to your EC2 instances or ECS containers
10+- Automatically scales with traffic
11+- Sets `X-Forwarded-For` header with the client's real IP
12+
13+## Architecture
14+
15+```
16+Client → ALB (HTTPS/443) → Gordon (HTTP/80) → Containers
17+ ↓
18+ TLS terminated
19+ X-Forwarded-For set
20+```
21+
22+## Understanding VPC CIDR
23+
24+A **VPC** (Virtual Private Cloud) is your isolated network in AWS. Every VPC has a **CIDR** (Classless Inter-Domain Routing) block—the private IP range used by resources in that VPC.
25+
26+ALB connects to Gordon from within your VPC, so Gordon sees the ALB's private IP, not the client's public IP. The client IP is passed via `X-Forwarded-For`.
27+
28+**To find your VPC CIDR:**
29+
30+1. Go to AWS Console → VPC → Your VPCs
31+2. Find the **IPv4 CIDR** column
32+3. Common values: `10.0.0.0/16`, `172.31.0.0/16`
33+
34+## Gordon Configuration
35+
36+```toml
37+[api.rate_limit]
38+enabled = true
39+# Trust your VPC CIDR so Gordon reads X-Forwarded-For from ALB
40+trusted_proxies = ["10.0.0.0/16"] # Replace with your VPC CIDR
41+```
42+
43+### Multiple VPCs or Subnets
44+
45+If Gordon receives traffic from multiple VPCs (e.g., via VPC peering):
46+
47+```toml
48+[api.rate_limit]
49+trusted_proxies = [
50+ "10.0.0.0/16", # Production VPC
51+ "10.1.0.0/16", # Staging VPC
52+]
53+```
54+
55+## ALB Configuration
56+
57+### Target Group Settings
58+
59+When creating your ALB target group:
60+
61+| Setting | Value |
62+|---------|-------|
63+| Target type | `instance` or `ip` |
64+| Protocol | HTTP |
65+| Port | 80 (or your Gordon port) |
66+| Health check path | `/health` or `/v2/` |
67+
68+### Client IP Preservation
69+
70+ALB automatically sets `X-Forwarded-For`. No special configuration needed for HTTP targets.
71+
72+For **NLB** (Network Load Balancer) with TCP, you may need to enable Proxy Protocol v2:
73+
74+```yaml
75+# Kubernetes annotation for AWS Load Balancer Controller
76+service.beta.kubernetes.io/aws-load-balancer-proxy-protocol: "*"
77+```
78+
79+## Verifying the Setup
80+
81+1. Make a request through the ALB
82+2. Check Gordon logs for the client IP:
83+
84+```bash
85+# Should show real client IP, not ALB IP
86+journalctl -u gordon | grep "client_ip"
87+```
88+
89+If you see the ALB's private IP (e.g., `10.0.x.x`) instead of the client's public IP, check that:
90+- `trusted_proxies` includes your VPC CIDR
91+- ALB is forwarding `X-Forwarded-For` (default behavior)
92+
93+## Security Groups
94+
95+Ensure your security groups allow:
96+
97+| Source | Destination | Port | Purpose |
98+|--------|-------------|------|---------|
99+| `0.0.0.0/0` | ALB | 443 | HTTPS from internet |
100+| ALB SG | Gordon SG | 80 | ALB to Gordon |
101+
102+## Terraform Example
103+
104+```hcl
105+resource "aws_lb" "gordon" {
106+ name = "gordon-alb"
107+ internal = false
108+ load_balancer_type = "application"
109+ security_groups = [aws_security_group.alb.id]
110+ subnets = var.public_subnets
111+}
112+
113+resource "aws_lb_listener" "https" {
114+ load_balancer_arn = aws_lb.gordon.arn
115+ port = "443"
116+ protocol = "HTTPS"
117+ ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
118+ certificate_arn = var.certificate_arn
119+
120+ default_action {
121+ type = "forward"
122+ target_group_arn = aws_lb_target_group.gordon.arn
123+ }
124+}
125+
126+resource "aws_lb_target_group" "gordon" {
127+ name = "gordon-tg"
128+ port = 80
129+ protocol = "HTTP"
130+ vpc_id = var.vpc_id
131+
132+ health_check {
133+ path = "/v2/"
134+ healthy_threshold = 2
135+ unhealthy_threshold = 10
136+ }
137+}
138+```
139+
140+## Troubleshooting
141+
142+### All requests show the same IP
143+
144+**Symptom:** Rate limiting doesn't work, logs show ALB IP for all requests.
145+
146+**Cause:** `trusted_proxies` doesn't include ALB's IP range.
147+
148+**Fix:** Add your VPC CIDR to `trusted_proxies`.
149+
150+### 502 Bad Gateway
151+
152+**Symptom:** ALB returns 502 errors.
153+
154+**Cause:** Gordon health check failing or not listening.
155+
156+**Fix:** Verify Gordon is running and health check path is correct.
157+
158+## Related
159+
160+- [Rate Limiting Configuration](../../docs/config/rate-limiting.md)
161+- [Running Gordon in a Container](./running-in-container.md)
@@ -0,0 +1,161 @@
1+# AWS Application Load Balancer (ALB) Setup
2+
3+This guide covers deploying Gordon behind AWS Application Load Balancer for TLS termination.
4+
5+## Overview
6+
7+AWS ALB is a Layer 7 load balancer that:
8+- Terminates TLS connections
9+- Forwards requests to your EC2 instances or ECS containers
10+- Automatically scales with traffic
11+- Sets `X-Forwarded-For` header with the client's real IP
12+
13+## Architecture
14+
15+```
16+Client → ALB (HTTPS/443) → Gordon (HTTP/80) → Containers
17+ ↓
18+ TLS terminated
19+ X-Forwarded-For set
20+```
21+
22+## Understanding VPC CIDR
23+
24+A **VPC** (Virtual Private Cloud) is your isolated network in AWS. Every VPC has a **CIDR** (Classless Inter-Domain Routing) block—the private IP range used by resources in that VPC.
25+
26+ALB connects to Gordon from within your VPC, so Gordon sees the ALB's private IP, not the client's public IP. The client IP is passed via `X-Forwarded-For`.
27+
28+**To find your VPC CIDR:**
29+
30+1. Go to AWS Console → VPC → Your VPCs
31+2. Find the **IPv4 CIDR** column
32+3. Common values: `10.0.0.0/16`, `172.31.0.0/16`
33+
34+## Gordon Configuration
35+
36+```toml
37+[api.rate_limit]
38+enabled = true
39+# Trust your VPC CIDR so Gordon reads X-Forwarded-For from ALB
40+trusted_proxies = ["10.0.0.0/16"] # Replace with your VPC CIDR
41+```
42+
43+### Multiple VPCs or Subnets
44+
45+If Gordon receives traffic from multiple VPCs (e.g., via VPC peering):
46+
47+```toml
48+[api.rate_limit]
49+trusted_proxies = [
50+ "10.0.0.0/16", # Production VPC
51+ "10.1.0.0/16", # Staging VPC
52+]
53+```
54+
55+## ALB Configuration
56+
57+### Target Group Settings
58+
59+When creating your ALB target group:
60+
61+| Setting | Value |
62+|---------|-------|
63+| Target type | `instance` or `ip` |
64+| Protocol | HTTP |
65+| Port | 80 (or your Gordon port) |
66+| Health check path | `/health` or `/v2/` |
67+
68+### Client IP Preservation
69+
70+ALB automatically sets `X-Forwarded-For`. No special configuration needed for HTTP targets.
71+
72+For **NLB** (Network Load Balancer) with TCP, you may need to enable Proxy Protocol v2:
73+
74+```yaml
75+# Kubernetes annotation for AWS Load Balancer Controller
76+service.beta.kubernetes.io/aws-load-balancer-proxy-protocol: "*"
77+```
78+
79+## Verifying the Setup
80+
81+1. Make a request through the ALB
82+2. Check Gordon logs for the client IP:
83+
84+```bash
85+# Should show real client IP, not ALB IP
86+journalctl -u gordon | grep "client_ip"
87+```
88+
89+If you see the ALB's private IP (e.g., `10.0.x.x`) instead of the client's public IP, check that:
90+- `trusted_proxies` includes your VPC CIDR
91+- ALB is forwarding `X-Forwarded-For` (default behavior)
92+
93+## Security Groups
94+
95+Ensure your security groups allow:
96+
97+| Source | Destination | Port | Purpose |
98+|--------|-------------|------|---------|
99+| `0.0.0.0/0` | ALB | 443 | HTTPS from internet |
100+| ALB SG | Gordon SG | 80 | ALB to Gordon |
101+
102+## Terraform Example
103+
104+```hcl
105+resource "aws_lb" "gordon" {
106+ name = "gordon-alb"
107+ internal = false
108+ load_balancer_type = "application"
109+ security_groups = [aws_security_group.alb.id]
110+ subnets = var.public_subnets
111+}
112+
113+resource "aws_lb_listener" "https" {
114+ load_balancer_arn = aws_lb.gordon.arn
115+ port = "443"
116+ protocol = "HTTPS"
117+ ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
118+ certificate_arn = var.certificate_arn
119+
120+ default_action {
121+ type = "forward"
122+ target_group_arn = aws_lb_target_group.gordon.arn
123+ }
124+}
125+
126+resource "aws_lb_target_group" "gordon" {
127+ name = "gordon-tg"
128+ port = 80
129+ protocol = "HTTP"
130+ vpc_id = var.vpc_id
131+
132+ health_check {
133+ path = "/v2/"
134+ healthy_threshold = 2
135+ unhealthy_threshold = 10
136+ }
137+}
138+```
139+
140+## Troubleshooting
141+
142+### All requests show the same IP
143+
144+**Symptom:** Rate limiting doesn't work, logs show ALB IP for all requests.
145+
146+**Cause:** `trusted_proxies` doesn't include ALB's IP range.
147+
148+**Fix:** Add your VPC CIDR to `trusted_proxies`.
149+
150+### 502 Bad Gateway
151+
152+**Symptom:** ALB returns 502 errors.
153+
154+**Cause:** Gordon health check failing or not listening.
155+
156+**Fix:** Verify Gordon is running and health check path is correct.
157+
158+## Related
159+
160+- [Rate Limiting Configuration](../../docs/config/rate-limiting.md)
161+- [Running Gordon in a Container](./running-in-container.md)
M wiki/guides/index.md +4 -2
@@ -7,9 +7,11 @@ In-depth guides for specific Gordon setups and integrations.
77 - [Running Gordon in a Container](./running-in-container.md) - Deploy Gordon itself in Docker or Podman
88 - [Podman Rootless Setup](./podman-rootless.md) - Enhanced security with rootless containers
99
10-## External Services
10+## TLS & Load Balancing
1111
12-- [Cloudflare DNS & Proxy Setup](./cloudflare-setup.md) - HTTPS termination with Cloudflare
12+- [Cloudflare DNS & Proxy Setup](./cloudflare-setup.md) - HTTPS termination with Cloudflare (recommended)
13+- [AWS Application Load Balancer](./aws-alb.md) - Deploy behind AWS ALB
14+- [Kubernetes Ingress](./kubernetes-ingress.md) - Deploy behind Kubernetes ingress controller
1315
1416 ## Remote Management
1517
@@ -7,9 +7,11 @@ In-depth guides for specific Gordon setups and integrations.
7 - [Running Gordon in a Container](./running-in-container.md) - Deploy Gordon itself in Docker or Podman7 - [Running Gordon in a Container](./running-in-container.md) - Deploy Gordon itself in Docker or Podman
8 - [Podman Rootless Setup](./podman-rootless.md) - Enhanced security with rootless containers8 - [Podman Rootless Setup](./podman-rootless.md) - Enhanced security with rootless containers
9 9
10-## External Services10+## TLS & Load Balancing
11 11
12-- [Cloudflare DNS & Proxy Setup](./cloudflare-setup.md) - HTTPS termination with Cloudflare12+- [Cloudflare DNS & Proxy Setup](./cloudflare-setup.md) - HTTPS termination with Cloudflare (recommended)
13+- [AWS Application Load Balancer](./aws-alb.md) - Deploy behind AWS ALB
14+- [Kubernetes Ingress](./kubernetes-ingress.md) - Deploy behind Kubernetes ingress controller
13 15
14 ## Remote Management16 ## Remote Management
15 17
A wiki/guides/kubernetes-ingress.md +287 -0
@@ -0,0 +1,287 @@
1+# Kubernetes Ingress Setup
2+
3+This guide covers deploying Gordon behind a Kubernetes ingress controller for TLS termination.
4+
5+## Overview
6+
7+In Kubernetes, an **Ingress Controller** handles external traffic:
8+- Terminates TLS connections
9+- Routes requests to services based on hostname/path
10+- Sets `X-Forwarded-For` header with the client's real IP
11+
12+Common ingress controllers: **nginx-ingress**, **Traefik**, **Contour**, **Kong**.
13+
14+## Architecture
15+
16+```
17+Client → Ingress Controller (HTTPS/443) → Gordon Service (HTTP) → Gordon Pod
18+ ↓
19+ TLS terminated
20+ X-Forwarded-For set
21+```
22+
23+## Understanding Pod Network CIDR
24+
25+Kubernetes assigns each pod an IP from the **pod network CIDR**. When the ingress controller forwards requests to Gordon, it comes from the ingress pod's IP.
26+
27+**To find your pod network CIDR:**
28+
29+```bash
30+# Method 1: Check cluster-info
31+kubectl cluster-info dump | grep -m 1 cluster-cidr
32+
33+# Method 2: Check CNI config (varies by CNI plugin)
34+kubectl get cm -n kube-system kubeadm-config -o yaml | grep podSubnet
35+
36+# Method 3: Check a pod's IP to infer the range
37+kubectl get pods -o wide
38+```
39+
40+**Common defaults by CNI:**
41+
42+| CNI Plugin | Default Pod CIDR |
43+|------------|------------------|
44+| Flannel | `10.244.0.0/16` |
45+| Calico | `192.168.0.0/16` |
46+| Weave | `10.32.0.0/12` |
47+| Cilium | `10.0.0.0/8` |
48+| GKE | `10.0.0.0/14` or `/9` |
49+| EKS (VPC CNI) | VPC CIDR |
50+
51+## Gordon Configuration
52+
53+```toml
54+[api.rate_limit]
55+enabled = true
56+# Trust pod network so Gordon reads X-Forwarded-For from ingress
57+trusted_proxies = ["10.244.0.0/16"] # Replace with your pod CIDR
58+```
59+
60+## Ingress Controller Configuration
61+
62+The ingress controller must be configured to forward the real client IP.
63+
64+### nginx-ingress
65+
66+Create or update the ConfigMap:
67+
68+```yaml
69+apiVersion: v1
70+kind: ConfigMap
71+metadata:
72+ name: ingress-nginx-controller
73+ namespace: ingress-nginx
74+data:
75+ # Enable X-Forwarded-For processing
76+ use-forwarded-headers: "true"
77+ # Trust these CIDRs to set X-Forwarded-For
78+ # Include your cloud provider's load balancer IPs
79+ proxy-real-ip-cidr: "0.0.0.0/0" # Or specific LB CIDR
80+```
81+
82+If you have a cloud load balancer in front of nginx-ingress (common in EKS, GKE):
83+
84+```yaml
85+data:
86+ use-forwarded-headers: "true"
87+ use-proxy-protocol: "false" # Set to "true" if using NLB with proxy protocol
88+ proxy-real-ip-cidr: "10.0.0.0/8,192.168.0.0/16"
89+```
90+
91+### Traefik
92+
93+In your Traefik configuration:
94+
95+```yaml
96+# traefik.yaml or Helm values
97+entryPoints:
98+ web:
99+ address: ":80"
100+ forwardedHeaders:
101+ trustedIPs:
102+ - "10.0.0.0/8"
103+ - "192.168.0.0/16"
104+ websecure:
105+ address: ":443"
106+ forwardedHeaders:
107+ trustedIPs:
108+ - "10.0.0.0/8"
109+ - "192.168.0.0/16"
110+```
111+
112+## Complete Example
113+
114+### Gordon Deployment
115+
116+```yaml
117+apiVersion: apps/v1
118+kind: Deployment
119+metadata:
120+ name: gordon
121+spec:
122+ replicas: 1
123+ selector:
124+ matchLabels:
125+ app: gordon
126+ template:
127+ metadata:
128+ labels:
129+ app: gordon
130+ spec:
131+ containers:
132+ - name: gordon
133+ image: your-registry/gordon:latest
134+ ports:
135+ - containerPort: 80
136+ - containerPort: 5000
137+ volumeMounts:
138+ - name: config
139+ mountPath: /etc/gordon
140+ - name: docker-sock
141+ mountPath: /var/run/docker.sock
142+ volumes:
143+ - name: config
144+ configMap:
145+ name: gordon-config
146+ - name: docker-sock
147+ hostPath:
148+ path: /var/run/docker.sock
149+---
150+apiVersion: v1
151+kind: Service
152+metadata:
153+ name: gordon
154+spec:
155+ selector:
156+ app: gordon
157+ ports:
158+ - name: http
159+ port: 80
160+ targetPort: 80
161+ - name: registry
162+ port: 5000
163+ targetPort: 5000
164+```
165+
166+### Gordon ConfigMap
167+
168+```yaml
169+apiVersion: v1
170+kind: ConfigMap
171+metadata:
172+ name: gordon-config
173+data:
174+ gordon.toml: |
175+ [server]
176+ port = 80
177+ registry_port = 5000
178+ gordon_domain = "gordon.example.com"
179+
180+ [api.rate_limit]
181+ enabled = true
182+ trusted_proxies = ["10.244.0.0/16"]
183+
184+ [auth]
185+ enabled = false # Configure auth as needed
186+```
187+
188+### Ingress Resource
189+
190+```yaml
191+apiVersion: networking.k8s.io/v1
192+kind: Ingress
193+metadata:
194+ name: gordon
195+ annotations:
196+ nginx.ingress.kubernetes.io/proxy-body-size: "0" # Unlimited for image uploads
197+spec:
198+ ingressClassName: nginx
199+ tls:
200+ - hosts:
201+ - gordon.example.com
202+ secretName: gordon-tls
203+ rules:
204+ - host: gordon.example.com
205+ http:
206+ paths:
207+ - path: /
208+ pathType: Prefix
209+ backend:
210+ service:
211+ name: gordon
212+ port:
213+ number: 80
214+```
215+
216+## Verifying the Setup
217+
218+1. Deploy Gordon and the ingress
219+2. Make a request through the ingress
220+3. Check Gordon logs:
221+
222+```bash
223+kubectl logs -l app=gordon | grep "client_ip"
224+```
225+
226+If you see the ingress pod's IP instead of the client's IP:
227+- Verify `trusted_proxies` includes your pod CIDR
228+- Verify ingress controller has `use-forwarded-headers: "true"`
229+
230+## Cloud-Specific Notes
231+
232+### GKE
233+
234+GKE uses a VPC-native pod network. Find the pod CIDR in:
235+- Console → Kubernetes Engine → Cluster → Networking → Pod address range
236+
237+```toml
238+trusted_proxies = ["10.0.0.0/14"] # GKE default
239+```
240+
241+### EKS
242+
243+EKS with VPC CNI assigns pod IPs from your VPC subnets:
244+
245+```toml
246+trusted_proxies = ["10.0.0.0/16"] # Your VPC CIDR
247+```
248+
249+### AKS
250+
251+AKS default pod CIDR:
252+
253+```toml
254+trusted_proxies = ["10.244.0.0/16"] # Azure CNI default
255+```
256+
257+## Troubleshooting
258+
259+### All requests show the same IP
260+
261+**Symptom:** Rate limiting ineffective, logs show ingress pod IP.
262+
263+**Cause:** `trusted_proxies` missing pod CIDR or ingress not forwarding headers.
264+
265+**Fix:**
266+1. Add pod CIDR to `trusted_proxies`
267+2. Enable `use-forwarded-headers` in ingress controller
268+
269+### X-Forwarded-For contains multiple IPs
270+
271+**Symptom:** `X-Forwarded-For: 203.0.113.50, 10.0.1.5, 10.244.0.3`
272+
273+This is normal when multiple proxies are involved (cloud LB → ingress → Gordon). Gordon takes the **first** IP (leftmost), which is the original client.
274+
275+### Client IP shows cloud load balancer
276+
277+**Symptom:** Client IP is AWS/GCP/Azure LB IP, not real client.
278+
279+**Cause:** Cloud LB not configured to forward client IP.
280+
281+**Fix:** Configure cloud LB to preserve or pass client IP (varies by provider).
282+
283+## Related
284+
285+- [Rate Limiting Configuration](../../docs/config/rate-limiting.md)
286+- [AWS ALB Guide](./aws-alb.md)
287+- [Running Gordon in a Container](./running-in-container.md)
@@ -0,0 +1,287 @@
1+# Kubernetes Ingress Setup
2+
3+This guide covers deploying Gordon behind a Kubernetes ingress controller for TLS termination.
4+
5+## Overview
6+
7+In Kubernetes, an **Ingress Controller** handles external traffic:
8+- Terminates TLS connections
9+- Routes requests to services based on hostname/path
10+- Sets `X-Forwarded-For` header with the client's real IP
11+
12+Common ingress controllers: **nginx-ingress**, **Traefik**, **Contour**, **Kong**.
13+
14+## Architecture
15+
16+```
17+Client → Ingress Controller (HTTPS/443) → Gordon Service (HTTP) → Gordon Pod
18+ ↓
19+ TLS terminated
20+ X-Forwarded-For set
21+```
22+
23+## Understanding Pod Network CIDR
24+
25+Kubernetes assigns each pod an IP from the **pod network CIDR**. When the ingress controller forwards requests to Gordon, it comes from the ingress pod's IP.
26+
27+**To find your pod network CIDR:**
28+
29+```bash
30+# Method 1: Check cluster-info
31+kubectl cluster-info dump | grep -m 1 cluster-cidr
32+
33+# Method 2: Check CNI config (varies by CNI plugin)
34+kubectl get cm -n kube-system kubeadm-config -o yaml | grep podSubnet
35+
36+# Method 3: Check a pod's IP to infer the range
37+kubectl get pods -o wide
38+```
39+
40+**Common defaults by CNI:**
41+
42+| CNI Plugin | Default Pod CIDR |
43+|------------|------------------|
44+| Flannel | `10.244.0.0/16` |
45+| Calico | `192.168.0.0/16` |
46+| Weave | `10.32.0.0/12` |
47+| Cilium | `10.0.0.0/8` |
48+| GKE | `10.0.0.0/14` or `/9` |
49+| EKS (VPC CNI) | VPC CIDR |
50+
51+## Gordon Configuration
52+
53+```toml
54+[api.rate_limit]
55+enabled = true
56+# Trust pod network so Gordon reads X-Forwarded-For from ingress
57+trusted_proxies = ["10.244.0.0/16"] # Replace with your pod CIDR
58+```
59+
60+## Ingress Controller Configuration
61+
62+The ingress controller must be configured to forward the real client IP.
63+
64+### nginx-ingress
65+
66+Create or update the ConfigMap:
67+
68+```yaml
69+apiVersion: v1
70+kind: ConfigMap
71+metadata:
72+ name: ingress-nginx-controller
73+ namespace: ingress-nginx
74+data:
75+ # Enable X-Forwarded-For processing
76+ use-forwarded-headers: "true"
77+ # Trust these CIDRs to set X-Forwarded-For
78+ # Include your cloud provider's load balancer IPs
79+ proxy-real-ip-cidr: "0.0.0.0/0" # Or specific LB CIDR
80+```
81+
82+If you have a cloud load balancer in front of nginx-ingress (common in EKS, GKE):
83+
84+```yaml
85+data:
86+ use-forwarded-headers: "true"
87+ use-proxy-protocol: "false" # Set to "true" if using NLB with proxy protocol
88+ proxy-real-ip-cidr: "10.0.0.0/8,192.168.0.0/16"
89+```
90+
91+### Traefik
92+
93+In your Traefik configuration:
94+
95+```yaml
96+# traefik.yaml or Helm values
97+entryPoints:
98+ web:
99+ address: ":80"
100+ forwardedHeaders:
101+ trustedIPs:
102+ - "10.0.0.0/8"
103+ - "192.168.0.0/16"
104+ websecure:
105+ address: ":443"
106+ forwardedHeaders:
107+ trustedIPs:
108+ - "10.0.0.0/8"
109+ - "192.168.0.0/16"
110+```
111+
112+## Complete Example
113+
114+### Gordon Deployment
115+
116+```yaml
117+apiVersion: apps/v1
118+kind: Deployment
119+metadata:
120+ name: gordon
121+spec:
122+ replicas: 1
123+ selector:
124+ matchLabels:
125+ app: gordon
126+ template:
127+ metadata:
128+ labels:
129+ app: gordon
130+ spec:
131+ containers:
132+ - name: gordon
133+ image: your-registry/gordon:latest
134+ ports:
135+ - containerPort: 80
136+ - containerPort: 5000
137+ volumeMounts:
138+ - name: config
139+ mountPath: /etc/gordon
140+ - name: docker-sock
141+ mountPath: /var/run/docker.sock
142+ volumes:
143+ - name: config
144+ configMap:
145+ name: gordon-config
146+ - name: docker-sock
147+ hostPath:
148+ path: /var/run/docker.sock
149+---
150+apiVersion: v1
151+kind: Service
152+metadata:
153+ name: gordon
154+spec:
155+ selector:
156+ app: gordon
157+ ports:
158+ - name: http
159+ port: 80
160+ targetPort: 80
161+ - name: registry
162+ port: 5000
163+ targetPort: 5000
164+```
165+
166+### Gordon ConfigMap
167+
168+```yaml
169+apiVersion: v1
170+kind: ConfigMap
171+metadata:
172+ name: gordon-config
173+data:
174+ gordon.toml: |
175+ [server]
176+ port = 80
177+ registry_port = 5000
178+ gordon_domain = "gordon.example.com"
179+
180+ [api.rate_limit]
181+ enabled = true
182+ trusted_proxies = ["10.244.0.0/16"]
183+
184+ [auth]
185+ enabled = false # Configure auth as needed
186+```
187+
188+### Ingress Resource
189+
190+```yaml
191+apiVersion: networking.k8s.io/v1
192+kind: Ingress
193+metadata:
194+ name: gordon
195+ annotations:
196+ nginx.ingress.kubernetes.io/proxy-body-size: "0" # Unlimited for image uploads
197+spec:
198+ ingressClassName: nginx
199+ tls:
200+ - hosts:
201+ - gordon.example.com
202+ secretName: gordon-tls
203+ rules:
204+ - host: gordon.example.com
205+ http:
206+ paths:
207+ - path: /
208+ pathType: Prefix
209+ backend:
210+ service:
211+ name: gordon
212+ port:
213+ number: 80
214+```
215+
216+## Verifying the Setup
217+
218+1. Deploy Gordon and the ingress
219+2. Make a request through the ingress
220+3. Check Gordon logs:
221+
222+```bash
223+kubectl logs -l app=gordon | grep "client_ip"
224+```
225+
226+If you see the ingress pod's IP instead of the client's IP:
227+- Verify `trusted_proxies` includes your pod CIDR
228+- Verify ingress controller has `use-forwarded-headers: "true"`
229+
230+## Cloud-Specific Notes
231+
232+### GKE
233+
234+GKE uses a VPC-native pod network. Find the pod CIDR in:
235+- Console → Kubernetes Engine → Cluster → Networking → Pod address range
236+
237+```toml
238+trusted_proxies = ["10.0.0.0/14"] # GKE default
239+```
240+
241+### EKS
242+
243+EKS with VPC CNI assigns pod IPs from your VPC subnets:
244+
245+```toml
246+trusted_proxies = ["10.0.0.0/16"] # Your VPC CIDR
247+```
248+
249+### AKS
250+
251+AKS default pod CIDR:
252+
253+```toml
254+trusted_proxies = ["10.244.0.0/16"] # Azure CNI default
255+```
256+
257+## Troubleshooting
258+
259+### All requests show the same IP
260+
261+**Symptom:** Rate limiting ineffective, logs show ingress pod IP.
262+
263+**Cause:** `trusted_proxies` missing pod CIDR or ingress not forwarding headers.
264+
265+**Fix:**
266+1. Add pod CIDR to `trusted_proxies`
267+2. Enable `use-forwarded-headers` in ingress controller
268+
269+### X-Forwarded-For contains multiple IPs
270+
271+**Symptom:** `X-Forwarded-For: 203.0.113.50, 10.0.1.5, 10.244.0.3`
272+
273+This is normal when multiple proxies are involved (cloud LB → ingress → Gordon). Gordon takes the **first** IP (leftmost), which is the original client.
274+
275+### Client IP shows cloud load balancer
276+
277+**Symptom:** Client IP is AWS/GCP/Azure LB IP, not real client.
278+
279+**Cause:** Cloud LB not configured to forward client IP.
280+
281+**Fix:** Configure cloud LB to preserve or pass client IP (varies by provider).
282+
283+## Related
284+
285+- [Rate Limiting Configuration](../../docs/config/rate-limiting.md)
286+- [AWS ALB Guide](./aws-alb.md)
287+- [Running Gordon in a Container](./running-in-container.md)
M wiki/guides/running-in-container.md +6 -8
@@ -240,8 +240,8 @@ gpg --gen-key
240240 pass init <your-gpg-key-id>
241241
242242 # Store Gordon secrets
243-pass insert gordon/registry/password_hash
244-pass insert gordon/registry/token_secret
243+pass insert gordon/auth/password_hash
244+pass insert gordon/auth/token_secret
245245 ```
246246
247247 ### Custom Dockerfile
@@ -307,15 +307,13 @@ volumes:
307307 ### Configuration
308308
309309 ```toml
310-[secrets]
311-backend = "pass"
312-
313-[registry_auth]
310+[auth]
314311 enabled = true
315312 type = "token"
313+secrets_backend = "pass"
316314 username = "admin"
317-password_hash = "gordon/registry/password_hash"
318-token_secret = "gordon/registry/token_secret"
315+password_hash = "gordon/auth/password_hash"
316+token_secret = "gordon/auth/token_secret"
319317 ```
320318
321319 ### GPG Agent (Optional)
@@ -240,8 +240,8 @@ gpg --gen-key
240 pass init <your-gpg-key-id>240 pass init <your-gpg-key-id>
241 241
242 # Store Gordon secrets242 # Store Gordon secrets
243-pass insert gordon/registry/password_hash243+pass insert gordon/auth/password_hash
244-pass insert gordon/registry/token_secret244+pass insert gordon/auth/token_secret
245 ```245 ```
246 246
247 ### Custom Dockerfile247 ### Custom Dockerfile
@@ -307,15 +307,13 @@ volumes:
307 ### Configuration307 ### Configuration
308 308
309 ```toml309 ```toml
310-[secrets]310+[auth]
311-backend = "pass"
312-
313-[registry_auth]
314 enabled = true311 enabled = true
315 type = "token"312 type = "token"
313+secrets_backend = "pass"
316 username = "admin"314 username = "admin"
317-password_hash = "gordon/registry/password_hash"315+password_hash = "gordon/auth/password_hash"
318-token_secret = "gordon/registry/token_secret"316+token_secret = "gordon/auth/token_secret"
319 ```317 ```
320 318
321 ### GPG Agent (Optional)319 ### GPG Agent (Optional)

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