---
title: Client IP Resolution Algorithm
summary: X-Forwarded-For parsing, trusted proxy chain validation, and IPv6 normalization for accurate client identification
---

# Client IP Resolution Algorithm

## Overview

When SophiaClaw gateway runs behind a reverse proxy (nginx, Traefik, Cloudflare, Tailscale Serve), the direct TCP connection comes from the proxy's IP address, not the original client. The client IP resolution algorithm reconstructs the true client IP by parsing proxy headers and validating the proxy chain.

**Location:** `src/gateway/net.ts`

---

## Problem Statement

```
┌─────────────┐     ┌─────────────┐     ┌──────────────────┐
│   Client    │────►│  Proxy      │────►│  SophiaClaw      │
│  203.0.113.5│     │  10.0.0.1   │     │  Gateway         │
└─────────────┘     └─────────────┘     └──────────────────┘
                         │                      │
                    Direct connection      Sees proxy IP
                    comes from proxy       (10.0.0.1)
```

**Challenge:** Gateway sees proxy IP (10.0.0.1) but needs original client IP (203.0.113.5) for:

- Rate limiting
- Authentication decisions
- Access control (allowlists/denylists)
- Geographic routing
- Audit logging

---

## Header Standards

### X-Forwarded-For (RFC 7239 equivalent)

```
X-Forwarded-For: <client>, <proxy1>, <proxy2>
```

**Semantics:**

- Leftmost = original client (potentially spoofed)
- Rightmost = immediate upstream
- Each proxy appends its view of the client IP

**Example chain:**

```
X-Forwarded-For: 203.0.113.5, 10.0.0.1, 192.168.1.100
                 └─────┘ └──────┘ └─────────┘
                  client  proxy1   proxy2
```

### X-Real-IP

```
X-Real-IP: <client>
```

**Semantics:**

- Single IP set by first trusted proxy
- Simpler than X-Forwarded-For
- Less common in multi-proxy chains

---

## Algorithm Design

### Core Function

```typescript
function resolveClientIp(params: {
  remoteAddr?: string; // Direct TCP connection IP (proxy IP)
  forwardedFor?: string; // X-Forwarded-For header value
  realIp?: string; // X-Real-IP header value
  trustedProxies?: string[]; // List of trusted proxy IPs/CIDRs
  allowRealIpFallback?: boolean; // Enable X-Real-IP fallback (default: false)
}): string | undefined;
```

### Decision Tree

```
┌─────────────────────────────────────────────────────────┐
│  Client IP Resolution Decision Tree                     │
└─────────────────────────────────────────────────────────┘
                      │
                      ▼
         ┌────────────────────────┐
         │ Normalize remoteAddr   │
         └───────────┬────────────┘
                     │
                     ▼
         ┌────────────────────────┐
         │ Is remoteAddr trusted? │
         └───────────┬────────────┘
               ┌─────┴─────┐
               │           │
              NO          YES
               │           │
               │           ▼
               │   ┌──────────────────┐
               │   │ Parse X-Forwarded│
               │   │ -For chain       │
               │   └────────┬─────────┘
               │            │
               │            ▼
               │   ┌──────────────────┐
               │   │ Walk right-to-   │
               │   │left, return first│
               │   │untrusted hop     │
               │   └────────┬─────────┘
               │            │
               │      ┌─────┴─────┐
               │      │           │
               │   Found       None
               │      │           │
               │      │           ▼
               │      │   ┌───────────────┐
               │      │   │ allowRealIp   │
               │      │   │ fallback?     │
               │      │   └───────┬───────┘
               │      │          │
               │      │     ┌────┴────┐
               │      │    YES       NO
               │      │     │         │
               │      │     ▼         ▼
               │      │  Parse     Return
               │      │  X-Real-IP undefined
               │      │     │
               │      │     ▼
               │      │  Return IP
               ▼      ▼
          Return  Return
          remote  undefined
          IP      (fail closed)
```

### Implementation

#### Step 1: Normalize Remote Address

```typescript
function normalizeIp(ip: string | undefined): string | undefined {
  return normalizeIpAddress(ip); // Handles IPv4-mapped IPv6, brackets, ports
}

// Examples:
// "::ffff:192.0.2.1" → "192.0.2.1"
// "[2001:db8::1]:8080" → "2001:db8::1"
// "192.0.2.1:12345" → "192.0.2.1"
```

#### Step 2: Check if Remote is Trusted

```typescript
function isTrustedProxyAddress(ip: string | undefined, trustedProxies?: string[]): boolean {
  const normalized = normalizeIp(ip);
  if (!normalized || !trustedProxies || trustedProxies.length === 0) {
    return false;
  }

  return trustedProxies.some((proxy) => {
    const candidate = proxy.trim();
    if (!candidate) return false;
    // CIDR matching: check if IP falls within proxy's network range
    return isIpInCidr(normalized, candidate);
  });
}
```

**CIDR Examples:**

- `"10.0.0.1"` - Single IP
- `"10.0.0.0/8"` - Entire Class A network
- `"192.168.1.0/24"` - /24 subnet
- `"::1"` - IPv6 loopback
- `"fd00::/8"` - IPv6 ULA range

#### Step 3: Parse X-Forwarded-For Chain

```typescript
function resolveForwardedClientIp(params: {
  forwardedFor?: string;
  trustedProxies?: string[];
}): string | undefined {
  const { forwardedFor, trustedProxies } = params;

  if (!trustedProxies?.length) {
    return undefined; // Can't validate chain without trusted proxies
  }

  // Parse comma-separated chain, filter invalid entries
  const forwardedChain: string[] = [];
  for (const entry of forwardedFor?.split(",") ?? []) {
    const normalized = parseIpLiteral(entry);
    if (normalized) {
      forwardedChain.push(normalized);
    }
  }

  if (forwardedChain.length === 0) {
    return undefined;
  }

  // Walk right-to-left, return first untrusted hop
  for (let index = forwardedChain.length - 1; index >= 0; index -= 1) {
    const hop = forwardedChain[index];
    if (!isTrustedProxyAddress(hop, trustedProxies)) {
      return hop; // Found client IP
    }
  }

  return undefined; // All hops are trusted (fail closed)
}
```

#### Step 4: Complete Resolution

```typescript
export function resolveClientIp(params: {
  remoteAddr?: string;
  forwardedFor?: string;
  realIp?: string;
  trustedProxies?: string[];
  allowRealIpFallback?: boolean;
}): string | undefined {
  const remote = normalizeIp(params.remoteAddr);
  if (!remote) {
    return undefined;
  }

  // Case 1: Direct connection (no proxy)
  if (!isTrustedProxyAddress(remote, params.trustedProxies)) {
    return remote; // Use direct IP
  }

  // Case 2: Behind trusted proxy - parse headers
  const forwardedIp = resolveForwardedClientIp({
    forwardedFor: params.forwardedFor,
    trustedProxies: params.trustedProxies,
  });

  if (forwardedIp) {
    return forwardedIp; // Found in X-Forwarded-For
  }

  // Case 3: X-Real-IP fallback (if enabled)
  if (params.allowRealIpFallback) {
    return parseRealIp(params.realIp);
  }

  // Fail closed - don't trust proxy's own IP as client
  return undefined;
}
```

---

## Security Model

### Fail-Closed Design

**Principle:** When traffic comes from a trusted proxy but client-origin headers are missing or invalid, return `undefined` rather than falling back to the proxy's IP.

**Rationale:** Using the proxy's IP as a fallback can:

- Accidentally treat unrelated requests as local/trusted
- Bypass rate limiting (all requests appear from same proxy IP)
- Defeat geographic restrictions

```typescript
// Example: Fail-closed behavior
const trustedProxies = ["10.0.0.1"];
const result = resolveClientIp({
  remoteAddr: "10.0.0.1", // Trusted proxy
  forwardedFor: "", // Missing header
  trustedProxies,
});
// Returns: undefined (NOT "10.0.0.1")
```

### Trusted Proxy Configuration

**Required:** Explicitly configure `gateway.trustedProxies` when running behind a proxy.

```json5
{
  gateway: {
    trustedProxies: [
      "10.0.0.1", // Single IP
      "192.168.0.0/16", // Private network
      "172.16.0.0/12",
      "fd00::/8", // IPv6 ULA
      "fe80::/10", // IPv6 link-local
      "127.0.0.1", // Loopback
      "::1",
    ],
  },
}
```

### Proxy Chain Validation

**Attack Scenario:** Malicious client spoofs X-Forwarded-For header

```
┌─────────────┐     ┌─────────────┐     ┌──────────────────┐
│   Client    │────►│  Proxy      │────►│  SophiaClaw      │
│  Attacker   │     │  10.0.0.1   │     │  Gateway         │
└─────────────┘     └─────────────┘     └──────────────────┘
       │
       │ Sends: X-Forwarded-For: 8.8.8.8
       │ (claims to be Google DNS)
       ▼
```

**Defense:** Right-to-left walk ensures only untrusted hops are considered:

```typescript
// Trusted proxies: ["10.0.0.1"]
// X-Forwarded-For: "8.8.8.8, 10.0.0.1"
//                └──┘ └──────┘
//              client  proxy

// Algorithm walks right-to-left:
// Hop 1: "10.0.0.1" → trusted, skip
// Hop 0: "8.8.8.8" → untrusted, return as client IP

// Result: "8.8.8.8" (correct, even if spoofed)
// Gateway still applies other security checks (auth, rate limits)
```

---

## IPv6 Handling

### Normalization Cases

```typescript
normalizeIpAddress("::ffff:192.0.2.1"); // → "192.0.2.1" (IPv4-mapped)
normalizeIpAddress("[2001:db8::1]"); // → "2001:db8::1" (bracketed)
normalizeIpAddress("[2001:db8::1]:8080"); // → "2001:db8::1" (with port)
normalizeIpAddress("::1"); // → "::1" (loopback)
normalizeIpAddress("fe80::1"); // → "fe80::1" (link-local)
```

### IPv4-Mapped IPv6 Addresses

**Problem:** Some systems represent IPv4 addresses as IPv6:

```
IPv4:        192.0.2.1
Mapped IPv6: ::ffff:192.0.2.1
```

**Solution:** Normalize to canonical IPv4 form for consistent matching:

```typescript
function normalizeIpAddress(ip: string | undefined): string | undefined {
  if (!ip) return undefined;

  const trimmed = ip.trim();

  // Handle IPv4-mapped IPv6 addresses
  const ipv4Match = trimmed.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
  if (ipv4Match) {
    return ipv4Match[1].toLowerCase(); // Return IPv4 form
  }

  // Strip brackets from IPv6
  if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
    return trimmed.slice(1, -1);
  }

  // Strip port from IPv4
  const lastColon = trimmed.lastIndexOf(":");
  if (lastColon > -1 && trimmed.includes(".") && trimmed.indexOf(":") === lastColon) {
    const candidate = trimmed.slice(0, lastColon);
    if (isIPv4(candidate)) {
      return candidate;
    }
  }

  return trimmed.toLowerCase();
}
```

### CIDR Matching for IPv6

```typescript
function isIpInCidr(ip: string, cidr: string): boolean {
  // Parse CIDR notation
  const [network, prefixStr] = cidr.split("/");
  const prefix = parseInt(prefixStr, 10) || 128;

  // Convert IPs to binary for comparison
  const ipBits = ipToBinary(ip);
  const networkBits = ipToBinary(network);

  // Compare first 'prefix' bits
  const mask = createMask(prefix);
  return (ipBits & mask) === (networkBits & mask);
}
```

---

## Integration Points

### Rate Limiting

```typescript
// In auth-rate-limit.ts
export function normalizeRateLimitClientIp(ip: string | undefined): string {
  return resolveClientIp({ remoteAddr: ip }) ?? "unknown";
}

// Ensures rate limits apply to true client, not proxy IP
```

### Authentication

```typescript
// In auth.ts
const clientIp = resolveClientIp({
  remoteAddr: request.socket.remoteAddress,
  forwardedFor: request.headers["x-forwarded-for"],
  realIp: request.headers["x-real-ip"],
  trustedProxies: config.gateway.trustedProxies,
  allowRealIpFallback: false,
});

// Use clientIp for allowlist/denylist checks
if (config.auth.allowlist && !config.auth.allowlist.includes(clientIp)) {
  return { ok: false, reason: "ip_not_allowed" };
}
```

### Audit Logging

```typescript
log.info("Authentication attempt", {
  clientIp: resolveClientIp({ ... }),
  userAgent: request.headers["user-agent"],
  timestamp: Date.now(),
});
```

---

## Testing Strategies

### Test Cases

```typescript
describe("resolveClientIp", () => {
  const testCases: Array<{
    name: string;
    remoteAddr: string;
    forwardedFor?: string;
    trustedProxies?: string[];
    expected?: string;
  }> = [
    {
      name: "returns remote IP when remote is not trusted proxy",
      remoteAddr: "203.0.113.5",
      trustedProxies: ["10.0.0.1"],
      expected: "203.0.113.5",
    },
    {
      name: "uses right-most untrusted X-Forwarded-For hop",
      remoteAddr: "10.0.0.1",
      forwardedFor: "203.0.113.5, 10.0.0.2, 10.0.0.1",
      trustedProxies: ["10.0.0.1", "10.0.0.2"],
      expected: "10.0.0.2",
    },
    {
      name: "fails closed when all X-Forwarded-For hops are trusted",
      remoteAddr: "10.0.0.1",
      forwardedFor: "10.0.0.1, 10.0.0.2",
      trustedProxies: ["10.0.0.1", "10.0.0.2"],
      expected: undefined,
    },
    {
      name: "ignores invalid X-Forwarded-For entries",
      remoteAddr: "10.0.0.1",
      forwardedFor: "invalid, 203.0.113.5, not-an-ip",
      trustedProxies: ["10.0.0.1"],
      expected: "203.0.113.5",
    },
  ];

  for (const tc of testCases) {
    it(tc.name, () => {
      const result = resolveClientIp({
        remoteAddr: tc.remoteAddr,
        forwardedFor: tc.forwardedFor,
        trustedProxies: tc.trustedProxies,
      });
      expect(result).toBe(tc.expected);
    });
  }
});
```

---

## Edge Cases

### Empty or Malformed Headers

```typescript
// Empty X-Forwarded-For
resolveClientIp({
  remoteAddr: "10.0.0.1",
  forwardedFor: "",
  trustedProxies: ["10.0.0.1"],
});
// Returns: undefined (fail closed)

// Malformed entries mixed with valid
resolveClientIp({
  remoteAddr: "10.0.0.1",
  forwardedFor: "invalid, 203.0.113.5, , another-invalid",
  trustedProxies: ["10.0.0.1"],
});
// Returns: "203.0.113.5" (ignores invalid)
```

### Loopback Exemption

```typescript
// Auto-exempt loopback from rate limiting
isLoopbackAddress("127.0.0.1"); // true
isLoopbackAddress("::1"); // true
isLoopbackAddress("::ffff:127.0.0.1"); // true (mapped)
```

### Private Network Detection

```typescript
isPrivateOrLoopbackAddress("10.0.0.1"); // true (RFC1918)
isPrivateOrLoopbackAddress("172.16.5.3"); // true (RFC1918)
isPrivateOrLoopbackAddress("192.168.1.1"); // true (RFC1918)
isPrivateOrLoopbackAddress("100.64.0.1"); // true (CGNAT)
isPrivateOrLoopbackAddress("fe80::1"); // true (link-local)
isPrivateOrLoopbackAddress("fd00::1"); // true (ULA)
isPrivateOrLoopbackAddress("203.0.113.5"); // false (public)
```

---

## Performance Considerations

### Time Complexity

- **Normalization:** O(1) - fixed string operations
- **CIDR matching:** O(n) where n = number of trusted proxies
- **Chain walking:** O(m) where m = length of X-Forwarded-For chain

### Optimization Strategies

```typescript
// Pre-compute trusted proxy set for O(1) lookup
const trustedSet = new Set(trustedProxies); // For exact IP matches

// For CIDR ranges, group by prefix length
const cidrByPrefix = new Map<number, string[]>();
// Allows early exit if no CIDRs match prefix length
```

### Memory Footprint

- Minimal: stores only parsed IP strings
- No caching needed (computation is fast)
- Scales linearly with trusted proxy count

---

## Related Documentation

- [Gateway Authentication](/gateway/authentication)
- [Trusted Proxy Auth](/gateway/trusted-proxy-auth)
- [Rate Limiter](/algorithms/gateway-network/rate-limiter)
- [Gateway Configuration](/gateway/configuration)
