Merge pull request #33 from bnema/security/audit-2026-01
Security Audit 2026-01: Fix vulnerabilities
29 files · +2544 -69
M .github/actions/deploy/README.md +3 -5
| @@ -125,13 +125,11 @@ In your repository settings, add: | |||
| 125 | 125 | Ensure your Gordon config uses token authentication: | |
| 126 | 126 | ||
| 127 | 127 | ```toml | |
| 128 | - | [secrets] | |
| 129 | - | backend = "pass" # or "sops" | |
| 130 | - | ||
| 131 | - | [registry_auth] | |
| 128 | + | [auth] | |
| 132 | 129 | enabled = true | |
| 133 | 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 | 135 | ## 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 | ```toml | 127 | ```toml | ||
| 128 | - | [secrets] | 128 | + | [auth] |
| 129 | - | backend = "pass" # or "sops" | |||
| 130 | - | ||||
| 131 | - | [registry_auth] | |||
| 132 | enabled = true | 129 | 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 | ## Examples | 135 | ## Examples | ||
M docs/config/auth.md +15 -0
| @@ -52,6 +52,21 @@ token_expiry = "30d" | |||
| 52 | 52 | | `token_secret` | string | - | Path to JWT signing secret in secrets backend | | |
| 53 | 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 | 70 | ## Secrets Backend | |
| 56 | 71 | ||
| 57 | 72 | 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 Backend | 70 | ## 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" | |||
| 47 | 47 | token_secret = "gordon/auth/token_secret" | |
| 48 | 48 | 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 | 58 | # Deploy behavior | |
| 51 | 59 | [deploy] | |
| 52 | 60 | 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 | 120 | | `[server]` | Core server settings | [Server](./server.md) | | |
| 113 | 121 | | `[auth]` | Authentication and secrets backend | [Auth](./auth.md) | | |
| 122 | + | | `[api.rate_limit]` | Rate limiting configuration | [Rate Limiting](./rate-limiting.md) | | |
| 114 | 123 | | `[deploy]` | Deployment behavior | [Deploy](./deploy.md) | | |
| 115 | 124 | | `[logging]` | Logging configuration | [Logging](./logging.md) | | |
| 116 | 125 | | `[env]` | Environment variable settings | [Environment](./env.md) | | |
| @@ -133,6 +142,11 @@ enabled = false # Auto-create routes from image names | |||
| 133 | 142 | | `auth.type` | `"password"` | | |
| 134 | 143 | | `auth.secrets_backend` | `"unsafe"` | | |
| 135 | 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 | 150 | | `deploy.pull_policy` | `"if-tag-changed"` | | |
| 137 | 151 | | `logging.level` | `"info"` | | |
| 138 | 152 | | `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 never | 48 | 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 behavior | 58 | # Deploy behavior | ||
| 51 | [deploy] | 59 | [deploy] | ||
| 52 | pull_policy = "if-tag-changed" # always, if-not-present, if-tag-changed | 60 | 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 | |||
| 26 | 26 | token_secret = "" # Path in secrets backend to JWT signing key (REQUIRED for token type) | |
| 27 | 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 | 40 | # LOGGING | |
| 31 | 41 | # ============================================================================= | |
| @@ -119,6 +129,11 @@ preserve = true # Keep volumes when containers are | |||
| 119 | 129 | | `auth.type` | `"password"` | Auth type | | |
| 120 | 130 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | |
| 121 | 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 | 137 | | `logging.level` | `"info"` | Log level | | |
| 123 | 138 | | `logging.format` | `"console"` | Log format | | |
| 124 | 139 | | `logging.file.enabled` | `false` | File logging disabled | | |
| @@ -163,6 +178,20 @@ GORDON_LOGGING_FILE_MAX_SIZE=200 | |||
| 163 | 178 | 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 | 195 | ## Pull Policy Options | |
| 167 | 196 | ||
| 168 | 197 | | 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 | # LOGGING | 40 | # 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=false | 178 | 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 Options | 195 | ## 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, | |||
| 74 | 74 | authenticated := usernameMatch && passwordMatch | |
| 75 | 75 | if !authenticated { | |
| 76 | 76 | log.Debug(). | |
| 77 | - | Str("provided_username", username). | |
| 77 | + | Str("provided_username", redactUsername(username)). | |
| 78 | 78 | Str(zerowrap.FieldMethod, r.Method). | |
| 79 | 79 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 80 | 80 | Msg("authentication failed") | |
| 81 | 81 | } else { | |
| 82 | 82 | log.Debug(). | |
| 83 | - | Str("username", username). | |
| 83 | + | Str("username", redactUsername(username)). | |
| 84 | 84 | Str(zerowrap.FieldMethod, r.Method). | |
| 85 | 85 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 86 | 86 | Msg("authentication successful") | |
| @@ -89,6 +89,16 @@ func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string, | |||
| 89 | 89 | 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 | 102 | // InternalRegistryAuth holds the credentials used for loopback-only registry access. | |
| 93 | 103 | // These are generated per Gordon instance and are never exposed in config. | |
| 94 | 104 | type InternalRegistryAuth struct { | |
| @@ -191,7 +201,7 @@ func authenticatePassword(ctx context.Context, r *http.Request, authSvc in.AuthS | |||
| 191 | 201 | } | |
| 192 | 202 | ||
| 193 | 203 | log.Debug(). | |
| 194 | - | Str("provided_username", username). | |
| 204 | + | Str("provided_username", redactUsername(username)). | |
| 195 | 205 | Str(zerowrap.FieldMethod, r.Method). | |
| 196 | 206 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 197 | 207 | Msg("password authentication failed") | |
| @@ -240,7 +250,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||
| 240 | 250 | if err != nil { | |
| 241 | 251 | log.Debug(). | |
| 242 | 252 | Err(err). | |
| 243 | - | Str("provided_username", username). | |
| 253 | + | Str("provided_username", redactUsername(username)). | |
| 244 | 254 | Str(zerowrap.FieldMethod, r.Method). | |
| 245 | 255 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 246 | 256 | Msg("token-as-password validation failed") | |
| @@ -250,8 +260,8 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||
| 250 | 260 | // Verify the username matches the token subject | |
| 251 | 261 | if claims.Subject != username { | |
| 252 | 262 | 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)). | |
| 255 | 265 | Str(zerowrap.FieldMethod, r.Method). | |
| 256 | 266 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 257 | 267 | Msg("username does not match token subject") | |
| @@ -74,13 +74,13 @@ func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string, | |||||
| 74 | authenticated := usernameMatch && passwordMatch | 74 | 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 authenticated | 89 | 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 subject | 260 | // 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 ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | + | "regexp" | |
| 9 | 10 | "strings" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/bnema/zerowrap" | |
| 13 | + | ||
| 14 | + | "gordon/internal/domain" | |
| 12 | 15 | ) | |
| 13 | 16 | ||
| 14 | 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 | 44 | // 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 | + | } | |
| 44 | 50 | ||
| 45 | 51 | file, err := os.Open(envFile) | |
| 46 | 52 | if err != nil { | |
| @@ -73,8 +79,11 @@ func (s *FileStore) ListKeys(domain string) ([]string, error) { | |||
| 73 | 79 | } | |
| 74 | 80 | ||
| 75 | 81 | // 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 | + | } | |
| 78 | 87 | ||
| 79 | 88 | file, err := os.Open(envFile) | |
| 80 | 89 | if err != nil { | |
| @@ -109,14 +118,19 @@ func (s *FileStore) GetAll(domain string) (map[string]string, error) { | |||
| 109 | 118 | } | |
| 110 | 119 | ||
| 111 | 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 | 127 | // Ensure env directory exists | |
| 114 | 128 | if err := os.MkdirAll(s.envDir, 0700); err != nil { | |
| 115 | 129 | return fmt.Errorf("failed to create env directory: %w", err) | |
| 116 | 130 | } | |
| 117 | 131 | ||
| 118 | 132 | // Read existing secrets | |
| 119 | - | existing, err := s.GetAll(domain) | |
| 133 | + | existing, err := s.GetAll(domainName) | |
| 120 | 134 | if err != nil { | |
| 121 | 135 | return err | |
| 122 | 136 | } | |
| @@ -127,13 +141,18 @@ func (s *FileStore) Set(domain string, secrets map[string]string) error { | |||
| 127 | 141 | } | |
| 128 | 142 | ||
| 129 | 143 | // Write back atomically | |
| 130 | - | return s.writeSecretsAtomic(domain, existing) | |
| 144 | + | return s.writeSecretsAtomic(domainName, existing) | |
| 131 | 145 | } | |
| 132 | 146 | ||
| 133 | 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 | 154 | // Read existing secrets | |
| 136 | - | existing, err := s.GetAll(domain) | |
| 155 | + | existing, err := s.GetAll(domainName) | |
| 137 | 156 | if err != nil { | |
| 138 | 157 | return err | |
| 139 | 158 | } | |
| @@ -142,13 +161,16 @@ func (s *FileStore) Delete(domain, key string) error { | |||
| 142 | 161 | delete(existing, key) | |
| 143 | 162 | ||
| 144 | 163 | // Write back atomically | |
| 145 | - | return s.writeSecretsAtomic(domain, existing) | |
| 164 | + | return s.writeSecretsAtomic(domainName, existing) | |
| 146 | 165 | } | |
| 147 | 166 | ||
| 148 | 167 | // writeSecretsAtomic writes all secrets to the domain's env file atomically. | |
| 149 | 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 { | |
| 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 | + | } | |
| 152 | 174 | tmpFile := envFile + ".tmp" | |
| 153 | 175 | ||
| 154 | 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 | 181 | // 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 | 183 | file.Close() | |
| 162 | 184 | os.Remove(tmpFile) | |
| 163 | 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 | 220 | 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 | 227 | // getEnvFilePath converts a domain to its env file path. | |
| 202 | 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 | 252 | // Create domain-safe filename (replace dots and other chars with underscores) | |
| 205 | - | safeDomain := strings.ReplaceAll(domain, ".", "_") | |
| 253 | + | safeDomain := strings.ReplaceAll(domainName, ".", "_") | |
| 206 | 254 | safeDomain = strings.ReplaceAll(safeDomain, ":", "_") | |
| 207 | 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 | } | |
| @@ -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 exists | 127 | // 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 secrets | 132 | // 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 err | 135 | 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 atomically | 143 | // 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 secrets | 154 | // 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 err | 157 | 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 atomically | 163 | // 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 comment | 181 | // 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 nil | 220 | 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 ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "os/exec" | |
| 8 | + | "regexp" | |
| 8 | 9 | "strings" | |
| 9 | 10 | "time" | |
| 10 | 11 | ||
| 11 | 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 | 22 | // PassProvider implements the SecretProvider interface using the pass password manager. | |
| 15 | 23 | type PassProvider struct { | |
| 16 | 24 | timeout time.Duration | |
| @@ -30,8 +38,45 @@ func (p *PassProvider) Name() string { | |||
| 30 | 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 | 66 | // GetSecret retrieves a secret from pass by path. | |
| 34 | 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 | 80 | ctx, cancel := context.WithTimeout(ctx, p.timeout) | |
| 36 | 81 | defer cancel() | |
| 37 | 82 | ||
| @@ -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.Duration | 24 | 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 | |||
| 28 | 28 | if dataDir == "" { | |
| 29 | 29 | return nil, fmt.Errorf("data_dir is required for unsafe backend") | |
| 30 | 30 | } | |
| 31 | - | return NewUnsafeStore(dataDir, log), nil | |
| 31 | + | return NewUnsafeStore(dataDir, log) | |
| 32 | 32 | ||
| 33 | 33 | default: | |
| 34 | 34 | 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), nil | 31 | + | 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 { | |||
| 38 | 38 | ||
| 39 | 39 | // NewUnsafeStore creates a new file-based token store. | |
| 40 | 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 | 45 | store := &UnsafeStore{ | |
| 43 | 46 | dataDir: dataDir, | |
| 44 | 47 | log: log, | |
| @@ -51,7 +54,7 @@ func NewUnsafeStore(dataDir string, log zerowrap.Logger) *UnsafeStore { | |||
| 51 | 54 | Str("data_dir", dataDir). | |
| 52 | 55 | Msg("using unsafe secrets backend - secrets are stored in plain text") | |
| 53 | 56 | ||
| 54 | - | return store | |
| 57 | + | return store, nil | |
| 55 | 58 | } | |
| 56 | 59 | ||
| 57 | 60 | // 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 store | 57 | + | 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 { | |||
| 100 | 100 | TokenSecret string `mapstructure:"token_secret"` // path in secrets backend | |
| 101 | 101 | TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d" | |
| 102 | 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 | 115 | // services holds all the services used by the application. | |
| @@ -467,13 +477,44 @@ type InternalCredentials struct { | |||
| 467 | 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 | 505 | // getInternalCredentialsFile returns the path to the internal credentials file. | |
| 506 | + | // SECURITY: Credentials are stored in a secure location with restricted permissions. | |
| 471 | 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. | |
| 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. | |
| 477 | 518 | // The file is cleaned up on graceful shutdown but may persist if Gordon crashes. | |
| 478 | 519 | // These credentials are for internal loopback communication only and are regenerated on each start. | |
| 479 | 520 | func persistInternalCredentials(username, password string) error { | |
| @@ -485,7 +526,15 @@ func persistInternalCredentials(username, password string) error { | |||
| 485 | 526 | if err != nil { | |
| 486 | 527 | return fmt.Errorf("failed to marshal credentials: %w", err) | |
| 487 | 528 | } | |
| 529 | + | ||
| 488 | 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 | 538 | if err := os.WriteFile(credFile, data, 0600); err != nil { | |
| 490 | 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 | 695 | 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 | 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 | 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 | 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 | 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 | 887 | if cfg.Auth.Enabled && svc.authSvc != nil { | |
| 812 | 888 | internalAuth := middleware.InternalRegistryAuth{ | |
| 813 | 889 | Username: svc.internalRegUser, | |
| @@ -824,11 +900,13 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 824 | 900 | registryMux := http.NewServeMux() | |
| 825 | 901 | if svc.tokenHandler != nil { | |
| 826 | 902 | // 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 | 905 | middleware.PanicRecovery(log), | |
| 829 | 906 | middleware.RequestLogger(log), | |
| 907 | + | rateLimitMiddleware, | |
| 830 | 908 | )(svc.tokenHandler) | |
| 831 | - | registryMux.Handle("/v2/token", tokenWithLogging) | |
| 909 | + | registryMux.Handle("/v2/token", tokenWithMiddleware) | |
| 832 | 910 | } | |
| 833 | 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 | 919 | // Add admin auth middleware if auth is enabled | |
| 842 | 920 | 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 | + | } | |
| 845 | 926 | adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, adminLimiter, log)) | |
| 846 | 927 | } | |
| 847 | 928 | adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler) | |
| @@ -1079,19 +1160,32 @@ func readDeployRequest() (string, error) { | |||
| 1079 | 1160 | } | |
| 1080 | 1161 | ||
| 1081 | 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 | 1165 | func createPidFile(log zerowrap.Logger) string { | |
| 1083 | 1166 | pid := os.Getpid() | |
| 1084 | 1167 | ||
| 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")) | |
| 1088 | 1174 | } | |
| 1089 | 1175 | ||
| 1176 | + | // Fall back to home directory | |
| 1090 | 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 | 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 | 1189 | if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0600); err == nil { | |
| 1096 | 1190 | log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file") | |
| 1097 | 1191 | return location | |
| @@ -1112,16 +1206,26 @@ func removePidFile(pidFile string, log zerowrap.Logger) { | |||
| 1112 | 1206 | } | |
| 1113 | 1207 | ||
| 1114 | 1208 | // findPidFile finds the Gordon PID file. | |
| 1209 | + | // Checks secure locations first, then falls back to legacy /tmp location. | |
| 1115 | 1210 | 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")) | |
| 1119 | 1216 | } | |
| 1120 | 1217 | ||
| 1218 | + | // Check home directory | |
| 1121 | 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 | 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 | 1229 | for _, location := range locations { | |
| 1126 | 1230 | if _, err := os.Stat(location); err == nil { | |
| 1127 | 1231 | return location | |
| @@ -1152,6 +1256,10 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 1152 | 1256 | v.SetDefault("auth.type", "password") | |
| 1153 | 1257 | v.SetDefault("auth.secrets_backend", "unsafe") | |
| 1154 | 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 | 1263 | v.SetDefault("auto_route.enabled", false) | |
| 1156 | 1264 | v.SetDefault("network_isolation.enabled", false) | |
| 1157 | 1265 | v.SetDefault("network_isolation.network_prefix", "gordon") | |
| @@ -100,6 +100,16 @@ type Config struct { | |||||
| 100 | TokenSecret string `mapstructure:"token_secret"` // path in secrets backend | 100 | 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, nil | 695 | 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 tokens | 902 | // 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 enabled | 919 | // 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 20 | 921 | + | // 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 location | 1191 | 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 location | 1231 | 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 ( | |||
| 62 | 62 | ErrInvalidPath = errors.New("invalid path format") | |
| 63 | 63 | ErrInsufficientScope = errors.New("insufficient scope for operation") | |
| 64 | 64 | ErrInvalidScope = errors.New("invalid scope format") | |
| 65 | + | ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed") | |
| 65 | 66 | ||
| 66 | 67 | // Attachment errors | |
| 67 | 68 | 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 errors | 67 | // 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 | |||
| 292 | 292 | "sub": subject, | |
| 293 | 293 | "iss": TokenIssuer, | |
| 294 | 294 | "iat": now.Unix(), | |
| 295 | + | "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection | |
| 295 | 296 | "scopes": scopes, | |
| 296 | 297 | } | |
| 297 | 298 | ||
| @@ -351,6 +352,7 @@ func (s *Service) GenerateAccessToken(ctx context.Context, subject string, scope | |||
| 351 | 352 | "sub": subject, | |
| 352 | 353 | "iss": TokenIssuer, | |
| 353 | 354 | "iat": now.Unix(), | |
| 355 | + | "nbf": now.Unix(), // SECURITY: Not-before claim for clock skew protection | |
| 354 | 356 | "scopes": scopes, | |
| 355 | 357 | "exp": now.Add(expiry).Unix(), | |
| 356 | 358 | } | |
| @@ -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. | |||
| 755 | 755 | assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash1), []byte(password))) | |
| 756 | 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 | + | } | |
| @@ -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{ | |||
| 39 | 39 | type Config struct { | |
| 40 | 40 | RegistryDomain string | |
| 41 | 41 | 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 | 47 | // Service implements the ProxyService interface. | |
| @@ -140,6 +143,16 @@ func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.Pro | |||
| 140 | 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 | 156 | target := &domain.ProxyTarget{ | |
| 144 | 157 | Host: host, | |
| 145 | 158 | Port: port, | |
| @@ -312,7 +325,11 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target * | |||
| 312 | 325 | }, | |
| 313 | 326 | func(resp *http.Response) error { | |
| 314 | 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 | 333 | return nil | |
| 317 | 334 | }, | |
| 318 | 335 | ) | |
| @@ -39,6 +39,9 @@ var proxyTransport = &http.Transport{ | |||||
| 39 | type Config struct { | 39 | type Config struct { | ||
| 40 | RegistryDomain string | 40 | RegistryDomain string | ||
| 41 | RegistryPort int | 41 | 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 nil | 333 | 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) { | |||
| 73 | 73 | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |
| 74 | 74 | ctx := testContext() | |
| 75 | 75 | ||
| 76 | - | // Mock external routes | |
| 76 | + | // Mock external routes - use public IP to pass SSRF check | |
| 77 | 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 | 81 | result, err := svc.GetTarget(ctx, "reg.example.com") | |
| 82 | 82 | ||
| 83 | 83 | assert.NoError(t, err) | |
| 84 | - | assert.Equal(t, "localhost", result.Host) | |
| 84 | + | assert.Equal(t, "203.0.113.10", result.Host) | |
| 85 | 85 | assert.Equal(t, 5000, result.Port) | |
| 86 | 86 | assert.Equal(t, "", result.ContainerID) | |
| 87 | 87 | assert.Equal(t, "http", result.Scheme) | |
| @@ -95,14 +95,14 @@ func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) { | |||
| 95 | 95 | svc := NewService(runtime, containerSvc, configSvc, Config{}) | |
| 96 | 96 | ctx := testContext() | |
| 97 | 97 | ||
| 98 | - | // First call - should resolve external route | |
| 98 | + | // First call - should resolve external route (use public IP) | |
| 99 | 99 | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{ | |
| 100 | - | "reg.example.com": "localhost:5000", | |
| 100 | + | "reg.example.com": "203.0.113.10:5000", | |
| 101 | 101 | }).Once() | |
| 102 | 102 | ||
| 103 | 103 | result1, err := svc.GetTarget(ctx, "reg.example.com") | |
| 104 | 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 | 107 | // Second call - should return from cache (no mock call needed) | |
| 108 | 108 | result2, err := svc.GetTarget(ctx, "reg.example.com") | |
| @@ -110,6 +110,44 @@ func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) { | |||
| 110 | 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 | 151 | func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) { | |
| 114 | 152 | runtime := outmocks.NewMockContainerRuntime(t) | |
| 115 | 153 | 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 routes | 76 | + | // 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 route | 98 | + | // 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" | |||
| 27 | 27 | port = 8080 | |
| 28 | 28 | gordon_domain = "gordon.local" | |
| 29 | 29 | ||
| 30 | - | [registry_auth] | |
| 30 | + | [auth] | |
| 31 | 31 | enabled = false | |
| 32 | 32 | ||
| 33 | 33 | [auto_route] | |
| @@ -44,13 +44,11 @@ enabled = true | |||
| 44 | 44 | port = 8080 | |
| 45 | 45 | gordon_domain = "gordon.company.com" | |
| 46 | 46 | ||
| 47 | - | [secrets] | |
| 48 | - | backend = "pass" | |
| 49 | - | ||
| 50 | - | [registry_auth] | |
| 47 | + | [auth] | |
| 51 | 48 | enabled = true | |
| 52 | 49 | type = "token" | |
| 53 | - | token_secret = "gordon/registry/token_secret" | |
| 50 | + | secrets_backend = "pass" | |
| 51 | + | token_secret = "gordon/auth/token_secret" | |
| 54 | 52 | ||
| 55 | 53 | [network_isolation] | |
| 56 | 54 | enabled = true | |
| @@ -27,7 +27,7 @@ gordon_domain = "gordon.local" | |||||
| 27 | port = 8080 | 27 | port = 8080 | ||
| 28 | gordon_domain = "gordon.local" | 28 | gordon_domain = "gordon.local" | ||
| 29 | 29 | ||||
| 30 | - | [registry_auth] | 30 | + | [auth] |
| 31 | enabled = false | 31 | enabled = false | ||
| 32 | 32 | ||||
| 33 | [auto_route] | 33 | [auto_route] | ||
| @@ -44,13 +44,11 @@ enabled = true | |||||
| 44 | port = 8080 | 44 | 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 = true | 48 | 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 = true | 54 | enabled = true | ||
M wiki/examples/minimal.md +1 -1
| @@ -19,7 +19,7 @@ registry_port = 5000 | |||
| 19 | 19 | gordon_domain = "gordon.local" | |
| 20 | 20 | ||
| 21 | 21 | # Authentication disabled for simplicity | |
| 22 | - | [registry_auth] | |
| 22 | + | [auth] | |
| 23 | 23 | enabled = false | |
| 24 | 24 | ||
| 25 | 25 | # 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 simplicity | 21 | # Authentication disabled for simplicity | ||
| 22 | - | [registry_auth] | 22 | + | [auth] |
| 23 | enabled = false | 23 | enabled = false | ||
| 24 | 24 | ||||
| 25 | # Simple route | 25 | # 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. | |||
| 7 | 7 | - [Running Gordon in a Container](./running-in-container.md) - Deploy Gordon itself in Docker or Podman | |
| 8 | 8 | - [Podman Rootless Setup](./podman-rootless.md) - Enhanced security with rootless containers | |
| 9 | 9 | ||
| 10 | - | ## External Services | |
| 10 | + | ## TLS & Load Balancing | |
| 11 | 11 | ||
| 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 | |
| 13 | 15 | ||
| 14 | 16 | ## Remote Management | |
| 15 | 17 | ||
| @@ -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 Podman | 7 | - [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 containers | 8 | - [Podman Rootless Setup](./podman-rootless.md) - Enhanced security with rootless containers | ||
| 9 | 9 | ||||
| 10 | - | ## External Services | 10 | + | ## TLS & Load Balancing |
| 11 | 11 | ||||
| 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 | |||
| 13 | 15 | ||||
| 14 | ## Remote Management | 16 | ## 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 | |||
| 240 | 240 | pass init <your-gpg-key-id> | |
| 241 | 241 | ||
| 242 | 242 | # 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 | |
| 245 | 245 | ``` | |
| 246 | 246 | ||
| 247 | 247 | ### Custom Dockerfile | |
| @@ -307,15 +307,13 @@ volumes: | |||
| 307 | 307 | ### Configuration | |
| 308 | 308 | ||
| 309 | 309 | ```toml | |
| 310 | - | [secrets] | |
| 311 | - | backend = "pass" | |
| 312 | - | ||
| 313 | - | [registry_auth] | |
| 310 | + | [auth] | |
| 314 | 311 | enabled = true | |
| 315 | 312 | type = "token" | |
| 313 | + | secrets_backend = "pass" | |
| 316 | 314 | 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" | |
| 319 | 317 | ``` | |
| 320 | 318 | ||
| 321 | 319 | ### 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 secrets | 242 | # Store Gordon secrets | ||
| 243 | - | pass insert gordon/registry/password_hash | 243 | + | pass insert gordon/auth/password_hash |
| 244 | - | pass insert gordon/registry/token_secret | 244 | + | pass insert gordon/auth/token_secret |
| 245 | ``` | 245 | ``` | ||
| 246 | 246 | ||||
| 247 | ### Custom Dockerfile | 247 | ### Custom Dockerfile | ||
| @@ -307,15 +307,13 @@ volumes: | |||||
| 307 | ### Configuration | 307 | ### Configuration | ||
| 308 | 308 | ||||
| 309 | ```toml | 309 | ```toml | ||
| 310 | - | [secrets] | 310 | + | [auth] |
| 311 | - | backend = "pass" | |||
| 312 | - | ||||
| 313 | - | [registry_auth] | |||
| 314 | enabled = true | 311 | 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) | ||