History

security: fix localhost middleware header spoofing vulnerability

Fix critical security vulnerability in amp-restrict-management-to-localhost feature where attackers could bypass localhost restriction by spoofing X-Forwarded-For headers. Changes: - Use RemoteAddr (actual TCP connection) instead of ClientIP() in localhostOnlyMiddleware to prevent header spoofing attacks - Add comprehensive test coverage for spoofing prevention (6 test cases) - Update documentation with reverse proxy deployment guidance and limitations of the RemoteAddr approach The fix prevents attacks like: curl -H "X-Forwarded-For: 127.0.0.1" https://server/api/user Trade-off: Users behind reverse proxies will need to disable the feature and use alternative security measures (firewall rules, proxy ACLs). Addresses security review feedback from PR #287.

Ben Vargas committed 11 months ago

Commit a6cb16b1 parent 70ee4e03 files · +129 −5Browse files

Unified · Split

3 files
  1. docs/amp-cli-integration.md +32 −1
  2. internal/api/modules/amp/routes.go +18 −4
  3. internal/api/modules/amp/routes_test.go +79 −0
docs/amp-cli-integration.md+32−1
@@ -135,8 +135,39 @@ When enabled, management routes (`/api/auth`, `/api/user`, `/api/threads`, etc.) · 134 unmodified lines
135- Drive-by browser attacks135- Drive-by browser attacks
136- Remote access to management endpoints136- Remote access to management endpoints
137- CORS-based attacks137- CORS-based attacks
138- Header spoofing attacks (e.g., `X-Forwarded-For: 127.0.0.1`)
138 139
139**Important**: Only disable this if you understand the security implications and have other protections in place (VPN, firewall, etc.).140#### How It Works
141
142This restriction uses the **actual TCP connection address** (`RemoteAddr`), not HTTP headers like `X-Forwarded-For`. This prevents header spoofing attacks but has important implications:
143
144- ✅ **Works for direct connections**: Running CLIProxyAPI directly on your machine or server
145- ⚠️ **May not work behind reverse proxies**: If deploying behind nginx, Cloudflare, or other proxies, the connection will appear to come from the proxy's IP, not localhost
146
147#### Reverse Proxy Deployments
148
149If you need to run CLIProxyAPI behind a reverse proxy (nginx, Caddy, Cloudflare Tunnel, etc.):
150
1511. **Disable the localhost restriction**:
152 ```yaml
153 amp-restrict-management-to-localhost: false
154 ```
155
1562. **Use alternative security measures**:
157 - Firewall rules restricting access to management routes
158 - Proxy-level authentication (HTTP Basic Auth, OAuth)
159 - Network-level isolation (VPN, Tailscale, Cloudflare Access)
160 - Bind CLIProxyAPI to `127.0.0.1` only and access via SSH tunnel
161
1623. **Example nginx configuration** (blocks external access to management routes):
163 ```nginx
164 location /api/auth { deny all; }
165 location /api/user { deny all; }
166 location /api/threads { deny all; }
167 location /api/internal { deny all; }
168 ```
169
170**Important**: Only disable `amp-restrict-management-to-localhost` if you understand the security implications and have other protections in place.
140 171
141## Setup172## Setup
142 173
internal/api/modules/amp/routes.go+18−4
@@ -16,14 +16,28 @@ import ( · 15 unmodified lines
16 16
17// localhostOnlyMiddleware restricts access to localhost (127.0.0.1, ::1) only.17// localhostOnlyMiddleware restricts access to localhost (127.0.0.1, ::1) only.
18// Returns 403 Forbidden for non-localhost clients.18// Returns 403 Forbidden for non-localhost clients.
19//
20// Security: Uses RemoteAddr (actual TCP connection) instead of ClientIP() to prevent
21// header spoofing attacks via X-Forwarded-For or similar headers. This means the
22// middleware will not work correctly behind reverse proxies - users deploying behind
23// nginx/Cloudflare should disable this feature and use firewall rules instead.
19func localhostOnlyMiddleware() gin.HandlerFunc {24func localhostOnlyMiddleware() gin.HandlerFunc {
20 return func(c *gin.Context) {25 return func(c *gin.Context) {
21 clientIP := c.ClientIP()26 // Use actual TCP connection address (RemoteAddr) to prevent header spoofing
27 // This cannot be forged by X-Forwarded-For or other client-controlled headers
28 remoteAddr := c.Request.RemoteAddr
29
30 // RemoteAddr format is "IP:port" or "[IPv6]:port", extract just the IP
31 host, _, err := net.SplitHostPort(remoteAddr)
32 if err != nil {
33 // Try parsing as raw IP (shouldn't happen with standard HTTP, but be defensive)
34 host = remoteAddr
35 }
22 36
23 // Parse the IP to handle both IPv4 and IPv637 // Parse the IP to handle both IPv4 and IPv6
24 ip := net.ParseIP(clientIP)38 ip := net.ParseIP(host)
25 if ip == nil {39 if ip == nil {
26 log.Warnf("Amp management: invalid client IP %s, denying access", clientIP)40 log.Warnf("Amp management: invalid RemoteAddr %s, denying access", remoteAddr)
27 c.AbortWithStatusJSON(403, gin.H{41 c.AbortWithStatusJSON(403, gin.H{
28 "error": "Access denied: management routes restricted to localhost",42 "error": "Access denied: management routes restricted to localhost",
29 })43 })
@@ -32,7 +46,7 @@ func localhostOnlyMiddleware() gin.HandlerFunc { · 2 unmodified lines
32 46
33 // Check if IP is loopback (127.0.0.1 or ::1)47 // Check if IP is loopback (127.0.0.1 or ::1)
34 if !ip.IsLoopback() {48 if !ip.IsLoopback() {
35 log.Warnf("Amp management: non-localhost IP %s attempted access, denying", clientIP)49 log.Warnf("Amp management: non-localhost connection from %s attempted access, denying", remoteAddr)
36 c.AbortWithStatusJSON(403, gin.H{50 c.AbortWithStatusJSON(403, gin.H{
37 "error": "Access denied: management routes restricted to localhost",51 "error": "Access denied: management routes restricted to localhost",
38 })52 })
internal/api/modules/amp/routes_test.go+79−0
@@ -220,3 +220,82 @@ func TestRegisterProviderAliases_NoAuthMiddleware(t *testing.T) { · 219 unmodified lines
220 t.Fatal("routes should register even without auth middleware")220 t.Fatal("routes should register even without auth middleware")
221 }221 }
222}222}
223
224func TestLocalhostOnlyMiddleware_PreventsSpoofing(t *testing.T) {
225 gin.SetMode(gin.TestMode)
226 r := gin.New()
227
228 // Apply localhost-only middleware
229 r.Use(localhostOnlyMiddleware())
230 r.GET("/test", func(c *gin.Context) {
231 c.String(http.StatusOK, "ok")
232 })
233
234 tests := []struct {
235 name string
236 remoteAddr string
237 forwardedFor string
238 expectedStatus int
239 description string
240 }{
241 {
242 name: "spoofed_header_remote_connection",
243 remoteAddr: "192.168.1.100:12345",
244 forwardedFor: "127.0.0.1",
245 expectedStatus: http.StatusForbidden,
246 description: "Spoofed X-Forwarded-For header should be ignored",
247 },
248 {
249 name: "real_localhost_ipv4",
250 remoteAddr: "127.0.0.1:54321",
251 forwardedFor: "",
252 expectedStatus: http.StatusOK,
253 description: "Real localhost IPv4 connection should work",
254 },
255 {
256 name: "real_localhost_ipv6",
257 remoteAddr: "[::1]:54321",
258 forwardedFor: "",
259 expectedStatus: http.StatusOK,
260 description: "Real localhost IPv6 connection should work",
261 },
262 {
263 name: "remote_ipv4",
264 remoteAddr: "203.0.113.42:8080",
265 forwardedFor: "",
266 expectedStatus: http.StatusForbidden,
267 description: "Remote IPv4 connection should be blocked",
268 },
269 {
270 name: "remote_ipv6",
271 remoteAddr: "[2001:db8::1]:9090",
272 forwardedFor: "",
273 expectedStatus: http.StatusForbidden,
274 description: "Remote IPv6 connection should be blocked",
275 },
276 {
277 name: "spoofed_localhost_ipv6",
278 remoteAddr: "203.0.113.42:8080",
279 forwardedFor: "::1",
280 expectedStatus: http.StatusForbidden,
281 description: "Spoofed X-Forwarded-For with IPv6 localhost should be ignored",
282 },
283 }
284
285 for _, tt := range tests {
286 t.Run(tt.name, func(t *testing.T) {
287 req := httptest.NewRequest(http.MethodGet, "/test", nil)
288 req.RemoteAddr = tt.remoteAddr
289 if tt.forwardedFor != "" {
290 req.Header.Set("X-Forwarded-For", tt.forwardedFor)
291 }
292
293 w := httptest.NewRecorder()
294 r.ServeHTTP(w, req)
295
296 if w.Code != tt.expectedStatus {
297 t.Errorf("%s: expected status %d, got %d", tt.description, tt.expectedStatus, w.Code)
298 }
299 })
300 }
301}