fix(security): enforce always-on auth and block scope/IP escalation
- commit
- 18fc433c7f7ccbb51ff06f9e1589fa83dfe73009
- author
- bnema · · 2026-02-09 04:30 UTC
- parent
- 21424aa
- signature
- unsigned
9 files · +287 -72
M docs/config/auth.md +3 -10
| @@ -10,7 +10,6 @@ Authentication is enabled by default. Gordon will not start without a valid `tok | |||
| 10 | 10 | ||
| 11 | 11 | ```toml | |
| 12 | 12 | [auth] | |
| 13 | - | enabled = true | |
| 14 | 13 | secrets_backend = "pass" | |
| 15 | 14 | token_secret = "gordon/auth/token_secret" | |
| 16 | 15 | token_expiry = "30d" | |
| @@ -20,7 +19,6 @@ token_expiry = "30d" | |||
| 20 | 19 | ||
| 21 | 20 | ```toml | |
| 22 | 21 | [auth] | |
| 23 | - | enabled = true | |
| 24 | 22 | secrets_backend = "pass" | |
| 25 | 23 | username = "deploy" | |
| 26 | 24 | password_hash = "gordon/auth/password_hash" | |
| @@ -33,7 +31,7 @@ Token auth always works. Adding `username` + `password_hash` enables interactive | |||
| 33 | 31 | ||
| 34 | 32 | | Option | Type | Default | Description | | |
| 35 | 33 | |--------|------|---------|-------------| | |
| 36 | - | | `enabled` | bool | `true` | Enable authentication | | |
| 34 | + | | `enabled` | bool | `true` | Deprecated. Authentication is always required; setting `false` is rejected | | |
| 37 | 35 | | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` | | |
| 38 | 36 | | `token_secret` | string | - | **Required.** Path to JWT signing secret in secrets backend | | |
| 39 | 37 | | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) | | |
| @@ -154,14 +152,9 @@ gordon auth token generate --subject temp --expiry 30d | |||
| 154 | 152 | - Tokens from one Gordon instance won't work on another | |
| 155 | 153 | - Changing `token_secret` invalidates all existing tokens | |
| 156 | 154 | ||
| 157 | - | ## Disable Auth (development only) | |
| 155 | + | ## Authentication Required | |
| 158 | 156 | ||
| 159 | - | ```toml | |
| 160 | - | [auth] | |
| 161 | - | enabled = false | |
| 162 | - | ``` | |
| 163 | - | ||
| 164 | - | > **Security Warning:** When authentication is disabled, the admin API is fully accessible without credentials. This includes endpoints to deploy containers, manage secrets, modify routes, and reload configuration. Only disable auth for local development where Gordon is not exposed to the internet. | |
| 157 | + | Authentication is always required. `auth.enabled=false` is no longer supported. | |
| 165 | 158 | ||
| 166 | 159 | ## Internal Registry Auth | |
| 167 | 160 | ||
| @@ -10,7 +10,6 @@ Authentication is enabled by default. Gordon will not start without a valid `tok | |||||
| 10 | 10 | ||||
| 11 | ```toml | 11 | ```toml | ||
| 12 | [auth] | 12 | [auth] | ||
| 13 | - | enabled = true | |||
| 14 | secrets_backend = "pass" | 13 | secrets_backend = "pass" | ||
| 15 | token_secret = "gordon/auth/token_secret" | 14 | token_secret = "gordon/auth/token_secret" | ||
| 16 | token_expiry = "30d" | 15 | token_expiry = "30d" | ||
| @@ -20,7 +19,6 @@ token_expiry = "30d" | |||||
| 20 | 19 | ||||
| 21 | ```toml | 20 | ```toml | ||
| 22 | [auth] | 21 | [auth] | ||
| 23 | - | enabled = true | |||
| 24 | secrets_backend = "pass" | 22 | secrets_backend = "pass" | ||
| 25 | username = "deploy" | 23 | username = "deploy" | ||
| 26 | password_hash = "gordon/auth/password_hash" | 24 | password_hash = "gordon/auth/password_hash" | ||
| @@ -33,7 +31,7 @@ Token auth always works. Adding `username` + `password_hash` enables interactive | |||||
| 33 | 31 | ||||
| 34 | | Option | Type | Default | Description | | 32 | | Option | Type | Default | Description | | ||
| 35 | |--------|------|---------|-------------| | 33 | |--------|------|---------|-------------| | ||
| 36 | - | | `enabled` | bool | `true` | Enable authentication | | 34 | + | | `enabled` | bool | `true` | Deprecated. Authentication is always required; setting `false` is rejected | |
| 37 | | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` | | 35 | | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` | | ||
| 38 | | `token_secret` | string | - | **Required.** Path to JWT signing secret in secrets backend | | 36 | | `token_secret` | string | - | **Required.** Path to JWT signing secret in secrets backend | | ||
| 39 | | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) | | 37 | | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) | | ||
| @@ -154,14 +152,9 @@ gordon auth token generate --subject temp --expiry 30d | |||||
| 154 | - Tokens from one Gordon instance won't work on another | 152 | - Tokens from one Gordon instance won't work on another | ||
| 155 | - Changing `token_secret` invalidates all existing tokens | 153 | - Changing `token_secret` invalidates all existing tokens | ||
| 156 | 154 | ||||
| 157 | - | ## Disable Auth (development only) | 155 | + | ## Authentication Required |
| 158 | 156 | ||||
| 159 | - | ```toml | 157 | + | Authentication is always required. `auth.enabled=false` is no longer supported. |
| 160 | - | [auth] | |||
| 161 | - | enabled = false | |||
| 162 | - | ``` | |||
| 163 | - | ||||
| 164 | - | > **Security Warning:** When authentication is disabled, the admin API is fully accessible without credentials. This includes endpoints to deploy containers, manage secrets, modify routes, and reload configuration. Only disable auth for local development where Gordon is not exposed to the internet. | |||
| 165 | 158 | ||||
| 166 | ## Internal Registry Auth | 159 | ## Internal Registry Auth | ||
| 167 | 160 | ||||
M docs/config/server.md +4 -0
| @@ -103,6 +103,10 @@ This domain is used for: | |||
| 103 | 103 | ||
| 104 | 104 | When requests arrive on the proxy port with this domain as the Host header, Gordon routes them to the backend services (registry and admin API). | |
| 105 | 105 | ||
| 106 | + | Security note: | |
| 107 | + | - Direct access to `/admin/*` on `registry_port` is blocked for non-loopback clients. | |
| 108 | + | - Admin API traffic should go through the main proxy listener (`server.port`/`server.tls_port`). | |
| 109 | + | ||
| 106 | 110 | > **Note:** `registry_domain` is supported as a deprecated alias for backwards compatibility. | |
| 107 | 111 | ||
| 108 | 112 | ## Data Directory | |
| @@ -103,6 +103,10 @@ This domain is used for: | |||||
| 103 | 103 | ||||
| 104 | When requests arrive on the proxy port with this domain as the Host header, Gordon routes them to the backend services (registry and admin API). | 104 | When requests arrive on the proxy port with this domain as the Host header, Gordon routes them to the backend services (registry and admin API). | ||
| 105 | 105 | ||||
| 106 | + | Security note: | |||
| 107 | + | - Direct access to `/admin/*` on `registry_port` is blocked for non-loopback clients. | |||
| 108 | + | - Admin API traffic should go through the main proxy listener (`server.port`/`server.tls_port`). | |||
| 109 | + | ||||
| 106 | > **Note:** `registry_domain` is supported as a deprecated alias for backwards compatibility. | 110 | > **Note:** `registry_domain` is supported as a deprecated alias for backwards compatibility. | ||
| 107 | 111 | ||||
| 108 | ## Data Directory | 112 | ## Data Directory | ||
M internal/adapters/in/http/admin/middleware.go +5 -9
| @@ -51,19 +51,15 @@ func AuthMiddleware( | |||
| 51 | 51 | } | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | - | // Check if auth is enabled. | |
| 55 | - | // DESIGN NOTE: When auth is disabled, the admin API is intentionally | |
| 56 | - | // accessible without credentials. This is the expected behavior for | |
| 57 | - | // local/development setups where Gordon is not exposed to the internet. | |
| 58 | - | // Users who disable auth accept this tradeoff explicitly in their config. | |
| 59 | - | // For production deployments, auth should always be enabled. | |
| 54 | + | // Authentication is always required for admin APIs. | |
| 55 | + | // Fail closed if auth is disabled or misconfigured. | |
| 60 | 56 | if !authSvc.IsEnabled() { | |
| 61 | - | log.Warn(). | |
| 57 | + | log.Error(). | |
| 62 | 58 | Str("path", r.URL.Path). | |
| 63 | 59 | Str("method", r.Method). | |
| 64 | 60 | Str("remote_addr", r.RemoteAddr). | |
| 65 | - | Msg("auth disabled - allowing unauthenticated admin API access (intended for local use only)") | |
| 66 | - | next.ServeHTTP(w, r) | |
| 61 | + | Msg("authentication disabled/misconfigured - denying admin API request") | |
| 62 | + | sendUnauthorized(w, "authentication is required") | |
| 67 | 63 | return | |
| 68 | 64 | } | |
| 69 | 65 | ||
| @@ -51,19 +51,15 @@ func AuthMiddleware( | |||||
| 51 | } | 51 | } | ||
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | - | // Check if auth is enabled. | 54 | + | // Authentication is always required for admin APIs. |
| 55 | - | // DESIGN NOTE: When auth is disabled, the admin API is intentionally | 55 | + | // Fail closed if auth is disabled or misconfigured. |
| 56 | - | // accessible without credentials. This is the expected behavior for | |||
| 57 | - | // local/development setups where Gordon is not exposed to the internet. | |||
| 58 | - | // Users who disable auth accept this tradeoff explicitly in their config. | |||
| 59 | - | // For production deployments, auth should always be enabled. | |||
| 60 | if !authSvc.IsEnabled() { | 56 | if !authSvc.IsEnabled() { | ||
| 61 | - | log.Warn(). | 57 | + | log.Error(). |
| 62 | Str("path", r.URL.Path). | 58 | Str("path", r.URL.Path). | ||
| 63 | Str("method", r.Method). | 59 | Str("method", r.Method). | ||
| 64 | Str("remote_addr", r.RemoteAddr). | 60 | Str("remote_addr", r.RemoteAddr). | ||
| 65 | - | Msg("auth disabled - allowing unauthenticated admin API access (intended for local use only)") | 61 | + | Msg("authentication disabled/misconfigured - denying admin API request") |
| 66 | - | next.ServeHTTP(w, r) | 62 | + | sendUnauthorized(w, "authentication is required") |
| 67 | return | 63 | return | ||
| 68 | } | 64 | } | ||
| 69 | 65 | ||||
M internal/adapters/in/http/admin/middleware_test.go +15 -5
| @@ -78,7 +78,11 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) { | |||
| 78 | 78 | ||
| 79 | 79 | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | |
| 80 | 80 | ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true) | |
| 81 | - | authSvc.EXPECT().IsEnabled().Return(false) | |
| 81 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 82 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | |
| 83 | + | Subject: "admin", | |
| 84 | + | Scopes: []string{"admin:*:*"}, | |
| 85 | + | }, nil) | |
| 82 | 86 | ||
| 83 | 87 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 84 | 88 | w.WriteHeader(http.StatusOK) | |
| @@ -90,6 +94,7 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) { | |||
| 90 | 94 | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 91 | 95 | req.RemoteAddr = "127.0.0.1:12345" | |
| 92 | 96 | req.Header.Set("X-Forwarded-For", "203.0.113.50") | |
| 97 | + | req.Header.Set("Authorization", "Bearer valid-admin-token") | |
| 93 | 98 | rec := httptest.NewRecorder() | |
| 94 | 99 | wrappedHandler.ServeHTTP(rec, req) | |
| 95 | 100 | ||
| @@ -99,8 +104,12 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) { | |||
| 99 | 104 | func TestAuthMiddleware_NoRateLimiting(t *testing.T) { | |
| 100 | 105 | authSvc := inmocks.NewMockAuthService(t) | |
| 101 | 106 | ||
| 102 | - | // Auth disabled, no rate limiters | |
| 103 | - | authSvc.EXPECT().IsEnabled().Return(false) | |
| 107 | + | // No rate limiters, auth still required | |
| 108 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 109 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | |
| 110 | + | Subject: "admin", | |
| 111 | + | Scopes: []string{"admin:*:*"}, | |
| 112 | + | }, nil) | |
| 104 | 113 | ||
| 105 | 114 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 106 | 115 | w.WriteHeader(http.StatusOK) | |
| @@ -110,6 +119,7 @@ func TestAuthMiddleware_NoRateLimiting(t *testing.T) { | |||
| 110 | 119 | wrappedHandler := mw(handler) | |
| 111 | 120 | ||
| 112 | 121 | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 122 | + | req.Header.Set("Authorization", "Bearer valid-admin-token") | |
| 113 | 123 | rec := httptest.NewRecorder() | |
| 114 | 124 | wrappedHandler.ServeHTTP(rec, req) | |
| 115 | 125 | ||
| @@ -139,8 +149,8 @@ func TestAuthMiddleware_AuthDisabled(t *testing.T) { | |||
| 139 | 149 | rec := httptest.NewRecorder() | |
| 140 | 150 | wrappedHandler.ServeHTTP(rec, req) | |
| 141 | 151 | ||
| 142 | - | assert.Equal(t, http.StatusOK, rec.Code) | |
| 143 | - | assert.True(t, handlerCalled) | |
| 152 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 153 | + | assert.False(t, handlerCalled) | |
| 144 | 154 | } | |
| 145 | 155 | ||
| 146 | 156 | func TestAuthMiddleware_MissingAuthHeader(t *testing.T) { | |
| @@ -78,7 +78,11 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) { | |||||
| 78 | 78 | ||||
| 79 | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | 79 | globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true) | ||
| 80 | ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true) | 80 | ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true) | ||
| 81 | - | authSvc.EXPECT().IsEnabled().Return(false) | 81 | + | authSvc.EXPECT().IsEnabled().Return(true) |
| 82 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | |||
| 83 | + | Subject: "admin", | |||
| 84 | + | Scopes: []string{"admin:*:*"}, | |||
| 85 | + | }, nil) | |||
| 82 | 86 | ||||
| 83 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | 87 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | ||
| 84 | w.WriteHeader(http.StatusOK) | 88 | w.WriteHeader(http.StatusOK) | ||
| @@ -90,6 +94,7 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) { | |||||
| 90 | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | 94 | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | ||
| 91 | req.RemoteAddr = "127.0.0.1:12345" | 95 | req.RemoteAddr = "127.0.0.1:12345" | ||
| 92 | req.Header.Set("X-Forwarded-For", "203.0.113.50") | 96 | req.Header.Set("X-Forwarded-For", "203.0.113.50") | ||
| 97 | + | req.Header.Set("Authorization", "Bearer valid-admin-token") | |||
| 93 | rec := httptest.NewRecorder() | 98 | rec := httptest.NewRecorder() | ||
| 94 | wrappedHandler.ServeHTTP(rec, req) | 99 | wrappedHandler.ServeHTTP(rec, req) | ||
| 95 | 100 | ||||
| @@ -99,8 +104,12 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) { | |||||
| 99 | func TestAuthMiddleware_NoRateLimiting(t *testing.T) { | 104 | func TestAuthMiddleware_NoRateLimiting(t *testing.T) { | ||
| 100 | authSvc := inmocks.NewMockAuthService(t) | 105 | authSvc := inmocks.NewMockAuthService(t) | ||
| 101 | 106 | ||||
| 102 | - | // Auth disabled, no rate limiters | 107 | + | // No rate limiters, auth still required |
| 103 | - | authSvc.EXPECT().IsEnabled().Return(false) | 108 | + | authSvc.EXPECT().IsEnabled().Return(true) |
| 109 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | |||
| 110 | + | Subject: "admin", | |||
| 111 | + | Scopes: []string{"admin:*:*"}, | |||
| 112 | + | }, nil) | |||
| 104 | 113 | ||||
| 105 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | 114 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | ||
| 106 | w.WriteHeader(http.StatusOK) | 115 | w.WriteHeader(http.StatusOK) | ||
| @@ -110,6 +119,7 @@ func TestAuthMiddleware_NoRateLimiting(t *testing.T) { | |||||
| 110 | wrappedHandler := mw(handler) | 119 | wrappedHandler := mw(handler) | ||
| 111 | 120 | ||||
| 112 | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | 121 | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | ||
| 122 | + | req.Header.Set("Authorization", "Bearer valid-admin-token") | |||
| 113 | rec := httptest.NewRecorder() | 123 | rec := httptest.NewRecorder() | ||
| 114 | wrappedHandler.ServeHTTP(rec, req) | 124 | wrappedHandler.ServeHTTP(rec, req) | ||
| 115 | 125 | ||||
| @@ -139,8 +149,8 @@ func TestAuthMiddleware_AuthDisabled(t *testing.T) { | |||||
| 139 | rec := httptest.NewRecorder() | 149 | rec := httptest.NewRecorder() | ||
| 140 | wrappedHandler.ServeHTTP(rec, req) | 150 | wrappedHandler.ServeHTTP(rec, req) | ||
| 141 | 151 | ||||
| 142 | - | assert.Equal(t, http.StatusOK, rec.Code) | 152 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) |
| 143 | - | assert.True(t, handlerCalled) | 153 | + | assert.False(t, handlerCalled) |
| 144 | } | 154 | } | ||
| 145 | 155 | ||||
| 146 | func TestAuthMiddleware_MissingAuthHeader(t *testing.T) { | 156 | func TestAuthMiddleware_MissingAuthHeader(t *testing.T) { | ||
M internal/adapters/in/http/auth/handler.go +101 -10
| @@ -50,6 +50,13 @@ type PasswordResponse struct { | |||
| 50 | 50 | IssuedAt string `json:"issued_at"` | |
| 51 | 51 | } | |
| 52 | 52 | ||
| 53 | + | const passwordSessionTTL = 24 * time.Hour | |
| 54 | + | ||
| 55 | + | func setNoStoreHeaders(w http.ResponseWriter) { | |
| 56 | + | w.Header().Set("Cache-Control", "no-store") | |
| 57 | + | w.Header().Set("Pragma", "no-cache") | |
| 58 | + | } | |
| 59 | + | ||
| 53 | 60 | // ServeHTTP routes requests to the appropriate handler. | |
| 54 | 61 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 55 | 62 | path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/auth"), "/") | |
| @@ -67,9 +74,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 67 | 74 | } | |
| 68 | 75 | ||
| 69 | 76 | // handlePassword handles POST /auth/password requests. | |
| 70 | - | // Validates username/password and returns a long-lived JWT (7 days). | |
| 77 | + | // Validates username/password and returns a daily session JWT. | |
| 71 | 78 | // Only works when auth type is "password" - returns error for "token" auth type. | |
| 72 | 79 | func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | |
| 80 | + | setNoStoreHeaders(w) | |
| 81 | + | ||
| 73 | 82 | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | |
| 74 | 83 | zerowrap.FieldLayer: "adapter", | |
| 75 | 84 | zerowrap.FieldAdapter: "http", | |
| @@ -89,8 +98,8 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | |||
| 89 | 98 | // Check if auth is enabled | |
| 90 | 99 | if h.authSvc == nil || !h.authSvc.IsEnabled() { | |
| 91 | 100 | w.Header().Set("Content-Type", "application/json") | |
| 92 | - | w.WriteHeader(http.StatusBadRequest) | |
| 93 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is disabled"}) | |
| 101 | + | w.WriteHeader(http.StatusServiceUnavailable) | |
| 102 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"}) | |
| 94 | 103 | return | |
| 95 | 104 | } | |
| 96 | 105 | ||
| @@ -132,8 +141,9 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | |||
| 132 | 141 | return | |
| 133 | 142 | } | |
| 134 | 143 | ||
| 135 | - | // Generate 7-day token with full access (push, pull, admin) | |
| 136 | - | expiry := 7 * 24 * time.Hour | |
| 144 | + | // Generate daily session token with full access (push, pull, admin). | |
| 145 | + | // This enforces regular re-authentication similar to short-session UX. | |
| 146 | + | expiry := passwordSessionTTL | |
| 137 | 147 | scopes := []string{"push", "pull", "admin:*:*"} | |
| 138 | 148 | ||
| 139 | 149 | token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry) | |
| @@ -168,6 +178,8 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | |||
| 168 | 178 | // This is the Docker Registry v2 token server endpoint. | |
| 169 | 179 | // Issues short-lived access tokens for registry access. | |
| 170 | 180 | func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |
| 181 | + | setNoStoreHeaders(w) | |
| 182 | + | ||
| 171 | 183 | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | |
| 172 | 184 | zerowrap.FieldLayer: "adapter", | |
| 173 | 185 | zerowrap.FieldAdapter: "http", | |
| @@ -186,21 +198,27 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 186 | 198 | ||
| 187 | 199 | // Check if auth is enabled | |
| 188 | 200 | if h.authSvc == nil || !h.authSvc.IsEnabled() { | |
| 189 | - | // If auth is disabled, return an anonymous token | |
| 190 | - | h.sendAnonymousToken(w, log) | |
| 201 | + | w.Header().Set("Content-Type", "application/json") | |
| 202 | + | w.WriteHeader(http.StatusServiceUnavailable) | |
| 203 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"}) | |
| 191 | 204 | return | |
| 192 | 205 | } | |
| 193 | 206 | ||
| 194 | 207 | // Get credentials from Basic Auth | |
| 195 | 208 | username, password, ok := r.BasicAuth() | |
| 196 | 209 | if !ok { | |
| 197 | - | // No credentials provided - return anonymous token with limited scope | |
| 198 | - | h.sendAnonymousToken(w, log) | |
| 210 | + | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`) | |
| 211 | + | w.Header().Set("Content-Type", "application/json") | |
| 212 | + | w.WriteHeader(http.StatusUnauthorized) | |
| 213 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "unauthorized"}) | |
| 199 | 214 | return | |
| 200 | 215 | } | |
| 201 | 216 | ||
| 202 | 217 | // Check for internal registry auth (localhost requests with internal creds) | |
| 203 | - | var authenticated bool | |
| 218 | + | var ( | |
| 219 | + | authenticated bool | |
| 220 | + | parentClaims *domain.TokenClaims | |
| 221 | + | ) | |
| 204 | 222 | if isLocalhostRequest(r) && h.isInternalAuth(username, password) { | |
| 205 | 223 | authenticated = true | |
| 206 | 224 | log.Debug().Str("username", username).Msg("internal registry auth accepted") | |
| @@ -214,6 +232,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 214 | 232 | claims, err := h.authSvc.ValidateToken(ctx, password) | |
| 215 | 233 | if err == nil && claims.Subject == username { | |
| 216 | 234 | authenticated = true | |
| 235 | + | parentClaims = claims | |
| 217 | 236 | } | |
| 218 | 237 | } | |
| 219 | 238 | } | |
| @@ -231,6 +250,15 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 231 | 250 | ||
| 232 | 251 | // Parse requested scopes from query parameter | |
| 233 | 252 | requestedScopes := h.parseRequestedScopes(r, log) | |
| 253 | + | if parentClaims != nil { | |
| 254 | + | requestedScopes = h.intersectRequestedScopes(requestedScopes, parentClaims.Scopes, log) | |
| 255 | + | if len(requestedScopes) == 0 { | |
| 256 | + | w.Header().Set("Content-Type", "application/json") | |
| 257 | + | w.WriteHeader(http.StatusForbidden) | |
| 258 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "forbidden: insufficient scope"}) | |
| 259 | + | return | |
| 260 | + | } | |
| 261 | + | } | |
| 234 | 262 | ||
| 235 | 263 | // Generate a short-lived access token (5 minutes) - not stored | |
| 236 | 264 | accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute) | |
| @@ -261,6 +289,69 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 261 | 289 | Msg("access token issued") | |
| 262 | 290 | } | |
| 263 | 291 | ||
| 292 | + | func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []string, log zerowrap.Logger) []string { | |
| 293 | + | var effective []string | |
| 294 | + | ||
| 295 | + | for _, reqScopeStr := range requestedScopes { | |
| 296 | + | reqScope, err := domain.ParseScope(reqScopeStr) | |
| 297 | + | if err != nil || reqScope.Type != domain.ScopeTypeRepository { | |
| 298 | + | continue | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | allowedActions := make([]string, 0, len(reqScope.Actions)) | |
| 302 | + | for _, action := range reqScope.Actions { | |
| 303 | + | if hasGrantedRegistryAccess(grantedScopes, reqScope.Name, action) { | |
| 304 | + | allowedActions = append(allowedActions, action) | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | if len(allowedActions) == 0 { | |
| 309 | + | continue | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | effective = append(effective, (&domain.Scope{ | |
| 313 | + | Type: reqScope.Type, | |
| 314 | + | Name: reqScope.Name, | |
| 315 | + | Actions: allowedActions, | |
| 316 | + | }).String()) | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | log.Debug(). | |
| 320 | + | Strs("requested_scopes", requestedScopes). | |
| 321 | + | Strs("granted_scopes", grantedScopes). | |
| 322 | + | Strs("effective_scopes", effective). | |
| 323 | + | Msg("calculated effective scopes from parent token") | |
| 324 | + | ||
| 325 | + | return effective | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) bool { | |
| 329 | + | for _, grantedScopeStr := range grantedScopes { | |
| 330 | + | scopeStr := strings.TrimSpace(grantedScopeStr) | |
| 331 | + | switch scopeStr { | |
| 332 | + | case domain.ScopeActionAll: | |
| 333 | + | return true | |
| 334 | + | case domain.ScopeActionPull, domain.ScopeActionPush: | |
| 335 | + | if scopeStr == action { | |
| 336 | + | return true | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | scope, err := domain.ParseScope(scopeStr) | |
| 341 | + | if err != nil { | |
| 342 | + | continue | |
| 343 | + | } | |
| 344 | + | if scope.Type != domain.ScopeTypeRepository { | |
| 345 | + | continue | |
| 346 | + | } | |
| 347 | + | if scope.CanAccess(repoName, action) { | |
| 348 | + | return true | |
| 349 | + | } | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | return false | |
| 353 | + | } | |
| 354 | + | ||
| 264 | 355 | // sendAnonymousToken sends a token for anonymous/unauthenticated access. | |
| 265 | 356 | func (h *Handler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) { | |
| 266 | 357 | // For anonymous access, we issue a very short-lived token with limited scope | |
| @@ -50,6 +50,13 @@ type PasswordResponse struct { | |||||
| 50 | IssuedAt string `json:"issued_at"` | 50 | IssuedAt string `json:"issued_at"` | ||
| 51 | } | 51 | } | ||
| 52 | 52 | ||||
| 53 | + | const passwordSessionTTL = 24 * time.Hour | |||
| 54 | + | ||||
| 55 | + | func setNoStoreHeaders(w http.ResponseWriter) { | |||
| 56 | + | w.Header().Set("Cache-Control", "no-store") | |||
| 57 | + | w.Header().Set("Pragma", "no-cache") | |||
| 58 | + | } | |||
| 59 | + | ||||
| 53 | // ServeHTTP routes requests to the appropriate handler. | 60 | // ServeHTTP routes requests to the appropriate handler. | ||
| 54 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | 61 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
| 55 | path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/auth"), "/") | 62 | path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/auth"), "/") | ||
| @@ -67,9 +74,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 67 | } | 74 | } | ||
| 68 | 75 | ||||
| 69 | // handlePassword handles POST /auth/password requests. | 76 | // handlePassword handles POST /auth/password requests. | ||
| 70 | - | // Validates username/password and returns a long-lived JWT (7 days). | 77 | + | // Validates username/password and returns a daily session JWT. |
| 71 | // Only works when auth type is "password" - returns error for "token" auth type. | 78 | // Only works when auth type is "password" - returns error for "token" auth type. | ||
| 72 | func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | 79 | func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | ||
| 80 | + | setNoStoreHeaders(w) | |||
| 81 | + | ||||
| 73 | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | 82 | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | ||
| 74 | zerowrap.FieldLayer: "adapter", | 83 | zerowrap.FieldLayer: "adapter", | ||
| 75 | zerowrap.FieldAdapter: "http", | 84 | zerowrap.FieldAdapter: "http", | ||
| @@ -89,8 +98,8 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | |||||
| 89 | // Check if auth is enabled | 98 | // Check if auth is enabled | ||
| 90 | if h.authSvc == nil || !h.authSvc.IsEnabled() { | 99 | if h.authSvc == nil || !h.authSvc.IsEnabled() { | ||
| 91 | w.Header().Set("Content-Type", "application/json") | 100 | w.Header().Set("Content-Type", "application/json") | ||
| 92 | - | w.WriteHeader(http.StatusBadRequest) | 101 | + | w.WriteHeader(http.StatusServiceUnavailable) |
| 93 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is disabled"}) | 102 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"}) |
| 94 | return | 103 | return | ||
| 95 | } | 104 | } | ||
| 96 | 105 | ||||
| @@ -132,8 +141,9 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | |||||
| 132 | return | 141 | return | ||
| 133 | } | 142 | } | ||
| 134 | 143 | ||||
| 135 | - | // Generate 7-day token with full access (push, pull, admin) | 144 | + | // Generate daily session token with full access (push, pull, admin). |
| 136 | - | expiry := 7 * 24 * time.Hour | 145 | + | // This enforces regular re-authentication similar to short-session UX. |
| 146 | + | expiry := passwordSessionTTL | |||
| 137 | scopes := []string{"push", "pull", "admin:*:*"} | 147 | scopes := []string{"push", "pull", "admin:*:*"} | ||
| 138 | 148 | ||||
| 139 | token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry) | 149 | token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry) | ||
| @@ -168,6 +178,8 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | |||||
| 168 | // This is the Docker Registry v2 token server endpoint. | 178 | // This is the Docker Registry v2 token server endpoint. | ||
| 169 | // Issues short-lived access tokens for registry access. | 179 | // Issues short-lived access tokens for registry access. | ||
| 170 | func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | 180 | func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | ||
| 181 | + | setNoStoreHeaders(w) | |||
| 182 | + | ||||
| 171 | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | 183 | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | ||
| 172 | zerowrap.FieldLayer: "adapter", | 184 | zerowrap.FieldLayer: "adapter", | ||
| 173 | zerowrap.FieldAdapter: "http", | 185 | zerowrap.FieldAdapter: "http", | ||
| @@ -186,21 +198,27 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||||
| 186 | 198 | ||||
| 187 | // Check if auth is enabled | 199 | // Check if auth is enabled | ||
| 188 | if h.authSvc == nil || !h.authSvc.IsEnabled() { | 200 | if h.authSvc == nil || !h.authSvc.IsEnabled() { | ||
| 189 | - | // If auth is disabled, return an anonymous token | 201 | + | w.Header().Set("Content-Type", "application/json") |
| 190 | - | h.sendAnonymousToken(w, log) | 202 | + | w.WriteHeader(http.StatusServiceUnavailable) |
| 203 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"}) | |||
| 191 | return | 204 | return | ||
| 192 | } | 205 | } | ||
| 193 | 206 | ||||
| 194 | // Get credentials from Basic Auth | 207 | // Get credentials from Basic Auth | ||
| 195 | username, password, ok := r.BasicAuth() | 208 | username, password, ok := r.BasicAuth() | ||
| 196 | if !ok { | 209 | if !ok { | ||
| 197 | - | // No credentials provided - return anonymous token with limited scope | 210 | + | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`) |
| 198 | - | h.sendAnonymousToken(w, log) | 211 | + | w.Header().Set("Content-Type", "application/json") |
| 212 | + | w.WriteHeader(http.StatusUnauthorized) | |||
| 213 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "unauthorized"}) | |||
| 199 | return | 214 | return | ||
| 200 | } | 215 | } | ||
| 201 | 216 | ||||
| 202 | // Check for internal registry auth (localhost requests with internal creds) | 217 | // Check for internal registry auth (localhost requests with internal creds) | ||
| 203 | - | var authenticated bool | 218 | + | var ( |
| 219 | + | authenticated bool | |||
| 220 | + | parentClaims *domain.TokenClaims | |||
| 221 | + | ) | |||
| 204 | if isLocalhostRequest(r) && h.isInternalAuth(username, password) { | 222 | if isLocalhostRequest(r) && h.isInternalAuth(username, password) { | ||
| 205 | authenticated = true | 223 | authenticated = true | ||
| 206 | log.Debug().Str("username", username).Msg("internal registry auth accepted") | 224 | log.Debug().Str("username", username).Msg("internal registry auth accepted") | ||
| @@ -214,6 +232,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||||
| 214 | claims, err := h.authSvc.ValidateToken(ctx, password) | 232 | claims, err := h.authSvc.ValidateToken(ctx, password) | ||
| 215 | if err == nil && claims.Subject == username { | 233 | if err == nil && claims.Subject == username { | ||
| 216 | authenticated = true | 234 | authenticated = true | ||
| 235 | + | parentClaims = claims | |||
| 217 | } | 236 | } | ||
| 218 | } | 237 | } | ||
| 219 | } | 238 | } | ||
| @@ -231,6 +250,15 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||||
| 231 | 250 | ||||
| 232 | // Parse requested scopes from query parameter | 251 | // Parse requested scopes from query parameter | ||
| 233 | requestedScopes := h.parseRequestedScopes(r, log) | 252 | requestedScopes := h.parseRequestedScopes(r, log) | ||
| 253 | + | if parentClaims != nil { | |||
| 254 | + | requestedScopes = h.intersectRequestedScopes(requestedScopes, parentClaims.Scopes, log) | |||
| 255 | + | if len(requestedScopes) == 0 { | |||
| 256 | + | w.Header().Set("Content-Type", "application/json") | |||
| 257 | + | w.WriteHeader(http.StatusForbidden) | |||
| 258 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "forbidden: insufficient scope"}) | |||
| 259 | + | return | |||
| 260 | + | } | |||
| 261 | + | } | |||
| 234 | 262 | ||||
| 235 | // Generate a short-lived access token (5 minutes) - not stored | 263 | // Generate a short-lived access token (5 minutes) - not stored | ||
| 236 | accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute) | 264 | accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute) | ||
| @@ -261,6 +289,69 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||||
| 261 | Msg("access token issued") | 289 | Msg("access token issued") | ||
| 262 | } | 290 | } | ||
| 263 | 291 | ||||
| 292 | + | func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []string, log zerowrap.Logger) []string { | |||
| 293 | + | var effective []string | |||
| 294 | + | ||||
| 295 | + | for _, reqScopeStr := range requestedScopes { | |||
| 296 | + | reqScope, err := domain.ParseScope(reqScopeStr) | |||
| 297 | + | if err != nil || reqScope.Type != domain.ScopeTypeRepository { | |||
| 298 | + | continue | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | allowedActions := make([]string, 0, len(reqScope.Actions)) | |||
| 302 | + | for _, action := range reqScope.Actions { | |||
| 303 | + | if hasGrantedRegistryAccess(grantedScopes, reqScope.Name, action) { | |||
| 304 | + | allowedActions = append(allowedActions, action) | |||
| 305 | + | } | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | if len(allowedActions) == 0 { | |||
| 309 | + | continue | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | effective = append(effective, (&domain.Scope{ | |||
| 313 | + | Type: reqScope.Type, | |||
| 314 | + | Name: reqScope.Name, | |||
| 315 | + | Actions: allowedActions, | |||
| 316 | + | }).String()) | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | log.Debug(). | |||
| 320 | + | Strs("requested_scopes", requestedScopes). | |||
| 321 | + | Strs("granted_scopes", grantedScopes). | |||
| 322 | + | Strs("effective_scopes", effective). | |||
| 323 | + | Msg("calculated effective scopes from parent token") | |||
| 324 | + | ||||
| 325 | + | return effective | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) bool { | |||
| 329 | + | for _, grantedScopeStr := range grantedScopes { | |||
| 330 | + | scopeStr := strings.TrimSpace(grantedScopeStr) | |||
| 331 | + | switch scopeStr { | |||
| 332 | + | case domain.ScopeActionAll: | |||
| 333 | + | return true | |||
| 334 | + | case domain.ScopeActionPull, domain.ScopeActionPush: | |||
| 335 | + | if scopeStr == action { | |||
| 336 | + | return true | |||
| 337 | + | } | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | scope, err := domain.ParseScope(scopeStr) | |||
| 341 | + | if err != nil { | |||
| 342 | + | continue | |||
| 343 | + | } | |||
| 344 | + | if scope.Type != domain.ScopeTypeRepository { | |||
| 345 | + | continue | |||
| 346 | + | } | |||
| 347 | + | if scope.CanAccess(repoName, action) { | |||
| 348 | + | return true | |||
| 349 | + | } | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | return false | |||
| 353 | + | } | |||
| 354 | + | ||||
| 264 | // sendAnonymousToken sends a token for anonymous/unauthenticated access. | 355 | // sendAnonymousToken sends a token for anonymous/unauthenticated access. | ||
| 265 | func (h *Handler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) { | 356 | func (h *Handler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) { | ||
| 266 | // For anonymous access, we issue a very short-lived token with limited scope | 357 | // For anonymous access, we issue a very short-lived token with limited scope | ||
M internal/adapters/in/http/auth/handler_test.go +58 -19
| @@ -26,7 +26,7 @@ func TestHandler_Password_Success(t *testing.T) { | |||
| 26 | 26 | authSvc.EXPECT().IsEnabled().Return(true) | |
| 27 | 27 | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | |
| 28 | 28 | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true) | |
| 29 | - | authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 7*24*time.Hour). | |
| 29 | + | authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 24*time.Hour). | |
| 30 | 30 | Return("jwt-token-here", nil) | |
| 31 | 31 | ||
| 32 | 32 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| @@ -44,7 +44,7 @@ func TestHandler_Password_Success(t *testing.T) { | |||
| 44 | 44 | err := json.NewDecoder(rec.Body).Decode(&resp) | |
| 45 | 45 | assert.NoError(t, err) | |
| 46 | 46 | assert.Equal(t, "jwt-token-here", resp.Token) | |
| 47 | - | assert.Equal(t, 7*24*60*60, resp.ExpiresIn) // 7 days in seconds | |
| 47 | + | assert.Equal(t, 24*60*60, resp.ExpiresIn) | |
| 48 | 48 | assert.NotEmpty(t, resp.IssuedAt) | |
| 49 | 49 | } | |
| 50 | 50 | ||
| @@ -101,8 +101,8 @@ func TestHandler_Password_AuthDisabled(t *testing.T) { | |||
| 101 | 101 | ||
| 102 | 102 | handler.ServeHTTP(rec, req) | |
| 103 | 103 | ||
| 104 | - | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 105 | - | assert.Contains(t, rec.Body.String(), "authentication is disabled") | |
| 104 | + | assert.Equal(t, http.StatusServiceUnavailable, rec.Code) | |
| 105 | + | assert.Contains(t, rec.Body.String(), "authentication is required") | |
| 106 | 106 | } | |
| 107 | 107 | ||
| 108 | 108 | func TestHandler_Password_MissingFields(t *testing.T) { | |
| @@ -179,7 +179,7 @@ func TestHandler_Token_Success(t *testing.T) { | |||
| 179 | 179 | assert.Equal(t, float64(300), resp["expires_in"]) | |
| 180 | 180 | } | |
| 181 | 181 | ||
| 182 | - | func TestHandler_Token_Anonymous(t *testing.T) { | |
| 182 | + | func TestHandler_Token_MissingCredentials(t *testing.T) { | |
| 183 | 183 | authSvc := mocks.NewMockAuthService(t) | |
| 184 | 184 | ||
| 185 | 185 | authSvc.EXPECT().IsEnabled().Return(true) | |
| @@ -191,13 +191,7 @@ func TestHandler_Token_Anonymous(t *testing.T) { | |||
| 191 | 191 | ||
| 192 | 192 | handler.ServeHTTP(rec, req) | |
| 193 | 193 | ||
| 194 | - | assert.Equal(t, http.StatusOK, rec.Code) | |
| 195 | - | ||
| 196 | - | var resp map[string]any | |
| 197 | - | err := json.NewDecoder(rec.Body).Decode(&resp) | |
| 198 | - | assert.NoError(t, err) | |
| 199 | - | assert.Equal(t, "", resp["token"]) | |
| 200 | - | assert.Equal(t, float64(60), resp["expires_in"]) | |
| 194 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 201 | 195 | } | |
| 202 | 196 | ||
| 203 | 197 | func TestHandler_Token_AuthDisabled(t *testing.T) { | |
| @@ -212,13 +206,8 @@ func TestHandler_Token_AuthDisabled(t *testing.T) { | |||
| 212 | 206 | ||
| 213 | 207 | handler.ServeHTTP(rec, req) | |
| 214 | 208 | ||
| 215 | - | // Should return anonymous token when auth is disabled | |
| 216 | - | assert.Equal(t, http.StatusOK, rec.Code) | |
| 217 | - | ||
| 218 | - | var resp map[string]any | |
| 219 | - | err := json.NewDecoder(rec.Body).Decode(&resp) | |
| 220 | - | assert.NoError(t, err) | |
| 221 | - | assert.Equal(t, "", resp["token"]) | |
| 209 | + | assert.Equal(t, http.StatusServiceUnavailable, rec.Code) | |
| 210 | + | assert.Contains(t, rec.Body.String(), "authentication is required") | |
| 222 | 211 | } | |
| 223 | 212 | ||
| 224 | 213 | func TestHandler_Token_InvalidCredentials(t *testing.T) { | |
| @@ -274,6 +263,7 @@ func TestHandler_Token_TokenAuth(t *testing.T) { | |||
| 274 | 263 | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken) | |
| 275 | 264 | authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{ | |
| 276 | 265 | Subject: "myuser", | |
| 266 | + | Scopes: []string{"repository:*:pull"}, | |
| 277 | 267 | }, nil) | |
| 278 | 268 | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 5*time.Minute). | |
| 279 | 269 | Return("short-lived-token", nil) | |
| @@ -294,6 +284,55 @@ func TestHandler_Token_TokenAuth(t *testing.T) { | |||
| 294 | 284 | assert.Equal(t, "short-lived-token", resp["token"]) | |
| 295 | 285 | } | |
| 296 | 286 | ||
| 287 | + | func TestHandler_Token_TokenAuthScopeIntersection(t *testing.T) { | |
| 288 | + | authSvc := mocks.NewMockAuthService(t) | |
| 289 | + | ||
| 290 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 291 | + | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken) | |
| 292 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{ | |
| 293 | + | Subject: "myuser", | |
| 294 | + | Scopes: []string{"repository:myrepo:pull"}, | |
| 295 | + | }, nil) | |
| 296 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 5*time.Minute). | |
| 297 | + | Return("scoped-token", nil) | |
| 298 | + | ||
| 299 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 300 | + | ||
| 301 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull,push", nil) | |
| 302 | + | req.SetBasicAuth("myuser", "existing-jwt-token") | |
| 303 | + | rec := httptest.NewRecorder() | |
| 304 | + | ||
| 305 | + | handler.ServeHTTP(rec, req) | |
| 306 | + | ||
| 307 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 308 | + | var resp map[string]any | |
| 309 | + | err := json.NewDecoder(rec.Body).Decode(&resp) | |
| 310 | + | assert.NoError(t, err) | |
| 311 | + | assert.Equal(t, "scoped-token", resp["token"]) | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | func TestHandler_Token_TokenAuthScopeDenied(t *testing.T) { | |
| 315 | + | authSvc := mocks.NewMockAuthService(t) | |
| 316 | + | ||
| 317 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 318 | + | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken) | |
| 319 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{ | |
| 320 | + | Subject: "myuser", | |
| 321 | + | Scopes: []string{"repository:myrepo:pull"}, | |
| 322 | + | }, nil) | |
| 323 | + | ||
| 324 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| 325 | + | ||
| 326 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:push", nil) | |
| 327 | + | req.SetBasicAuth("myuser", "existing-jwt-token") | |
| 328 | + | rec := httptest.NewRecorder() | |
| 329 | + | ||
| 330 | + | handler.ServeHTTP(rec, req) | |
| 331 | + | ||
| 332 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |
| 333 | + | assert.Contains(t, rec.Body.String(), "insufficient scope") | |
| 334 | + | } | |
| 335 | + | ||
| 297 | 336 | func TestHandler_Token_MethodNotAllowed(t *testing.T) { | |
| 298 | 337 | handler := NewHandler(nil, InternalAuth{}, testLogger()) | |
| 299 | 338 | ||
| @@ -26,7 +26,7 @@ func TestHandler_Password_Success(t *testing.T) { | |||||
| 26 | authSvc.EXPECT().IsEnabled().Return(true) | 26 | authSvc.EXPECT().IsEnabled().Return(true) | ||
| 27 | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | 27 | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword) | ||
| 28 | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true) | 28 | authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true) | ||
| 29 | - | authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 7*24*time.Hour). | 29 | + | authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 24*time.Hour). |
| 30 | Return("jwt-token-here", nil) | 30 | Return("jwt-token-here", nil) | ||
| 31 | 31 | ||||
| 32 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | 32 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | ||
| @@ -44,7 +44,7 @@ func TestHandler_Password_Success(t *testing.T) { | |||||
| 44 | err := json.NewDecoder(rec.Body).Decode(&resp) | 44 | err := json.NewDecoder(rec.Body).Decode(&resp) | ||
| 45 | assert.NoError(t, err) | 45 | assert.NoError(t, err) | ||
| 46 | assert.Equal(t, "jwt-token-here", resp.Token) | 46 | assert.Equal(t, "jwt-token-here", resp.Token) | ||
| 47 | - | assert.Equal(t, 7*24*60*60, resp.ExpiresIn) // 7 days in seconds | 47 | + | assert.Equal(t, 24*60*60, resp.ExpiresIn) |
| 48 | assert.NotEmpty(t, resp.IssuedAt) | 48 | assert.NotEmpty(t, resp.IssuedAt) | ||
| 49 | } | 49 | } | ||
| 50 | 50 | ||||
| @@ -101,8 +101,8 @@ func TestHandler_Password_AuthDisabled(t *testing.T) { | |||||
| 101 | 101 | ||||
| 102 | handler.ServeHTTP(rec, req) | 102 | handler.ServeHTTP(rec, req) | ||
| 103 | 103 | ||||
| 104 | - | assert.Equal(t, http.StatusBadRequest, rec.Code) | 104 | + | assert.Equal(t, http.StatusServiceUnavailable, rec.Code) |
| 105 | - | assert.Contains(t, rec.Body.String(), "authentication is disabled") | 105 | + | assert.Contains(t, rec.Body.String(), "authentication is required") |
| 106 | } | 106 | } | ||
| 107 | 107 | ||||
| 108 | func TestHandler_Password_MissingFields(t *testing.T) { | 108 | func TestHandler_Password_MissingFields(t *testing.T) { | ||
| @@ -179,7 +179,7 @@ func TestHandler_Token_Success(t *testing.T) { | |||||
| 179 | assert.Equal(t, float64(300), resp["expires_in"]) | 179 | assert.Equal(t, float64(300), resp["expires_in"]) | ||
| 180 | } | 180 | } | ||
| 181 | 181 | ||||
| 182 | - | func TestHandler_Token_Anonymous(t *testing.T) { | 182 | + | func TestHandler_Token_MissingCredentials(t *testing.T) { |
| 183 | authSvc := mocks.NewMockAuthService(t) | 183 | authSvc := mocks.NewMockAuthService(t) | ||
| 184 | 184 | ||||
| 185 | authSvc.EXPECT().IsEnabled().Return(true) | 185 | authSvc.EXPECT().IsEnabled().Return(true) | ||
| @@ -191,13 +191,7 @@ func TestHandler_Token_Anonymous(t *testing.T) { | |||||
| 191 | 191 | ||||
| 192 | handler.ServeHTTP(rec, req) | 192 | handler.ServeHTTP(rec, req) | ||
| 193 | 193 | ||||
| 194 | - | assert.Equal(t, http.StatusOK, rec.Code) | 194 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) |
| 195 | - | ||||
| 196 | - | var resp map[string]any | |||
| 197 | - | err := json.NewDecoder(rec.Body).Decode(&resp) | |||
| 198 | - | assert.NoError(t, err) | |||
| 199 | - | assert.Equal(t, "", resp["token"]) | |||
| 200 | - | assert.Equal(t, float64(60), resp["expires_in"]) | |||
| 201 | } | 195 | } | ||
| 202 | 196 | ||||
| 203 | func TestHandler_Token_AuthDisabled(t *testing.T) { | 197 | func TestHandler_Token_AuthDisabled(t *testing.T) { | ||
| @@ -212,13 +206,8 @@ func TestHandler_Token_AuthDisabled(t *testing.T) { | |||||
| 212 | 206 | ||||
| 213 | handler.ServeHTTP(rec, req) | 207 | handler.ServeHTTP(rec, req) | ||
| 214 | 208 | ||||
| 215 | - | // Should return anonymous token when auth is disabled | 209 | + | assert.Equal(t, http.StatusServiceUnavailable, rec.Code) |
| 216 | - | assert.Equal(t, http.StatusOK, rec.Code) | 210 | + | assert.Contains(t, rec.Body.String(), "authentication is required") |
| 217 | - | ||||
| 218 | - | var resp map[string]any | |||
| 219 | - | err := json.NewDecoder(rec.Body).Decode(&resp) | |||
| 220 | - | assert.NoError(t, err) | |||
| 221 | - | assert.Equal(t, "", resp["token"]) | |||
| 222 | } | 211 | } | ||
| 223 | 212 | ||||
| 224 | func TestHandler_Token_InvalidCredentials(t *testing.T) { | 213 | func TestHandler_Token_InvalidCredentials(t *testing.T) { | ||
| @@ -274,6 +263,7 @@ func TestHandler_Token_TokenAuth(t *testing.T) { | |||||
| 274 | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken) | 263 | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken) | ||
| 275 | authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{ | 264 | authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{ | ||
| 276 | Subject: "myuser", | 265 | Subject: "myuser", | ||
| 266 | + | Scopes: []string{"repository:*:pull"}, | |||
| 277 | }, nil) | 267 | }, nil) | ||
| 278 | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 5*time.Minute). | 268 | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 5*time.Minute). | ||
| 279 | Return("short-lived-token", nil) | 269 | Return("short-lived-token", nil) | ||
| @@ -294,6 +284,55 @@ func TestHandler_Token_TokenAuth(t *testing.T) { | |||||
| 294 | assert.Equal(t, "short-lived-token", resp["token"]) | 284 | assert.Equal(t, "short-lived-token", resp["token"]) | ||
| 295 | } | 285 | } | ||
| 296 | 286 | ||||
| 287 | + | func TestHandler_Token_TokenAuthScopeIntersection(t *testing.T) { | |||
| 288 | + | authSvc := mocks.NewMockAuthService(t) | |||
| 289 | + | ||||
| 290 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 291 | + | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken) | |||
| 292 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{ | |||
| 293 | + | Subject: "myuser", | |||
| 294 | + | Scopes: []string{"repository:myrepo:pull"}, | |||
| 295 | + | }, nil) | |||
| 296 | + | authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 5*time.Minute). | |||
| 297 | + | Return("scoped-token", nil) | |||
| 298 | + | ||||
| 299 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 300 | + | ||||
| 301 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull,push", nil) | |||
| 302 | + | req.SetBasicAuth("myuser", "existing-jwt-token") | |||
| 303 | + | rec := httptest.NewRecorder() | |||
| 304 | + | ||||
| 305 | + | handler.ServeHTTP(rec, req) | |||
| 306 | + | ||||
| 307 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 308 | + | var resp map[string]any | |||
| 309 | + | err := json.NewDecoder(rec.Body).Decode(&resp) | |||
| 310 | + | assert.NoError(t, err) | |||
| 311 | + | assert.Equal(t, "scoped-token", resp["token"]) | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | func TestHandler_Token_TokenAuthScopeDenied(t *testing.T) { | |||
| 315 | + | authSvc := mocks.NewMockAuthService(t) | |||
| 316 | + | ||||
| 317 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 318 | + | authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken) | |||
| 319 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{ | |||
| 320 | + | Subject: "myuser", | |||
| 321 | + | Scopes: []string{"repository:myrepo:pull"}, | |||
| 322 | + | }, nil) | |||
| 323 | + | ||||
| 324 | + | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |||
| 325 | + | ||||
| 326 | + | req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:push", nil) | |||
| 327 | + | req.SetBasicAuth("myuser", "existing-jwt-token") | |||
| 328 | + | rec := httptest.NewRecorder() | |||
| 329 | + | ||||
| 330 | + | handler.ServeHTTP(rec, req) | |||
| 331 | + | ||||
| 332 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |||
| 333 | + | assert.Contains(t, rec.Body.String(), "insufficient scope") | |||
| 334 | + | } | |||
| 335 | + | ||||
| 297 | func TestHandler_Token_MethodNotAllowed(t *testing.T) { | 336 | func TestHandler_Token_MethodNotAllowed(t *testing.T) { | ||
| 298 | handler := NewHandler(nil, InternalAuth{}, testLogger()) | 337 | handler := NewHandler(nil, InternalAuth{}, testLogger()) | ||
| 299 | 338 | ||||
M internal/adapters/in/http/middleware/clientip.go +44 -6
| @@ -6,6 +6,24 @@ import ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | + | func normalizeIP(raw string) string { | |
| 10 | + | ip := strings.TrimSpace(raw) | |
| 11 | + | if ip == "" { | |
| 12 | + | return "" | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | if host, _, err := net.SplitHostPort(ip); err == nil { | |
| 16 | + | ip = host | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | parsed := net.ParseIP(strings.TrimSpace(ip)) | |
| 20 | + | if parsed == nil { | |
| 21 | + | return "" | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | return parsed.String() | |
| 25 | + | } | |
| 26 | + | ||
| 9 | 27 | // ParseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet. | |
| 10 | 28 | // It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8"). | |
| 11 | 29 | // Single IPs are converted to /32 (IPv4) or /128 (IPv6) CIDR blocks. | |
| @@ -60,21 +78,41 @@ func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string { | |||
| 60 | 78 | if err != nil { | |
| 61 | 79 | remoteIP = r.RemoteAddr | |
| 62 | 80 | } | |
| 81 | + | if normalized := normalizeIP(remoteIP); normalized != "" { | |
| 82 | + | remoteIP = normalized | |
| 83 | + | } | |
| 63 | 84 | ||
| 64 | 85 | // Only honor proxy headers if the request comes from a trusted proxy | |
| 65 | 86 | if IsTrustedProxy(remoteIP, trustedNets) { | |
| 66 | - | // Check X-Forwarded-For for proxied requests | |
| 87 | + | // Check X-Forwarded-For for proxied requests. | |
| 88 | + | // SECURITY: Parse from right to left and return the first untrusted IP. | |
| 89 | + | // This prevents spoofing when a trusted proxy appends to an attacker- | |
| 90 | + | // supplied XFF chain. | |
| 67 | 91 | if xff := r.Header.Get("X-Forwarded-For"); xff != "" { | |
| 68 | - | // Take first IP in chain (original client) | |
| 69 | - | if first, _, found := strings.Cut(xff, ","); found { | |
| 70 | - | return strings.TrimSpace(first) | |
| 92 | + | parts := strings.Split(xff, ",") | |
| 93 | + | validChain := make([]string, 0, len(parts)) | |
| 94 | + | for _, part := range parts { | |
| 95 | + | if ip := normalizeIP(part); ip != "" { | |
| 96 | + | validChain = append(validChain, ip) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | for i := len(validChain) - 1; i >= 0; i-- { | |
| 101 | + | if !IsTrustedProxy(validChain[i], trustedNets) { | |
| 102 | + | return validChain[i] | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | if len(validChain) > 0 { | |
| 107 | + | return validChain[0] | |
| 71 | 108 | } | |
| 72 | - | return strings.TrimSpace(xff) | |
| 73 | 109 | } | |
| 74 | 110 | ||
| 75 | 111 | // Check X-Real-IP | |
| 76 | 112 | if xri := r.Header.Get("X-Real-IP"); xri != "" { | |
| 77 | - | return xri | |
| 113 | + | if ip := normalizeIP(xri); ip != "" { | |
| 114 | + | return ip | |
| 115 | + | } | |
| 78 | 116 | } | |
| 79 | 117 | } | |
| 80 | 118 | ||
| @@ -6,6 +6,24 @@ import ( | |||||
| 6 | "strings" | 6 | "strings" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | + | func normalizeIP(raw string) string { | |||
| 10 | + | ip := strings.TrimSpace(raw) | |||
| 11 | + | if ip == "" { | |||
| 12 | + | return "" | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | if host, _, err := net.SplitHostPort(ip); err == nil { | |||
| 16 | + | ip = host | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | parsed := net.ParseIP(strings.TrimSpace(ip)) | |||
| 20 | + | if parsed == nil { | |||
| 21 | + | return "" | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | return parsed.String() | |||
| 25 | + | } | |||
| 26 | + | ||||
| 9 | // ParseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet. | 27 | // ParseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet. | ||
| 10 | // It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8"). | 28 | // It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8"). | ||
| 11 | // Single IPs are converted to /32 (IPv4) or /128 (IPv6) CIDR blocks. | 29 | // Single IPs are converted to /32 (IPv4) or /128 (IPv6) CIDR blocks. | ||
| @@ -60,21 +78,41 @@ func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string { | |||||
| 60 | if err != nil { | 78 | if err != nil { | ||
| 61 | remoteIP = r.RemoteAddr | 79 | remoteIP = r.RemoteAddr | ||
| 62 | } | 80 | } | ||
| 81 | + | if normalized := normalizeIP(remoteIP); normalized != "" { | |||
| 82 | + | remoteIP = normalized | |||
| 83 | + | } | |||
| 63 | 84 | ||||
| 64 | // Only honor proxy headers if the request comes from a trusted proxy | 85 | // Only honor proxy headers if the request comes from a trusted proxy | ||
| 65 | if IsTrustedProxy(remoteIP, trustedNets) { | 86 | if IsTrustedProxy(remoteIP, trustedNets) { | ||
| 66 | - | // Check X-Forwarded-For for proxied requests | 87 | + | // Check X-Forwarded-For for proxied requests. |
| 88 | + | // SECURITY: Parse from right to left and return the first untrusted IP. | |||
| 89 | + | // This prevents spoofing when a trusted proxy appends to an attacker- | |||
| 90 | + | // supplied XFF chain. | |||
| 67 | if xff := r.Header.Get("X-Forwarded-For"); xff != "" { | 91 | if xff := r.Header.Get("X-Forwarded-For"); xff != "" { | ||
| 68 | - | // Take first IP in chain (original client) | 92 | + | parts := strings.Split(xff, ",") |
| 69 | - | if first, _, found := strings.Cut(xff, ","); found { | 93 | + | validChain := make([]string, 0, len(parts)) |
| 70 | - | return strings.TrimSpace(first) | 94 | + | for _, part := range parts { |
| 95 | + | if ip := normalizeIP(part); ip != "" { | |||
| 96 | + | validChain = append(validChain, ip) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | for i := len(validChain) - 1; i >= 0; i-- { | |||
| 101 | + | if !IsTrustedProxy(validChain[i], trustedNets) { | |||
| 102 | + | return validChain[i] | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | if len(validChain) > 0 { | |||
| 107 | + | return validChain[0] | |||
| 71 | } | 108 | } | ||
| 72 | - | return strings.TrimSpace(xff) | |||
| 73 | } | 109 | } | ||
| 74 | 110 | ||||
| 75 | // Check X-Real-IP | 111 | // Check X-Real-IP | ||
| 76 | if xri := r.Header.Get("X-Real-IP"); xri != "" { | 112 | if xri := r.Header.Get("X-Real-IP"); xri != "" { | ||
| 77 | - | return xri | 113 | + | if ip := normalizeIP(xri); ip != "" { |
| 114 | + | return ip | |||
| 115 | + | } | |||
| 78 | } | 116 | } | ||
| 79 | } | 117 | } | ||
| 80 | 118 | ||||
M internal/adapters/in/http/middleware/clientip_test.go +8 -1
| @@ -58,7 +58,14 @@ func TestGetClientIP(t *testing.T) { | |||
| 58 | 58 | { | |
| 59 | 59 | name: "XFF honored from trusted proxy (multiple IPs)", | |
| 60 | 60 | remoteAddr: "127.0.0.1:12345", | |
| 61 | - | xff: "203.0.113.50, 10.0.0.1, 172.16.0.1", | |
| 61 | + | xff: "203.0.113.50, 10.0.0.1", | |
| 62 | + | trustedNets: trustedNets, | |
| 63 | + | wantIP: "203.0.113.50", | |
| 64 | + | }, | |
| 65 | + | { | |
| 66 | + | name: "XFF spoof chain returns first untrusted from right", | |
| 67 | + | remoteAddr: "127.0.0.1:12345", | |
| 68 | + | xff: "198.51.100.123, 203.0.113.50, 10.0.0.9", | |
| 62 | 69 | trustedNets: trustedNets, | |
| 63 | 70 | wantIP: "203.0.113.50", | |
| 64 | 71 | }, | |
| @@ -58,7 +58,14 @@ func TestGetClientIP(t *testing.T) { | |||||
| 58 | { | 58 | { | ||
| 59 | name: "XFF honored from trusted proxy (multiple IPs)", | 59 | name: "XFF honored from trusted proxy (multiple IPs)", | ||
| 60 | remoteAddr: "127.0.0.1:12345", | 60 | remoteAddr: "127.0.0.1:12345", | ||
| 61 | - | xff: "203.0.113.50, 10.0.0.1, 172.16.0.1", | 61 | + | xff: "203.0.113.50, 10.0.0.1", |
| 62 | + | trustedNets: trustedNets, | |||
| 63 | + | wantIP: "203.0.113.50", | |||
| 64 | + | }, | |||
| 65 | + | { | |||
| 66 | + | name: "XFF spoof chain returns first untrusted from right", | |||
| 67 | + | remoteAddr: "127.0.0.1:12345", | |||
| 68 | + | xff: "198.51.100.123, 203.0.113.50, 10.0.0.9", | |||
| 62 | trustedNets: trustedNets, | 69 | trustedNets: trustedNets, | ||
| 63 | wantIP: "203.0.113.50", | 70 | wantIP: "203.0.113.50", | ||
| 64 | }, | 71 | }, | ||
M internal/app/run.go +49 -12
| @@ -41,6 +41,7 @@ import ( | |||
| 41 | 41 | "github.com/bnema/gordon/internal/adapters/out/tokenstore" | |
| 42 | 42 | ||
| 43 | 43 | // Adapters - Input | |
| 44 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 44 | 45 | "github.com/bnema/gordon/internal/adapters/in/http/admin" | |
| 45 | 46 | authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth" | |
| 46 | 47 | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | |
| @@ -487,11 +488,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 487 | 488 | return svc, nil | |
| 488 | 489 | } | |
| 489 | 490 | ||
| 490 | - | func setupInternalRegistryAuth(cfg Config, svc *services, log zerowrap.Logger) error { | |
| 491 | - | if !cfg.Auth.Enabled { | |
| 492 | - | return nil | |
| 493 | - | } | |
| 494 | - | ||
| 491 | + | func setupInternalRegistryAuth(_ Config, svc *services, log zerowrap.Logger) error { | |
| 495 | 492 | var err error | |
| 496 | 493 | svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth() | |
| 497 | 494 | if err != nil { | |
| @@ -761,7 +758,7 @@ func GetInternalCredentials() (*InternalCredentials, error) { | |||
| 761 | 758 | // createAuthService creates the authentication service and token store. | |
| 762 | 759 | func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) { | |
| 763 | 760 | if !cfg.Auth.Enabled { | |
| 764 | - | return nil, nil, nil | |
| 761 | + | return nil, nil, fmt.Errorf("auth.enabled=false is no longer supported; authentication is always required") | |
| 765 | 762 | } | |
| 766 | 763 | ||
| 767 | 764 | authType := resolveAuthType(cfg) | |
| @@ -850,7 +847,7 @@ func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowra | |||
| 850 | 847 | ||
| 851 | 848 | func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) { | |
| 852 | 849 | authConfig := auth.Config{ | |
| 853 | - | Enabled: cfg.Auth.Enabled, | |
| 850 | + | Enabled: true, | |
| 854 | 851 | AuthType: authType, | |
| 855 | 852 | Username: cfg.Auth.Username, | |
| 856 | 853 | } | |
| @@ -1045,7 +1042,7 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro | |||
| 1045 | 1042 | // createContainerService creates the container service with configuration. | |
| 1046 | 1043 | func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) { | |
| 1047 | 1044 | containerConfig := container.Config{ | |
| 1048 | - | RegistryAuthEnabled: cfg.Auth.Enabled, | |
| 1045 | + | RegistryAuthEnabled: true, | |
| 1049 | 1046 | RegistryDomain: cfg.Server.RegistryDomain, | |
| 1050 | 1047 | RegistryPort: cfg.Server.RegistryPort, | |
| 1051 | 1048 | InternalRegistryUsername: svc.internalRegUser, | |
| @@ -1071,7 +1068,7 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||
| 1071 | 1068 | containerConfig.DrainDelay = v.GetDuration("deploy.drain_delay") | |
| 1072 | 1069 | } | |
| 1073 | 1070 | ||
| 1074 | - | if cfg.Auth.Enabled && svc.authSvc != nil { | |
| 1071 | + | if svc.authSvc != nil { | |
| 1075 | 1072 | expiry, err := resolveServiceTokenExpiry(cfg) | |
| 1076 | 1073 | if err != nil { | |
| 1077 | 1074 | return nil, log.WrapErr(err, "failed to resolve service token expiry") | |
| @@ -1088,6 +1085,8 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||
| 1088 | 1085 | Msg("generated service token for container registry access") | |
| 1089 | 1086 | containerConfig.ServiceTokenUsername = serviceTokenSubject | |
| 1090 | 1087 | containerConfig.ServiceToken = serviceToken | |
| 1088 | + | } else { | |
| 1089 | + | return nil, fmt.Errorf("authentication service unavailable: cannot generate registry service token") | |
| 1091 | 1090 | } | |
| 1092 | 1091 | ||
| 1093 | 1092 | return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil | |
| @@ -1242,6 +1241,28 @@ func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) { | |||
| 1242 | 1241 | } | |
| 1243 | 1242 | } | |
| 1244 | 1243 | } | |
| 1244 | + | func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler { | |
| 1245 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 1246 | + | host, _, err := net.SplitHostPort(r.RemoteAddr) | |
| 1247 | + | if err != nil { | |
| 1248 | + | host = r.RemoteAddr | |
| 1249 | + | } | |
| 1250 | + | ||
| 1251 | + | ip := net.ParseIP(host) | |
| 1252 | + | if ip == nil || !ip.IsLoopback() { | |
| 1253 | + | log.Warn(). | |
| 1254 | + | Str("path", r.URL.Path). | |
| 1255 | + | Str("remote_addr", r.RemoteAddr). | |
| 1256 | + | Msg("blocked non-loopback access on internal admin route") | |
| 1257 | + | w.Header().Set("Content-Type", "application/json") | |
| 1258 | + | w.WriteHeader(http.StatusForbidden) | |
| 1259 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"}) | |
| 1260 | + | return | |
| 1261 | + | } | |
| 1262 | + | ||
| 1263 | + | next.ServeHTTP(w, r) | |
| 1264 | + | }) | |
| 1265 | + | } | |
| 1245 | 1266 | ||
| 1246 | 1267 | // createHTTPHandlers creates HTTP handlers with middleware. | |
| 1247 | 1268 | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) { | |
| @@ -1294,13 +1315,22 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1294 | 1315 | } | |
| 1295 | 1316 | registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware) | |
| 1296 | 1317 | ||
| 1297 | - | if cfg.Auth.Enabled && svc.authSvc != nil { | |
| 1318 | + | if svc.authSvc != nil { | |
| 1298 | 1319 | internalAuth := middleware.InternalRegistryAuth{ | |
| 1299 | 1320 | Username: svc.internalRegUser, | |
| 1300 | 1321 | Password: svc.internalRegPass, | |
| 1301 | 1322 | } | |
| 1302 | 1323 | registryMiddlewares = append(registryMiddlewares, | |
| 1303 | 1324 | middleware.RegistryAuthV2(svc.authSvc, internalAuth, log)) | |
| 1325 | + | } else { | |
| 1326 | + | log.Error().Msg("authentication service unavailable; registry requests will be denied") | |
| 1327 | + | registryMiddlewares = append(registryMiddlewares, func(next http.Handler) http.Handler { | |
| 1328 | + | return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 1329 | + | w.Header().Set("Content-Type", "application/json") | |
| 1330 | + | w.WriteHeader(http.StatusServiceUnavailable) | |
| 1331 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"}) | |
| 1332 | + | }) | |
| 1333 | + | }) | |
| 1304 | 1334 | } | |
| 1305 | 1335 | ||
| 1306 | 1336 | registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler) | |
| @@ -1334,7 +1364,6 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1334 | 1364 | middleware.RequestLogger(log, trustedNets), | |
| 1335 | 1365 | middleware.SecurityHeaders, | |
| 1336 | 1366 | } | |
| 1337 | - | // Add admin auth middleware if auth is enabled | |
| 1338 | 1367 | if svc.authSvc != nil { | |
| 1339 | 1368 | // Create rate limiters for admin API - uses same config as registry | |
| 1340 | 1369 | var globalLimiter, ipLimiter out.RateLimiter | |
| @@ -1343,9 +1372,17 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1343 | 1372 | ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log) | |
| 1344 | 1373 | } | |
| 1345 | 1374 | adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log)) | |
| 1375 | + | } else { | |
| 1376 | + | adminMiddlewares = append(adminMiddlewares, func(next http.Handler) http.Handler { | |
| 1377 | + | return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 1378 | + | w.Header().Set("Content-Type", "application/json") | |
| 1379 | + | w.WriteHeader(http.StatusServiceUnavailable) | |
| 1380 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"}) | |
| 1381 | + | }) | |
| 1382 | + | }) | |
| 1346 | 1383 | } | |
| 1347 | 1384 | adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler) | |
| 1348 | - | registryMux.Handle("/admin/", adminWithMiddleware) | |
| 1385 | + | registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log)) | |
| 1349 | 1386 | } | |
| 1350 | 1387 | ||
| 1351 | 1388 | // SECURITY: No CORS middleware on the proxy chain. Backend applications | |
| @@ -41,6 +41,7 @@ import ( | |||||
| 41 | "github.com/bnema/gordon/internal/adapters/out/tokenstore" | 41 | "github.com/bnema/gordon/internal/adapters/out/tokenstore" | ||
| 42 | 42 | ||||
| 43 | // Adapters - Input | 43 | // Adapters - Input | ||
| 44 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 44 | "github.com/bnema/gordon/internal/adapters/in/http/admin" | 45 | "github.com/bnema/gordon/internal/adapters/in/http/admin" | ||
| 45 | authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth" | 46 | authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth" | ||
| 46 | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | 47 | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | ||
| @@ -487,11 +488,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 487 | return svc, nil | 488 | return svc, nil | ||
| 488 | } | 489 | } | ||
| 489 | 490 | ||||
| 490 | - | func setupInternalRegistryAuth(cfg Config, svc *services, log zerowrap.Logger) error { | 491 | + | func setupInternalRegistryAuth(_ Config, svc *services, log zerowrap.Logger) error { |
| 491 | - | if !cfg.Auth.Enabled { | |||
| 492 | - | return nil | |||
| 493 | - | } | |||
| 494 | - | ||||
| 495 | var err error | 492 | var err error | ||
| 496 | svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth() | 493 | svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth() | ||
| 497 | if err != nil { | 494 | if err != nil { | ||
| @@ -761,7 +758,7 @@ func GetInternalCredentials() (*InternalCredentials, error) { | |||||
| 761 | // createAuthService creates the authentication service and token store. | 758 | // createAuthService creates the authentication service and token store. | ||
| 762 | func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) { | 759 | func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) { | ||
| 763 | if !cfg.Auth.Enabled { | 760 | if !cfg.Auth.Enabled { | ||
| 764 | - | return nil, nil, nil | 761 | + | return nil, nil, fmt.Errorf("auth.enabled=false is no longer supported; authentication is always required") |
| 765 | } | 762 | } | ||
| 766 | 763 | ||||
| 767 | authType := resolveAuthType(cfg) | 764 | authType := resolveAuthType(cfg) | ||
| @@ -850,7 +847,7 @@ func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowra | |||||
| 850 | 847 | ||||
| 851 | func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) { | 848 | func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) { | ||
| 852 | authConfig := auth.Config{ | 849 | authConfig := auth.Config{ | ||
| 853 | - | Enabled: cfg.Auth.Enabled, | 850 | + | Enabled: true, |
| 854 | AuthType: authType, | 851 | AuthType: authType, | ||
| 855 | Username: cfg.Auth.Username, | 852 | Username: cfg.Auth.Username, | ||
| 856 | } | 853 | } | ||
| @@ -1045,7 +1042,7 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro | |||||
| 1045 | // createContainerService creates the container service with configuration. | 1042 | // createContainerService creates the container service with configuration. | ||
| 1046 | func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) { | 1043 | func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) { | ||
| 1047 | containerConfig := container.Config{ | 1044 | containerConfig := container.Config{ | ||
| 1048 | - | RegistryAuthEnabled: cfg.Auth.Enabled, | 1045 | + | RegistryAuthEnabled: true, |
| 1049 | RegistryDomain: cfg.Server.RegistryDomain, | 1046 | RegistryDomain: cfg.Server.RegistryDomain, | ||
| 1050 | RegistryPort: cfg.Server.RegistryPort, | 1047 | RegistryPort: cfg.Server.RegistryPort, | ||
| 1051 | InternalRegistryUsername: svc.internalRegUser, | 1048 | InternalRegistryUsername: svc.internalRegUser, | ||
| @@ -1071,7 +1068,7 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||||
| 1071 | containerConfig.DrainDelay = v.GetDuration("deploy.drain_delay") | 1068 | containerConfig.DrainDelay = v.GetDuration("deploy.drain_delay") | ||
| 1072 | } | 1069 | } | ||
| 1073 | 1070 | ||||
| 1074 | - | if cfg.Auth.Enabled && svc.authSvc != nil { | 1071 | + | if svc.authSvc != nil { |
| 1075 | expiry, err := resolveServiceTokenExpiry(cfg) | 1072 | expiry, err := resolveServiceTokenExpiry(cfg) | ||
| 1076 | if err != nil { | 1073 | if err != nil { | ||
| 1077 | return nil, log.WrapErr(err, "failed to resolve service token expiry") | 1074 | return nil, log.WrapErr(err, "failed to resolve service token expiry") | ||
| @@ -1088,6 +1085,8 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||||
| 1088 | Msg("generated service token for container registry access") | 1085 | Msg("generated service token for container registry access") | ||
| 1089 | containerConfig.ServiceTokenUsername = serviceTokenSubject | 1086 | containerConfig.ServiceTokenUsername = serviceTokenSubject | ||
| 1090 | containerConfig.ServiceToken = serviceToken | 1087 | containerConfig.ServiceToken = serviceToken | ||
| 1088 | + | } else { | |||
| 1089 | + | return nil, fmt.Errorf("authentication service unavailable: cannot generate registry service token") | |||
| 1091 | } | 1090 | } | ||
| 1092 | 1091 | ||||
| 1093 | return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil | 1092 | return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil | ||
| @@ -1242,6 +1241,28 @@ func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) { | |||||
| 1242 | } | 1241 | } | ||
| 1243 | } | 1242 | } | ||
| 1244 | } | 1243 | } | ||
| 1244 | + | func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler { | |||
| 1245 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 1246 | + | host, _, err := net.SplitHostPort(r.RemoteAddr) | |||
| 1247 | + | if err != nil { | |||
| 1248 | + | host = r.RemoteAddr | |||
| 1249 | + | } | |||
| 1250 | + | ||||
| 1251 | + | ip := net.ParseIP(host) | |||
| 1252 | + | if ip == nil || !ip.IsLoopback() { | |||
| 1253 | + | log.Warn(). | |||
| 1254 | + | Str("path", r.URL.Path). | |||
| 1255 | + | Str("remote_addr", r.RemoteAddr). | |||
| 1256 | + | Msg("blocked non-loopback access on internal admin route") | |||
| 1257 | + | w.Header().Set("Content-Type", "application/json") | |||
| 1258 | + | w.WriteHeader(http.StatusForbidden) | |||
| 1259 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"}) | |||
| 1260 | + | return | |||
| 1261 | + | } | |||
| 1262 | + | ||||
| 1263 | + | next.ServeHTTP(w, r) | |||
| 1264 | + | }) | |||
| 1265 | + | } | |||
| 1245 | 1266 | ||||
| 1246 | // createHTTPHandlers creates HTTP handlers with middleware. | 1267 | // createHTTPHandlers creates HTTP handlers with middleware. | ||
| 1247 | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) { | 1268 | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) { | ||
| @@ -1294,13 +1315,22 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 1294 | } | 1315 | } | ||
| 1295 | registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware) | 1316 | registryMiddlewares = append(registryMiddlewares, rateLimitMiddleware) | ||
| 1296 | 1317 | ||||
| 1297 | - | if cfg.Auth.Enabled && svc.authSvc != nil { | 1318 | + | if svc.authSvc != nil { |
| 1298 | internalAuth := middleware.InternalRegistryAuth{ | 1319 | internalAuth := middleware.InternalRegistryAuth{ | ||
| 1299 | Username: svc.internalRegUser, | 1320 | Username: svc.internalRegUser, | ||
| 1300 | Password: svc.internalRegPass, | 1321 | Password: svc.internalRegPass, | ||
| 1301 | } | 1322 | } | ||
| 1302 | registryMiddlewares = append(registryMiddlewares, | 1323 | registryMiddlewares = append(registryMiddlewares, | ||
| 1303 | middleware.RegistryAuthV2(svc.authSvc, internalAuth, log)) | 1324 | middleware.RegistryAuthV2(svc.authSvc, internalAuth, log)) | ||
| 1325 | + | } else { | |||
| 1326 | + | log.Error().Msg("authentication service unavailable; registry requests will be denied") | |||
| 1327 | + | registryMiddlewares = append(registryMiddlewares, func(next http.Handler) http.Handler { | |||
| 1328 | + | return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 1329 | + | w.Header().Set("Content-Type", "application/json") | |||
| 1330 | + | w.WriteHeader(http.StatusServiceUnavailable) | |||
| 1331 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"}) | |||
| 1332 | + | }) | |||
| 1333 | + | }) | |||
| 1304 | } | 1334 | } | ||
| 1305 | 1335 | ||||
| 1306 | registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler) | 1336 | registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler) | ||
| @@ -1334,7 +1364,6 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 1334 | middleware.RequestLogger(log, trustedNets), | 1364 | middleware.RequestLogger(log, trustedNets), | ||
| 1335 | middleware.SecurityHeaders, | 1365 | middleware.SecurityHeaders, | ||
| 1336 | } | 1366 | } | ||
| 1337 | - | // Add admin auth middleware if auth is enabled | |||
| 1338 | if svc.authSvc != nil { | 1367 | if svc.authSvc != nil { | ||
| 1339 | // Create rate limiters for admin API - uses same config as registry | 1368 | // Create rate limiters for admin API - uses same config as registry | ||
| 1340 | var globalLimiter, ipLimiter out.RateLimiter | 1369 | var globalLimiter, ipLimiter out.RateLimiter | ||
| @@ -1343,9 +1372,17 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 1343 | ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log) | 1372 | ipLimiter = ratelimit.NewMemoryStore(cfg.API.RateLimit.PerIPRPS, cfg.API.RateLimit.Burst, log) | ||
| 1344 | } | 1373 | } | ||
| 1345 | adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log)) | 1374 | adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, globalLimiter, ipLimiter, trustedNets, log)) | ||
| 1375 | + | } else { | |||
| 1376 | + | adminMiddlewares = append(adminMiddlewares, func(next http.Handler) http.Handler { | |||
| 1377 | + | return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 1378 | + | w.Header().Set("Content-Type", "application/json") | |||
| 1379 | + | w.WriteHeader(http.StatusServiceUnavailable) | |||
| 1380 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication service unavailable"}) | |||
| 1381 | + | }) | |||
| 1382 | + | }) | |||
| 1346 | } | 1383 | } | ||
| 1347 | adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler) | 1384 | adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler) | ||
| 1348 | - | registryMux.Handle("/admin/", adminWithMiddleware) | 1385 | + | registryMux.Handle("/admin/", loopbackOnly(adminWithMiddleware, log)) |
| 1349 | } | 1386 | } | ||
| 1350 | 1387 | ||||
| 1351 | // SECURITY: No CORS middleware on the proxy chain. Backend applications | 1388 | // SECURITY: No CORS middleware on the proxy chain. Backend applications | ||