feat(amp): require API key authentication for management routes
Unified · Split
| @@ -136,8 +136,8 @@ ws-auth: false · 135 unmodified lines | ||
| 136 | 136 | # upstream-url: "https://ampcode.com" |
| 137 | 137 | # # Optional: Override API key for Amp upstream (otherwise uses env or file) |
| 138 | 138 | # upstream-api-key: "" |
| 139 | −# # Restrict Amp management routes (/api/auth, /api/user, etc.) to localhost only (recommended) | |
| 140 | −# restrict-management-to-localhost: true | |
| 139 | +# # Restrict Amp management routes (/api/auth, /api/user, etc.) to localhost only (default: false) | |
| 140 | +# restrict-management-to-localhost: false | |
| 141 | 141 | # # Force model mappings to run before checking local API keys (default: false) |
| 142 | 142 | # force-model-mappings: false |
| 143 | 143 | # # Amp Model Mappings |
| @@ -137,7 +137,8 @@ func (m *AmpModule) Register(ctx modules.Context) error { · 136 unmodified lines | ||
| 137 | 137 | m.registerProviderAliases(ctx.Engine, ctx.BaseHandler, auth) |
| 138 | 138 | |
| 139 | 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 | 143 | // If no upstream URL, skip proxy routes but provider aliases are still available |
| 143 | 144 | if upstreamURL == "" { |
| @@ -187,9 +188,6 @@ func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error { · 43 unmodified lines | ||
| 187 | 188 | |
| 188 | 189 | if oldSettings != nil && oldSettings.RestrictManagementToLocalhost != newSettings.RestrictManagementToLocalhost { |
| 189 | 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 | 193 | newUpstreamURL := strings.TrimSpace(newSettings.UpstreamURL) |
| @@ -64,7 +64,7 @@ func logAmpRouting(routeType AmpRouteType, requestedModel, resolvedModel, provid · 63 unmodified lines | ||
| 64 | 64 | fields["cost"] = "amp_credits" |
| 65 | 65 | fields["source"] = "ampcode.com" |
| 66 | 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 | 69 | case RouteTypeNoProvider: |
| 70 | 70 | fields["cost"] = "none" |
| @@ -41,6 +41,11 @@ func createReverseProxy(upstreamURL string, secretSource SecretSource) (*httputi · 40 unmodified lines | ||
| 41 | 41 | originalDirector(req) |
| 42 | 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 | 49 | // Preserve correlation headers for debugging |
| 45 | 50 | if req.Header.Get("X-Request-ID") == "" { |
| 46 | 51 | // Could generate one here if needed |
| @@ -50,7 +55,7 @@ func createReverseProxy(upstreamURL string, secretSource SecretSource) (*httputi · 3 unmodified lines | ||
| 50 | 55 | // Users going through ampcode.com proxy are paying for the service and should get all features |
| 51 | 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 | 59 | if key, err := secretSource.Get(req.Context()); err == nil && key != "" { |
| 55 | 60 | req.Header.Set("X-Api-Key", key) |
| 56 | 61 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key)) |
| @@ -98,7 +98,8 @@ func (m *AmpModule) managementAvailabilityMiddleware() gin.HandlerFunc { · 97 unmodified lines | ||
| 98 | 98 | // registerManagementRoutes registers Amp management proxy routes |
| 99 | 99 | // These routes proxy through to the Amp control plane for OAuth, user management, etc. |
| 100 | 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 | 103 | ampAPI := engine.Group("/api") |
| 103 | 104 | |
| 104 | 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 | 108 | // Apply dynamic localhost-only restriction (hot-reloadable via m.IsRestrictedToLocalhost()) |
| 108 | 109 | ampAPI.Use(m.localhostOnlyMiddleware()) |
| 109 | 110 | |
| 110 | − if !m.IsRestrictedToLocalhost() { | |
| 111 | − log.Warn("amp management routes are NOT restricted to localhost - this is insecure!") | |
| 111 | + // Apply authentication middleware - requires valid API key in Authorization header | |
| 112 | + if auth != nil { | |
| 113 | + ampAPI.Use(auth) | |
| 112 | 114 | } |
| 113 | 115 | |
| 114 | 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 | 156 | // Root-level routes that AMP CLI expects without /api prefix |
| 155 | 157 | // These need the same security middleware as the /api/* routes (dynamic for hot-reload) |
| 156 | 158 | rootMiddleware := []gin.HandlerFunc{m.managementAvailabilityMiddleware(), noCORSMiddleware(), m.localhostOnlyMiddleware()} |
| 159 | + if auth != nil { | |
| 160 | + rootMiddleware = append(rootMiddleware, auth) | |
| 161 | + } | |
| 157 | 162 | engine.GET("/threads/*path", append(rootMiddleware, proxyHandler)...) |
| 158 | 163 | engine.GET("/threads.rss", append(rootMiddleware, proxyHandler)...) |
| 159 | 164 | engine.GET("/news.rss", append(rootMiddleware, proxyHandler)...) |
| @@ -139,7 +139,7 @@ type AmpCode struct { · 138 unmodified lines | ||
| 139 | 139 | |
| 140 | 140 | // RestrictManagementToLocalhost restricts Amp management routes (/api/user, /api/threads, etc.) |
| 141 | 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 | 143 | RestrictManagementToLocalhost bool `yaml:"restrict-management-to-localhost" json:"restrict-management-to-localhost"` |
| 144 | 144 | |
| 145 | 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 | 327 | cfg.LoggingToFile = false |
| 328 | 328 | cfg.UsageStatisticsEnabled = false |
| 329 | 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 | 331 | if err = yaml.Unmarshal(data, &cfg); err != nil { |
| 332 | 332 | if optional { |
| 333 | 333 | // In cloud deploy mode, if YAML parsing fails, return empty config instead of error. |