feat(amp): require API key authentication for management routes

All Amp management endpoints (e.g., /api/user, /threads) are now protected by the standard API key authentication middleware. This ensures that all management operations require a valid API key, significantly improving security.

As a result of this change:
- The `restrict-management-to-localhost` setting now defaults to `false`. API key authentication provides a stronger and more flexible security control than IP-based restrictions, improving usability in containerized environments.
- The reverse proxy logic now strips the client's `Authorization` header after authenticating the initial request. It then injects the configured `upstream-api-key` for the request to the upstream Amp service.

BREAKING CHANGE: Amp management endpoints now require a valid API key for authentication. Requests without a valid API key in the `Authorization` header will be rejected with a 401 Unauthorized error.
This commit is contained in:
hkfires
2025-12-15 13:24:53 +08:00
parent 8e4fbcaa7d
commit 8f1dd69e72
6 changed files with 21 additions and 13 deletions

View File

@@ -136,8 +136,8 @@ ws-auth: false
# upstream-url: "https://ampcode.com" # upstream-url: "https://ampcode.com"
# # Optional: Override API key for Amp upstream (otherwise uses env or file) # # Optional: Override API key for Amp upstream (otherwise uses env or file)
# upstream-api-key: "" # upstream-api-key: ""
# # Restrict Amp management routes (/api/auth, /api/user, etc.) to localhost only (recommended) # # Restrict Amp management routes (/api/auth, /api/user, etc.) to localhost only (default: false)
# restrict-management-to-localhost: true # restrict-management-to-localhost: false
# # Force model mappings to run before checking local API keys (default: false) # # Force model mappings to run before checking local API keys (default: false)
# force-model-mappings: false # force-model-mappings: false
# # Amp Model Mappings # # Amp Model Mappings

View File

@@ -137,7 +137,8 @@ func (m *AmpModule) Register(ctx modules.Context) error {
m.registerProviderAliases(ctx.Engine, ctx.BaseHandler, auth) m.registerProviderAliases(ctx.Engine, ctx.BaseHandler, auth)
// Register management proxy routes once; middleware will gate access when upstream is unavailable. // Register management proxy routes once; middleware will gate access when upstream is unavailable.
m.registerManagementRoutes(ctx.Engine, ctx.BaseHandler) // Pass auth middleware to require valid API key for all management routes.
m.registerManagementRoutes(ctx.Engine, ctx.BaseHandler, auth)
// If no upstream URL, skip proxy routes but provider aliases are still available // If no upstream URL, skip proxy routes but provider aliases are still available
if upstreamURL == "" { if upstreamURL == "" {
@@ -187,9 +188,6 @@ func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error {
if oldSettings != nil && oldSettings.RestrictManagementToLocalhost != newSettings.RestrictManagementToLocalhost { if oldSettings != nil && oldSettings.RestrictManagementToLocalhost != newSettings.RestrictManagementToLocalhost {
m.setRestrictToLocalhost(newSettings.RestrictManagementToLocalhost) m.setRestrictToLocalhost(newSettings.RestrictManagementToLocalhost)
if !newSettings.RestrictManagementToLocalhost {
log.Warnf("amp management routes now accessible from any IP - this is insecure!")
}
} }
newUpstreamURL := strings.TrimSpace(newSettings.UpstreamURL) newUpstreamURL := strings.TrimSpace(newSettings.UpstreamURL)

View File

@@ -64,7 +64,7 @@ func logAmpRouting(routeType AmpRouteType, requestedModel, resolvedModel, provid
fields["cost"] = "amp_credits" fields["cost"] = "amp_credits"
fields["source"] = "ampcode.com" fields["source"] = "ampcode.com"
fields["model_id"] = requestedModel // Explicit model_id for easy config reference fields["model_id"] = requestedModel // Explicit model_id for easy config reference
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) 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)
case RouteTypeNoProvider: case RouteTypeNoProvider:
fields["cost"] = "none" fields["cost"] = "none"

View File

@@ -41,6 +41,11 @@ func createReverseProxy(upstreamURL string, secretSource SecretSource) (*httputi
originalDirector(req) originalDirector(req)
req.Host = parsed.Host req.Host = parsed.Host
// Remove client's Authorization header - it was only used for CLI Proxy API authentication
// We will set our own Authorization using the configured upstream-api-key
req.Header.Del("Authorization")
req.Header.Del("X-Api-Key")
// Preserve correlation headers for debugging // Preserve correlation headers for debugging
if req.Header.Get("X-Request-ID") == "" { if req.Header.Get("X-Request-ID") == "" {
// Could generate one here if needed // Could generate one here if needed
@@ -50,7 +55,7 @@ func createReverseProxy(upstreamURL string, secretSource SecretSource) (*httputi
// Users going through ampcode.com proxy are paying for the service and should get all features // Users going through ampcode.com proxy are paying for the service and should get all features
// including 1M context window (context-1m-2025-08-07) // including 1M context window (context-1m-2025-08-07)
// Inject API key from secret source (precedence: config > env > file) // Inject API key from secret source (only uses upstream-api-key from config)
if key, err := secretSource.Get(req.Context()); err == nil && key != "" { if key, err := secretSource.Get(req.Context()); err == nil && key != "" {
req.Header.Set("X-Api-Key", key) req.Header.Set("X-Api-Key", key)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key)) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key))

View File

@@ -98,7 +98,8 @@ func (m *AmpModule) managementAvailabilityMiddleware() gin.HandlerFunc {
// registerManagementRoutes registers Amp management proxy routes // registerManagementRoutes registers Amp management proxy routes
// These routes proxy through to the Amp control plane for OAuth, user management, etc. // These routes proxy through to the Amp control plane for OAuth, user management, etc.
// Uses dynamic middleware and proxy getter for hot-reload support. // Uses dynamic middleware and proxy getter for hot-reload support.
func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *handlers.BaseAPIHandler) { // The auth middleware validates Authorization header against configured API keys.
func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *handlers.BaseAPIHandler, auth gin.HandlerFunc) {
ampAPI := engine.Group("/api") ampAPI := engine.Group("/api")
// Always disable CORS for management routes to prevent browser-based attacks // Always disable CORS for management routes to prevent browser-based attacks
@@ -107,8 +108,9 @@ func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *ha
// Apply dynamic localhost-only restriction (hot-reloadable via m.IsRestrictedToLocalhost()) // Apply dynamic localhost-only restriction (hot-reloadable via m.IsRestrictedToLocalhost())
ampAPI.Use(m.localhostOnlyMiddleware()) ampAPI.Use(m.localhostOnlyMiddleware())
if !m.IsRestrictedToLocalhost() { // Apply authentication middleware - requires valid API key in Authorization header
log.Warn("amp management routes are NOT restricted to localhost - this is insecure!") if auth != nil {
ampAPI.Use(auth)
} }
// Dynamic proxy handler that uses m.getProxy() for hot-reload support // Dynamic proxy handler that uses m.getProxy() for hot-reload support
@@ -154,6 +156,9 @@ func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *ha
// Root-level routes that AMP CLI expects without /api prefix // Root-level routes that AMP CLI expects without /api prefix
// These need the same security middleware as the /api/* routes (dynamic for hot-reload) // These need the same security middleware as the /api/* routes (dynamic for hot-reload)
rootMiddleware := []gin.HandlerFunc{m.managementAvailabilityMiddleware(), noCORSMiddleware(), m.localhostOnlyMiddleware()} rootMiddleware := []gin.HandlerFunc{m.managementAvailabilityMiddleware(), noCORSMiddleware(), m.localhostOnlyMiddleware()}
if auth != nil {
rootMiddleware = append(rootMiddleware, auth)
}
engine.GET("/threads/*path", append(rootMiddleware, proxyHandler)...) engine.GET("/threads/*path", append(rootMiddleware, proxyHandler)...)
engine.GET("/threads.rss", append(rootMiddleware, proxyHandler)...) engine.GET("/threads.rss", append(rootMiddleware, proxyHandler)...)
engine.GET("/news.rss", append(rootMiddleware, proxyHandler)...) engine.GET("/news.rss", append(rootMiddleware, proxyHandler)...)

View File

@@ -139,7 +139,7 @@ type AmpCode struct {
// RestrictManagementToLocalhost restricts Amp management routes (/api/user, /api/threads, etc.) // RestrictManagementToLocalhost restricts Amp management routes (/api/user, /api/threads, etc.)
// to only accept connections from localhost (127.0.0.1, ::1). When true, prevents drive-by // to only accept connections from localhost (127.0.0.1, ::1). When true, prevents drive-by
// browser attacks and remote access to management endpoints. Default: true (recommended). // browser attacks and remote access to management endpoints. Default: false (API key auth is sufficient).
RestrictManagementToLocalhost bool `yaml:"restrict-management-to-localhost" json:"restrict-management-to-localhost"` RestrictManagementToLocalhost bool `yaml:"restrict-management-to-localhost" json:"restrict-management-to-localhost"`
// ModelMappings defines model name mappings for Amp CLI requests. // ModelMappings defines model name mappings for Amp CLI requests.
@@ -327,7 +327,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
cfg.LoggingToFile = false cfg.LoggingToFile = false
cfg.UsageStatisticsEnabled = false cfg.UsageStatisticsEnabled = false
cfg.DisableCooling = false cfg.DisableCooling = false
cfg.AmpCode.RestrictManagementToLocalhost = true // Default to secure: only localhost access cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient
if err = yaml.Unmarshal(data, &cfg); err != nil { if err = yaml.Unmarshal(data, &cfg); err != nil {
if optional { if optional {
// In cloud deploy mode, if YAML parsing fails, return empty config instead of error. // In cloud deploy mode, if YAML parsing fails, return empty config instead of error.