
# IP Address Restriction Policy

Restrict which clients can reach a route based on their IP address. Configure an
allow list to expose an endpoint only to a set of known networks, a deny list to
shut out abusive traffic, or both. Entries can be individual IP addresses or
CIDR ranges, in IPv4 or IPv6.

With this policy, you'll benefit from:

- **Private APIs on a Public Gateway**: Limit admin, partner, or internal
  endpoints to your office, VPN, or data center ranges
- **Immediate Blocking**: Drop traffic from a bad actor's address or subnet
  without a deploy of your backend
- **Per-Route Control**: Apply different lists to different routes, so a public
  endpoint and a restricted one can live in the same API
- **IPv4 and IPv6**: Single addresses and CIDR ranges in both families, with
  IPv4-mapped IPv6 addresses handled automatically
- **Fail-Closed Defaults**: Requests whose address cannot be determined are
  rejected when an allow list is set

## Configuration

The configuration shows how to configure the policy in the 'policies.json' document.

```json title="config/policies.json"
{
  "name": "my-ip-address-restriction-inbound-policy",
  "policyType": "ip-address-restriction-inbound",
  "handler": {
    "export": "IpAddressRestrictionInboundPolicy",
    "module": "$import(@zuplo/runtime)",
    "options": {
      "allow": ["203.0.113.42", "198.51.100.0/24"],
      "deny": ["203.0.113.99", "192.0.2.0/24"]
    }
  }
}
```

### Policy Configuration

- `name` <code className="text-green-600">&lt;string&gt;</code> - The name of your policy instance. This is used as a reference in your routes.
- `policyType` <code className="text-green-600">&lt;string&gt;</code> - The identifier of the policy. This is used by the Zuplo UI. Value should be `ip-address-restriction-inbound`.
- `handler.export` <code className="text-green-600">&lt;string&gt;</code> - The name of the exported type. Value should be `IpAddressRestrictionInboundPolicy`.
- `handler.module` <code className="text-green-600">&lt;string&gt;</code> - The module containing the policy. Value should be `$import(@zuplo/runtime)`.
- `handler.options` <code className="text-green-600">&lt;object&gt;</code> - The options for this policy. [See Policy Options](#policy-options) below.

### Policy Options

The options for this policy are specified below. All properties are optional unless specifically marked as required.

- `allow` <code className="text-green-600">&lt;string[]&gt;</code> - The IP addresses and CIDR ranges that are allowed to call the route. When set, requests from every other address are rejected with a `403 Forbidden`.
- `deny` <code className="text-green-600">&lt;string[]&gt;</code> - The IP addresses and CIDR ranges that are rejected with a `403 Forbidden`. Evaluated before `allow`, so an address matching both lists is rejected.
- `clientIpHeader` <code className="text-green-600">&lt;string&gt;</code> - Read the client IP address from this header instead of the standard forwarding headers (`x-real-ip`, `true-client-ip`, `cf-connecting-ip`, then the first entry of `x-forwarded-for`). Only set this when a proxy in front of the gateway reports the client address in a non-standard header.
- `allowUnknownIpAddress` <code className="text-green-600">&lt;boolean&gt;</code> - Allow requests whose client IP address cannot be determined. By default these requests are rejected when an `allow` list is set, because an unknown address cannot be on it. Defaults to `false`.

## Using the Policy

This policy compares the client's IP address against an allow list, a deny list,
or both, and rejects non-matching requests with a `403 Forbidden`. Entries are
individual IP addresses or CIDR ranges, in IPv4 or IPv6.

### How It Works

For each request the policy:

1. Determines the client IP address — the same one reported to your code as
   `context.incomingRequestProperties.ip` — or reads it from `clientIpHeader`
   when that option is set
2. Rejects the request if the address matches any `deny` entry
3. Rejects the request if an `allow` list is set and the address matches none of
   its entries
4. Otherwise passes the request to the next policy or the handler

`deny` is evaluated first, so an address covered by both lists is rejected.

At least one of `allow` or `deny` must be set — a policy with neither would let
every request through, which is never what was intended.

### Policy Configuration

Allow only two networks and a single address:

```json
{
  "name": "ip-address-restriction",
  "policyType": "ip-address-restriction-inbound",
  "handler": {
    "export": "IpAddressRestrictionInboundPolicy",
    "module": "$import(@zuplo/runtime)",
    "options": {
      "allow": ["198.51.100.0/24", "2001:db8:1234::/48", "203.0.113.42"]
    }
  }
}
```

Block a single abusive network while leaving the route otherwise public:

```json
{
  "name": "ip-address-restriction",
  "policyType": "ip-address-restriction-inbound",
  "handler": {
    "export": "IpAddressRestrictionInboundPolicy",
    "module": "$import(@zuplo/runtime)",
    "options": {
      "deny": ["192.0.2.0/24"]
    }
  }
}
```

### Writing Entries

- An entry without a prefix is a single address: `203.0.113.42` is the same as
  `203.0.113.42/32`, and `2001:db8::1` the same as `2001:db8::1/128`.
- Host bits beyond the prefix are ignored, so `10.1.2.3/8` and `10.0.0.0/8` mean
  the same range.
- IPv4-mapped IPv6 addresses are matched as the IPv4 address they map to. A
  client that a dual-stack proxy reports as `::ffff:198.51.100.7` matches an
  `198.51.100.0/24` entry.
- An IPv4 address never matches an IPv6 range, and vice versa. To cover a client
  reachable over both, list a range for each family.
- Addresses with leading zeros in an octet (`010.0.0.1`) are rejected, because
  different tools read them as different addresses.

Entries are validated when the policy is created, so a malformed address or
prefix fails your deployment rather than rejecting live traffic. Each list can
also be sourced from environment variables per entry:

```json
{
  "options": {
    "allow": ["$env(OFFICE_CIDR)", "$env(VPN_CIDR)"]
  }
}
```

### Usage Example

Restrict an admin route while leaving the rest of the API public:

```json
{
  "paths": {
    "/v1/admin/users": {
      "get": {
        "x-zuplo-route": {
          "policies": {
            "inbound": ["ip-address-restriction", "api-key-auth"]
          },
          "handler": {
            "export": "urlForwardHandler",
            "module": "$import(@zuplo/runtime)",
            "options": {
              "baseUrl": "https://api.internal.example.com"
            }
          }
        }
      }
    }
  }
}
```

Put this policy first in the inbound list so restricted addresses are rejected
before any work — authentication, rate limiting, upstream token fetches — is
done on their behalf.

### Where the Default Address Comes From

By default the policy uses the address the gateway itself resolved — the same
value your code sees as `context.incomingRequestProperties.ip`. That comes from
exactly **one** header, chosen by the platform running the gateway
(`true-client-ip` on Cloudflare, `x-real-ip` elsewhere), and it is resolved from
the request as it originally arrived.

Two consequences worth knowing:

- **There is no fallback to a second header.** If the trusted header is absent,
  the address is reported as unknown rather than being guessed from
  `x-forwarded-for` or another header. Every available fallback is one a caller
  can write, so a fallback would let anyone satisfy an allow list by sending a
  header.
- **Header stripping does not affect it.** The gateway removes `x-real-ip` and
  the `cf-*` headers from the request before policies run (from compatibility
  date **2024-01-15**), but the default path reads the original request, so this
  makes no difference to it. It _does_ affect `clientIpHeader` — see below.

During local development there is no edge to set the header and the caller
really is the loopback interface, so the address is reported as `127.0.0.1`. An
allow list that does not cover `127.0.0.1` will reject your local requests — add
it to the list to test locally:

```json
{
  "options": {
    "allow": ["198.51.100.0/24", "127.0.0.1", "::1"]
  }
}
```

Note that `allowUnknownIpAddress` does **not** help here. Locally the address is
known — it is `127.0.0.1` — and that option only applies when no address could
be determined at all.

### Requests With No Client IP Address

When no client IP address can be determined, the request is rejected if an
`allow` list is set, since an unknown address cannot be on it. With only a
`deny` list the request continues, because there is nothing for it to match.

Set `allowUnknownIpAddress` to `true` to let these requests through even with an
`allow` list. This is most useful when you run your own proxy and cannot
guarantee it sets the trusted header on every request.

### Custom Client IP Header

If a proxy in front of the gateway reports the client address in a header other
than the standard ones, name it with `clientIpHeader`:

```json
{
  "options": {
    "allow": ["198.51.100.0/24"],
    "clientIpHeader": "x-client-ip"
  }
}
```

Only the named header is read when this is set. Prefer a header your proxy sets
to a single address.

Unlike the default path, this option reads the request as policies see it, after
the gateway has stripped `x-real-ip` and the `cf-*` headers (from compatibility
date **2024-01-15**). Naming one of those here resolves nothing, and with an
`allow` list that rejects every request. Use a dedicated header your proxy sets,
such as `x-client-ip`.

If the header holds a comma-separated list, the **last** entry is used, because
proxies append and so the nearest proxy's entry is the rightmost one —
everything to its left is whatever the caller sent. For a single-value header
this makes no difference. This assumes one appending proxy in front of the
gateway; if you have more than one, use a dedicated single-value header, since
the trustworthy entry is then further left and its position depends on how many
hops you control.

### Security Considerations

:::warning

This policy is only as trustworthy as the header it reads the client address
from. A header is a security control **only** if the proxy in front of your
gateway replaces any client-supplied value rather than appending to it.

The default path is not configurable for this reason: it reads a single header
designated by the platform running the gateway, and never falls back to another
one. On Zuplo's managed edge that header is set by the edge on every request and
a caller cannot override it.

Two specifics worth knowing if you self-host or run a CDN in front of Zuplo:

- **`true-client-ip` is spoofable unless your own edge sets it.** Cloudflare
  documents this directly: in a stacked-CDN setup, if you do not add the header
  yourself, "its value can be spoofed to any value".
- **`x-forwarded-for` is a list, and the untrustworthy end comes first.** When a
  request already carries an `X-Forwarded-For` header,
  [Cloudflare appends to it](https://developers.cloudflare.com/fundamentals/reference/http-headers/#x-forwarded-for)
  rather than replacing it, and most proxies behave the same way. The entry your
  own proxy added is therefore at the **end** of the list, while the entries at
  the front are whatever the caller chose to send. The policy never reads this
  header on the default path — but if you point `clientIpHeader` at it, this is
  why only the last entry is used.

If a proxy you control reports the client address in a dedicated header that it
always overwrites, name that header with `clientIpHeader`. That is the
configuration to prefer whenever you have it, because it removes the ambiguity
entirely.

:::

- Rejected requests get a `403 Forbidden` with no detail about the address seen
  or the rule matched. The address and the matching entry are written to the
  request log at debug level instead, so an unauthorized caller is not told what
  the gateway sees or that it is filtering by address at all.
- An IP address identifies a network path, not a caller. Combine this policy
  with an authentication policy rather than using it as your only control —
  addresses within an allowed range can be shared, reassigned, or spoofed
  upstream of your gateway.
- Prefer the narrowest ranges that work. A `/8` allow entry covers 16 million
  addresses.

Read more about [how policies work](/articles/policies)
