feat(amp): require API key authentication for management routes
Unified · Split
| @@ -136,8 +136,8 @@ ws-auth: false · 135 unmodified lines | |||
| 136 | # upstream-url: "https://ampcode.com" | 136 | # upstream-url: "https://ampcode.com" |
| 137 | # # Optional: Override API key for Amp upstream (otherwise uses env or file) | 137 | # # Optional: Override API key for Amp upstream (otherwise uses env or file) |
| 138 | # upstream-api-key: "" | 138 | # upstream-api-key: "" |
| 139 | # # Restrict Amp management routes (/api/auth, /api/user, etc.) to localhost only (recommended) | 139 | # # Restrict Amp management routes (/api/auth, /api/user, etc.) to localhost only (default: false) |
| 140 | # restrict-management-to-localhost: true | 140 | # restrict-management-to-localhost: false |
| 141 | # # Force model mappings to run before checking local API keys (default: false) | 141 | # # Force model mappings to run before checking local API keys (default: false) |
| 142 | # force-model-mappings: false | 142 | # force-model-mappings: false |
| 143 | # # Amp Model Mappings | 143 | # # Amp Model Mappings |
| @@ -137,7 +137,8 @@ func (m *AmpModule) Register(ctx modules.Context) error { · 136 unmodified lines | |||
| 137 | m.registerProviderAliases(ctx.Engine, ctx.BaseHandler, auth) | 137 | m.registerProviderAliases(ctx.Engine, ctx.BaseHandler, auth) |
| 138 | 138 | ||
| 139 | // Register management proxy routes once; middleware will gate access when upstream is unavailable. | 139 | // Register management proxy routes once; middleware will gate access when upstream is unavailable. |
| 140 | m.registerManagementRoutes(ctx.Engine, ctx.BaseHandler) | 140 | // Pass auth middleware to require valid API key for all management routes. |
| 141 | m.registerManagementRoutes(ctx.Engine, ctx.BaseHandler, auth) | ||
| 141 | 142 | ||
| 142 | // If no upstream URL, skip proxy routes but provider aliases are still available | 143 | // If no upstream URL, skip proxy routes but provider aliases are still available |
| 143 | if upstreamURL == "" { | 144 | if upstreamURL == "" { |
| @@ -187,9 +188,6 @@ func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error { · 43 unmodified lines | |||
| 187 | 188 | ||
| 188 | if oldSettings != nil && oldSettings.RestrictManagementToLocalhost != newSettings.RestrictManagementToLocalhost { | 189 | if oldSettings != nil && oldSettings.RestrictManagementToLocalhost != newSettings.RestrictManagementToLocalhost { |
| 189 | m.setRestrictToLocalhost(newSettings.RestrictManagementToLocalhost) | 190 | m.setRestrictToLocalhost(newSettings.RestrictManagementToLocalhost) |
| 190 | if !newSettings.RestrictManagementToLocalhost { | ||
| 191 | log.Warnf("amp management routes now accessible from any IP - this is insecure!") | ||
| 192 | } | ||
| 193 | } | 191 | } |
| 194 | 192 | ||
| 195 | newUpstreamURL := strings.TrimSpace(newSettings.UpstreamURL) | 193 | newUpstreamURL := strings.TrimSpace(newSettings.UpstreamURL) |
| @@ -64,7 +64,7 @@ func logAmpRouting(routeType AmpRouteType, requestedModel, resolvedModel, provid · 63 unmodified lines | |||
| 64 | fields["cost"] = "amp_credits" | 64 | fields["cost"] = "amp_credits" |
| 65 | fields["source"] = "ampcode.com" | 65 | fields["source"] = "ampcode.com" |
| 66 | fields["model_id"] = requestedModel // Explicit model_id for easy config reference | 66 | fields["model_id"] = requestedModel // Explicit model_id for easy config reference |
| 67 | log.WithFields(fields).Warnf("forwarding to ampcode.com (uses amp credits) - model_id: %s | To use local proxy, add to config: amp-model-mappings: [{from: \"%s\", to: \"<your-local-model>\"}]", requestedModel, requestedModel) | 67 | log.WithFields(fields).Warnf("forwarding to ampcode.com (uses amp credits) - model_id: %s | To use local provider, add to config: ampcode.model-mappings: [{from: \"%s\", to: \"<your-local-model>\"}]", requestedModel, requestedModel) |
| 68 | 68 | ||
| 69 | case RouteTypeNoProvider: | 69 | case RouteTypeNoProvider: |
| 70 | fields["cost"] = "none" | 70 | fields["cost"] = "none" |
| @@ -41,6 +41,11 @@ func createReverseProxy(upstreamURL string, secretSource SecretSource) (*httputi · 40 unmodified lines | |||
| 41 | originalDirector(req) | 41 | originalDirector(req) |
| 42 | req.Host = parsed.Host | 42 | req.Host = parsed.Host |
| 43 | 43 | ||
| 44 | // Remove client's Authorization header - it was only used for CLI Proxy API authentication | ||
| 45 | // We will set our own Authorization using the configured upstream-api-key | ||
| 46 | req.Header.Del("Authorization") | ||
| 47 | req.Header.Del("X-Api-Key") | ||
| 48 | |||
| 44 | // Preserve correlation headers for debugging | 49 | // Preserve correlation headers for debugging |
| 45 | if req.Header.Get("X-Request-ID") == "" { | 50 | if req.Header.Get("X-Request-ID") == "" { |
| 46 | // Could generate one here if needed | 51 | // Could generate one here if needed |
| @@ -50,7 +55,7 @@ func createReverseProxy(upstreamURL string, secretSource SecretSource) (*httputi · 3 unmodified lines | |||
| 50 | // Users going through ampcode.com proxy are paying for the service and should get all features | 55 | // Users going through ampcode.com proxy are paying for the service and should get all features |
| 51 | // including 1M context window (context-1m-2025-08-07) | 56 | // including 1M context window (context-1m-2025-08-07) |
| 52 | 57 | ||
| 53 | // Inject API key from secret source (precedence: config > env > file) | 58 | // Inject API key from secret source (only uses upstream-api-key from config) |
| 54 | if key, err := secretSource.Get(req.Context()); err == nil && key != "" { | 59 | if key, err := secretSource.Get(req.Context()); err == nil && key != "" { |
| 55 | req.Header.Set("X-Api-Key", key) | 60 | req.Header.Set("X-Api-Key", key) |
| 56 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key)) | 61 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key)) |
| @@ -98,7 +98,8 @@ func (m *AmpModule) managementAvailabilityMiddleware() gin.HandlerFunc { · 97 unmodified lines | |||
| 98 | // registerManagementRoutes registers Amp management proxy routes | 98 | // registerManagementRoutes registers Amp management proxy routes |
| 99 | // These routes proxy through to the Amp control plane for OAuth, user management, etc. | 99 | // These routes proxy through to the Amp control plane for OAuth, user management, etc. |
| 100 | // Uses dynamic middleware and proxy getter for hot-reload support. | 100 | // Uses dynamic middleware and proxy getter for hot-reload support. |
| 101 | func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *handlers.BaseAPIHandler) { | 101 | // The auth middleware validates Authorization header against configured API keys. |
| 102 | func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *handlers.BaseAPIHandler, auth gin.HandlerFunc) { | ||
| 102 | ampAPI := engine.Group("/api") | 103 | ampAPI := engine.Group("/api") |
| 103 | 104 | ||
| 104 | // Always disable CORS for management routes to prevent browser-based attacks | 105 | // Always disable CORS for management routes to prevent browser-based attacks |
| @@ -107,8 +108,9 @@ func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *ha · 2 unmodified lines | |||
| 107 | // Apply dynamic localhost-only restriction (hot-reloadable via m.IsRestrictedToLocalhost()) | 108 | // Apply dynamic localhost-only restriction (hot-reloadable via m.IsRestrictedToLocalhost()) |
| 108 | ampAPI.Use(m.localhostOnlyMiddleware()) | 109 | ampAPI.Use(m.localhostOnlyMiddleware()) |
| 109 | 110 | ||
| 110 | if !m.IsRestrictedToLocalhost() { | 111 | // Apply authentication middleware - requires valid API key in Authorization header |
| 111 | log.Warn("amp management routes are NOT restricted to localhost - this is insecure!") | 112 | if auth != nil { |
| 113 | ampAPI.Use(auth) | ||
| 112 | } | 114 | } |
| 113 | 115 | ||
| 114 | // Dynamic proxy handler that uses m.getProxy() for hot-reload support | 116 | // Dynamic proxy handler that uses m.getProxy() for hot-reload support |
| @@ -154,6 +156,9 @@ func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *ha · 39 unmodified lines | |||
| 154 | // Root-level routes that AMP CLI expects without /api prefix | 156 | // Root-level routes that AMP CLI expects without /api prefix |
| 155 | // These need the same security middleware as the /api/* routes (dynamic for hot-reload) | 157 | // These need the same security middleware as the /api/* routes (dynamic for hot-reload) |
| 156 | rootMiddleware := []gin.HandlerFunc{m.managementAvailabilityMiddleware(), noCORSMiddleware(), m.localhostOnlyMiddleware()} | 158 | rootMiddleware := []gin.HandlerFunc{m.managementAvailabilityMiddleware(), noCORSMiddleware(), m.localhostOnlyMiddleware()} |
| 159 | if auth != nil { | ||
| 160 | rootMiddleware = append(rootMiddleware, auth) | ||
| 161 | } | ||
| 157 | engine.GET("/threads/*path", append(rootMiddleware, proxyHandler)...) | 162 | engine.GET("/threads/*path", append(rootMiddleware, proxyHandler)...) |
| 158 | engine.GET("/threads.rss", append(rootMiddleware, proxyHandler)...) | 163 | engine.GET("/threads.rss", append(rootMiddleware, proxyHandler)...) |
| 159 | engine.GET("/news.rss", append(rootMiddleware, proxyHandler)...) | 164 | engine.GET("/news.rss", append(rootMiddleware, proxyHandler)...) |
| @@ -139,7 +139,7 @@ type AmpCode struct { · 138 unmodified lines | |||
| 139 | 139 | ||
| 140 | // RestrictManagementToLocalhost restricts Amp management routes (/api/user, /api/threads, etc.) | 140 | // RestrictManagementToLocalhost restricts Amp management routes (/api/user, /api/threads, etc.) |
| 141 | // to only accept connections from localhost (127.0.0.1, ::1). When true, prevents drive-by | 141 | // to only accept connections from localhost (127.0.0.1, ::1). When true, prevents drive-by |
| 142 | // browser attacks and remote access to management endpoints. Default: true (recommended). | 142 | // browser attacks and remote access to management endpoints. Default: false (API key auth is sufficient). |
| 143 | RestrictManagementToLocalhost bool `yaml:"restrict-management-to-localhost" json:"restrict-management-to-localhost"` | 143 | RestrictManagementToLocalhost bool `yaml:"restrict-management-to-localhost" json:"restrict-management-to-localhost"` |
| 144 | 144 | ||
| 145 | // ModelMappings defines model name mappings for Amp CLI requests. | 145 | // ModelMappings defines model name mappings for Amp CLI requests. |
| @@ -327,7 +327,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { · 181 unmodified lines | |||
| 327 | cfg.LoggingToFile = false | 327 | cfg.LoggingToFile = false |
| 328 | cfg.UsageStatisticsEnabled = false | 328 | cfg.UsageStatisticsEnabled = false |
| 329 | cfg.DisableCooling = false | 329 | cfg.DisableCooling = false |
| 330 | cfg.AmpCode.RestrictManagementToLocalhost = true // Default to secure: only localhost access | 330 | cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient |
| 331 | if err = yaml.Unmarshal(data, &cfg); err != nil { | 331 | if err = yaml.Unmarshal(data, &cfg); err != nil { |
| 332 | if optional { | 332 | if optional { |
| 333 | // In cloud deploy mode, if YAML parsing fails, return empty config instead of error. | 333 | // In cloud deploy mode, if YAML parsing fails, return empty config instead of error. |