zuplo 6.74.14 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/articles/api-key-administration.mdx +2 -2
- package/docs/articles/api-key-authentication.mdx +6 -3
- package/docs/articles/api-key-buckets.mdx +19 -5
- package/docs/articles/api-key-consumer-bucket-portal-ui.mdx +20 -15
- package/docs/articles/api-key-self-serve-integration.mdx +2 -2
- package/docs/articles/monetization/api-access.mdx +1 -1
- package/docs/articles/monetization/going-to-production.mdx +1 -1
- package/docs/cli/test.mdx +20 -0
- package/docs/policies/_index.md +3 -1
- package/docs/policies/ai-gateway-metering-v2-inbound/doc.md +20 -0
- package/docs/policies/ai-gateway-metering-v2-inbound/schema.json +1 -1
- package/docs/policies/cdn-cache-control-outbound/doc.md +405 -0
- package/docs/policies/cdn-cache-control-outbound/intro.md +26 -0
- package/docs/policies/cdn-cache-control-outbound/schema.json +275 -0
- package/docs/policies/ip-address-restriction-inbound/doc.md +234 -0
- package/docs/policies/ip-address-restriction-inbound/intro.md +17 -0
- package/docs/policies/ip-address-restriction-inbound/schema.json +97 -0
- package/package.json +6 -6
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft-07/schema",
|
|
3
|
+
"$id": "https://cdn.zuplo.com/policies/runtime/schemas/cdn-cache-control-outbound.json",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"title": "CDN Cache Control",
|
|
6
|
+
"isDeprecated": false,
|
|
7
|
+
"isPaidAddOn": false,
|
|
8
|
+
"isEnterprise": false,
|
|
9
|
+
"isInternal": false,
|
|
10
|
+
"isBeta": false,
|
|
11
|
+
"isHidden": false,
|
|
12
|
+
"requiresAI": false,
|
|
13
|
+
"products": ["api-gateway"],
|
|
14
|
+
"description": "Sets the response headers that tell a CDN how long it may cache a response and which purge tags it belongs to, separately from what the browser is told.",
|
|
15
|
+
"deprecatedMessage": "",
|
|
16
|
+
"required": ["handler"],
|
|
17
|
+
"properties": {
|
|
18
|
+
"handler": {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"default": {},
|
|
21
|
+
"required": ["export", "module", "options"],
|
|
22
|
+
"properties": {
|
|
23
|
+
"export": {
|
|
24
|
+
"const": "CdnCacheControlOutboundPolicy",
|
|
25
|
+
"description": "The name of the exported type"
|
|
26
|
+
},
|
|
27
|
+
"module": {
|
|
28
|
+
"const": "$import(@zuplo/runtime)",
|
|
29
|
+
"description": "The module containing the policy"
|
|
30
|
+
},
|
|
31
|
+
"options": {
|
|
32
|
+
"title": "CdnCacheControlOutboundPolicyOptions",
|
|
33
|
+
"type": "object",
|
|
34
|
+
"description": "The options for this policy.",
|
|
35
|
+
"additionalProperties": false,
|
|
36
|
+
"required": ["cdn"],
|
|
37
|
+
"properties": {
|
|
38
|
+
"cdn": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"title": "CdnCacheControlVendor",
|
|
41
|
+
"enum": [
|
|
42
|
+
"akamai",
|
|
43
|
+
"fastly",
|
|
44
|
+
"cloudflare",
|
|
45
|
+
"cloudfront",
|
|
46
|
+
"generic"
|
|
47
|
+
],
|
|
48
|
+
"examples": ["akamai"],
|
|
49
|
+
"description": "The CDN in front of this gateway. Required, with no default — each CDN reads a different header for shared-cache TTL and purge tags, and a wrong guess fails silently. Use `generic` for an RFC 9213 CDN that is not listed."
|
|
50
|
+
},
|
|
51
|
+
"strategy": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"title": "CdnCacheControlStrategy",
|
|
54
|
+
"default": "targeted",
|
|
55
|
+
"enum": ["targeted", "s-maxage"],
|
|
56
|
+
"x-advanced": true,
|
|
57
|
+
"description": "How the edge TTL is carried. `targeted` uses the CDN's own header (`Edge-Control`, `Surrogate-Control`, `CDN-Cache-Control`) and is the only way to cache at the edge without also caching in the browser. `s-maxage` folds the edge TTL into `Cache-Control` instead, for a property that honors origin `Cache-Control` but not the targeted header. Defaults to `targeted`, except on a CDN that reads no targeted header — CloudFront defaults to `s-maxage`, which is the only thing it supports."
|
|
58
|
+
},
|
|
59
|
+
"edge": {
|
|
60
|
+
"type": "object",
|
|
61
|
+
"title": "CdnCacheControlEdgeOptions",
|
|
62
|
+
"additionalProperties": false,
|
|
63
|
+
"required": ["maxAge"],
|
|
64
|
+
"description": "How long the CDN may serve this response. These directives are sent only to the CDN, never to the browser (unless `strategy` is `s-maxage`).",
|
|
65
|
+
"properties": {
|
|
66
|
+
"maxAge": {
|
|
67
|
+
"type": "integer",
|
|
68
|
+
"minimum": 0,
|
|
69
|
+
"examples": [1800],
|
|
70
|
+
"description": "Seconds the CDN may serve this response without revalidating."
|
|
71
|
+
},
|
|
72
|
+
"staleWhileRevalidate": {
|
|
73
|
+
"type": "integer",
|
|
74
|
+
"minimum": 0,
|
|
75
|
+
"examples": [600],
|
|
76
|
+
"description": "Seconds the CDN may keep serving a stale response while it revalidates in the background. Not expressible in Akamai's `Edge-Control` — configure it in Property Manager, or use `strategy: s-maxage`."
|
|
77
|
+
},
|
|
78
|
+
"staleIfError": {
|
|
79
|
+
"type": "integer",
|
|
80
|
+
"minimum": 0,
|
|
81
|
+
"examples": [86400],
|
|
82
|
+
"description": "Seconds the CDN may serve a stale response when the gateway returns an error. Not expressible in Akamai's `Edge-Control` — configure it in Property Manager, or use `strategy: s-maxage`."
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"client": {
|
|
87
|
+
"type": "object",
|
|
88
|
+
"title": "CdnCacheControlClientOptions",
|
|
89
|
+
"additionalProperties": false,
|
|
90
|
+
"required": [],
|
|
91
|
+
"description": "The `Cache-Control` sent to the browser or app. Separate from `edge` so the CDN can absorb load without clients holding stale data.",
|
|
92
|
+
"properties": {
|
|
93
|
+
"maxAge": {
|
|
94
|
+
"type": "integer",
|
|
95
|
+
"minimum": 0,
|
|
96
|
+
"examples": [60],
|
|
97
|
+
"description": "Seconds the client may reuse this response. Omit to send no `max-age`."
|
|
98
|
+
},
|
|
99
|
+
"visibility": {
|
|
100
|
+
"type": "string",
|
|
101
|
+
"title": "CdnCacheControlClientVisibility",
|
|
102
|
+
"enum": ["public", "private", "no-store"],
|
|
103
|
+
"examples": ["public"],
|
|
104
|
+
"description": "`public` allows any cache to store the response, `private` restricts it to the client's own cache, `no-store` forbids client caching entirely. With `strategy: targeted` a `private`/`no-store` client policy still permits edge caching, because the CDN reads its own header."
|
|
105
|
+
},
|
|
106
|
+
"noCache": {
|
|
107
|
+
"type": "boolean",
|
|
108
|
+
"default": false,
|
|
109
|
+
"x-advanced": true,
|
|
110
|
+
"description": "Adds `no-cache`, so the client must revalidate before reusing the response. Distinct from `visibility: no-store`, which forbids storing it at all."
|
|
111
|
+
},
|
|
112
|
+
"mustRevalidate": {
|
|
113
|
+
"type": "boolean",
|
|
114
|
+
"default": false,
|
|
115
|
+
"x-advanced": true,
|
|
116
|
+
"description": "Adds `must-revalidate`, so the client may not serve the response once stale."
|
|
117
|
+
},
|
|
118
|
+
"mode": {
|
|
119
|
+
"type": "string",
|
|
120
|
+
"title": "CdnCacheControlClientMode",
|
|
121
|
+
"default": "strictest",
|
|
122
|
+
"enum": ["strictest", "replace", "preserve"],
|
|
123
|
+
"description": "How to combine the configured client policy with a `Cache-Control` the upstream already sent. `strictest` (default) emits the more conservative of the two — the lower `max-age` and the union of restrictive directives. `replace` overwrites whatever upstream sent. `preserve` leaves the upstream header untouched and only adds the CDN headers."
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
"tags": {
|
|
128
|
+
"type": "array",
|
|
129
|
+
"items": {
|
|
130
|
+
"type": "string"
|
|
131
|
+
},
|
|
132
|
+
"default": [],
|
|
133
|
+
"examples": [["catalog", "cities"]],
|
|
134
|
+
"description": "Purge tags, rendered in the target CDN's format: comma-joined `Edge-Cache-Tag` (Akamai), space-joined `Surrogate-Key` (Fastly), comma-joined `Cache-Tag` (Cloudflare). CloudFront has no tag purge and rejects this option. Tags are validated against the CDN's length and character limits, because an over-long tag header is silently truncated rather than rejected."
|
|
135
|
+
},
|
|
136
|
+
"vary": {
|
|
137
|
+
"type": "array",
|
|
138
|
+
"items": {
|
|
139
|
+
"type": "string"
|
|
140
|
+
},
|
|
141
|
+
"default": [],
|
|
142
|
+
"examples": [["accept-language"]],
|
|
143
|
+
"x-advanced": true,
|
|
144
|
+
"description": "Request headers that vary this response, unioned with any `Vary` the upstream already sent and emitted as `Vary`. The union is deliberate: replacing an upstream `Vary: Authorization` would stop a shared cache keying on it and let it serve one user's response to another. Off by default: Akamai skips caching any response carrying `Vary` until Cache ID Modification is configured, so enabling this can silently disable edge caching entirely."
|
|
145
|
+
},
|
|
146
|
+
"respectUpstream": {
|
|
147
|
+
"type": "boolean",
|
|
148
|
+
"default": true,
|
|
149
|
+
"x-advanced": true,
|
|
150
|
+
"description": "When true (default), an upstream `Cache-Control` of `no-store`/`no-cache`/`private`, or a `Set-Cookie` on the response, suppresses all edge headers and purge tags. This keeps the application's ability to mark a response uncacheable. Set to false to override it — on Akamai this emits `Edge-Control: !no-store`."
|
|
151
|
+
},
|
|
152
|
+
"cacheConfig": {
|
|
153
|
+
"type": "object",
|
|
154
|
+
"title": "CdnCacheControlConfigFunction",
|
|
155
|
+
"additionalProperties": false,
|
|
156
|
+
"x-advanced": true,
|
|
157
|
+
"required": ["export", "module"],
|
|
158
|
+
"description": "A function that overrides the cache configuration per response, for rules that cannot be expressed statically (per-entity purge tags, a TTL that depends on a response header, or skipping an empty result set). Returning nothing (`undefined` or `null`) falls back to the `edge`, `client` and `tags` options, so those stay the enforced default and the function only handles exceptions. A returned config \\*\\*replaces\\*\\* those options wholesale rather than being merged directive by directive, so include every directive the response needs. Return `{ cache: false }` to skip caching a response entirely — note that also sends `Cache-Control: no-store` to the client unless `client.mode` is `preserve`. The function receives the live response: it must not consume the body, and should call `response.clone()` if it needs to read it.",
|
|
159
|
+
"properties": {
|
|
160
|
+
"export": {
|
|
161
|
+
"type": "string",
|
|
162
|
+
"default": "default",
|
|
163
|
+
"description": "Specifies the export that holds your cache config function, e.g. `default`, `cdnCacheConfig`.",
|
|
164
|
+
"examples": ["default", "cdnCacheConfig"]
|
|
165
|
+
},
|
|
166
|
+
"module": {
|
|
167
|
+
"type": "string",
|
|
168
|
+
"default": "",
|
|
169
|
+
"description": "Specifies the module that holds your cache config function, in the format `$import(./modules/my-module)`.",
|
|
170
|
+
"examples": ["$import(./modules/my-module)"]
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
"if": {
|
|
176
|
+
"required": ["cdn"],
|
|
177
|
+
"properties": {
|
|
178
|
+
"cdn": {
|
|
179
|
+
"const": "cloudfront"
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
"then": {
|
|
184
|
+
"properties": {
|
|
185
|
+
"strategy": {
|
|
186
|
+
"const": "s-maxage"
|
|
187
|
+
},
|
|
188
|
+
"tags": {
|
|
189
|
+
"maxItems": 0
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
"else": {
|
|
194
|
+
"allOf": [
|
|
195
|
+
{
|
|
196
|
+
"if": {
|
|
197
|
+
"required": ["cdn"],
|
|
198
|
+
"properties": {
|
|
199
|
+
"cdn": {
|
|
200
|
+
"const": "generic"
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
"then": {
|
|
205
|
+
"properties": {
|
|
206
|
+
"tags": {
|
|
207
|
+
"maxItems": 0
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
"if": {
|
|
214
|
+
"required": ["cdn"],
|
|
215
|
+
"properties": {
|
|
216
|
+
"cdn": {
|
|
217
|
+
"const": "akamai"
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
"not": {
|
|
221
|
+
"required": ["strategy"],
|
|
222
|
+
"properties": {
|
|
223
|
+
"strategy": {
|
|
224
|
+
"const": "s-maxage"
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
"then": {
|
|
230
|
+
"properties": {
|
|
231
|
+
"edge": {
|
|
232
|
+
"properties": {
|
|
233
|
+
"staleWhileRevalidate": false,
|
|
234
|
+
"staleIfError": false
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
]
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
"examples": [
|
|
245
|
+
{
|
|
246
|
+
"export": "CdnCacheControlOutboundPolicy",
|
|
247
|
+
"module": "$import(@zuplo/runtime)",
|
|
248
|
+
"options": {
|
|
249
|
+
"cacheConfig": {
|
|
250
|
+
"export": "default",
|
|
251
|
+
"module": "$import(./modules/my-module)"
|
|
252
|
+
},
|
|
253
|
+
"cdn": "akamai",
|
|
254
|
+
"client": {
|
|
255
|
+
"maxAge": 60,
|
|
256
|
+
"mode": "strictest",
|
|
257
|
+
"mustRevalidate": false,
|
|
258
|
+
"noCache": false,
|
|
259
|
+
"visibility": "public"
|
|
260
|
+
},
|
|
261
|
+
"edge": {
|
|
262
|
+
"maxAge": 1800,
|
|
263
|
+
"staleIfError": 86400,
|
|
264
|
+
"staleWhileRevalidate": 600
|
|
265
|
+
},
|
|
266
|
+
"respectUpstream": true,
|
|
267
|
+
"strategy": "targeted",
|
|
268
|
+
"tags": ["catalog", "cities"],
|
|
269
|
+
"vary": ["accept-language"]
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
]
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
This policy compares the client's IP address against an allow list, a deny list,
|
|
2
|
+
or both, and rejects non-matching requests with a `403 Forbidden`. Entries are
|
|
3
|
+
individual IP addresses or CIDR ranges, in IPv4 or IPv6.
|
|
4
|
+
|
|
5
|
+
### How It Works
|
|
6
|
+
|
|
7
|
+
For each request the policy:
|
|
8
|
+
|
|
9
|
+
1. Determines the client IP address — the same one reported to your code as
|
|
10
|
+
`context.incomingRequestProperties.ip` — or reads it from `clientIpHeader`
|
|
11
|
+
when that option is set
|
|
12
|
+
2. Rejects the request if the address matches any `deny` entry
|
|
13
|
+
3. Rejects the request if an `allow` list is set and the address matches none of
|
|
14
|
+
its entries
|
|
15
|
+
4. Otherwise passes the request to the next policy or the handler
|
|
16
|
+
|
|
17
|
+
`deny` is evaluated first, so an address covered by both lists is rejected.
|
|
18
|
+
|
|
19
|
+
At least one of `allow` or `deny` must be set — a policy with neither would let
|
|
20
|
+
every request through, which is never what was intended.
|
|
21
|
+
|
|
22
|
+
### Policy Configuration
|
|
23
|
+
|
|
24
|
+
Allow only two networks and a single address:
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"name": "ip-address-restriction",
|
|
29
|
+
"policyType": "ip-address-restriction-inbound",
|
|
30
|
+
"handler": {
|
|
31
|
+
"export": "IpAddressRestrictionInboundPolicy",
|
|
32
|
+
"module": "$import(@zuplo/runtime)",
|
|
33
|
+
"options": {
|
|
34
|
+
"allow": ["198.51.100.0/24", "2001:db8:1234::/48", "203.0.113.42"]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Block a single abusive network while leaving the route otherwise public:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"name": "ip-address-restriction",
|
|
45
|
+
"policyType": "ip-address-restriction-inbound",
|
|
46
|
+
"handler": {
|
|
47
|
+
"export": "IpAddressRestrictionInboundPolicy",
|
|
48
|
+
"module": "$import(@zuplo/runtime)",
|
|
49
|
+
"options": {
|
|
50
|
+
"deny": ["192.0.2.0/24"]
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Writing Entries
|
|
57
|
+
|
|
58
|
+
- An entry without a prefix is a single address: `203.0.113.42` is the same as
|
|
59
|
+
`203.0.113.42/32`, and `2001:db8::1` the same as `2001:db8::1/128`.
|
|
60
|
+
- Host bits beyond the prefix are ignored, so `10.1.2.3/8` and `10.0.0.0/8` mean
|
|
61
|
+
the same range.
|
|
62
|
+
- IPv4-mapped IPv6 addresses are matched as the IPv4 address they map to. A
|
|
63
|
+
client that a dual-stack proxy reports as `::ffff:198.51.100.7` matches an
|
|
64
|
+
`198.51.100.0/24` entry.
|
|
65
|
+
- An IPv4 address never matches an IPv6 range, and vice versa. To cover a client
|
|
66
|
+
reachable over both, list a range for each family.
|
|
67
|
+
- Addresses with leading zeros in an octet (`010.0.0.1`) are rejected, because
|
|
68
|
+
different tools read them as different addresses.
|
|
69
|
+
|
|
70
|
+
Entries are validated when the policy is created, so a malformed address or
|
|
71
|
+
prefix fails your deployment rather than rejecting live traffic. Each list can
|
|
72
|
+
also be sourced from environment variables per entry:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"options": {
|
|
77
|
+
"allow": ["$env(OFFICE_CIDR)", "$env(VPN_CIDR)"]
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Usage Example
|
|
83
|
+
|
|
84
|
+
Restrict an admin route while leaving the rest of the API public:
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"paths": {
|
|
89
|
+
"/v1/admin/users": {
|
|
90
|
+
"get": {
|
|
91
|
+
"x-zuplo-route": {
|
|
92
|
+
"policies": {
|
|
93
|
+
"inbound": ["ip-address-restriction", "api-key-auth"]
|
|
94
|
+
},
|
|
95
|
+
"handler": {
|
|
96
|
+
"export": "urlForwardHandler",
|
|
97
|
+
"module": "$import(@zuplo/runtime)",
|
|
98
|
+
"options": {
|
|
99
|
+
"baseUrl": "https://api.internal.example.com"
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Put this policy first in the inbound list so restricted addresses are rejected
|
|
110
|
+
before any work — authentication, rate limiting, upstream token fetches — is
|
|
111
|
+
done on their behalf.
|
|
112
|
+
|
|
113
|
+
### Where the Default Address Comes From
|
|
114
|
+
|
|
115
|
+
By default the policy uses the address the gateway itself resolved — the same
|
|
116
|
+
value your code sees as `context.incomingRequestProperties.ip`. That comes from
|
|
117
|
+
exactly **one** header, chosen by the platform running the gateway
|
|
118
|
+
(`true-client-ip` on Cloudflare, `x-real-ip` elsewhere), and it is resolved from
|
|
119
|
+
the request as it originally arrived.
|
|
120
|
+
|
|
121
|
+
Two consequences worth knowing:
|
|
122
|
+
|
|
123
|
+
- **There is no fallback to a second header.** If the trusted header is absent,
|
|
124
|
+
the address is reported as unknown rather than being guessed from
|
|
125
|
+
`x-forwarded-for` or another header. Every available fallback is one a caller
|
|
126
|
+
can write, so a fallback would let anyone satisfy an allow list by sending a
|
|
127
|
+
header.
|
|
128
|
+
- **Header stripping does not affect it.** The gateway removes `x-real-ip` and
|
|
129
|
+
the `cf-*` headers from the request before policies run (from compatibility
|
|
130
|
+
date **2024-01-15**), but the default path reads the original request, so this
|
|
131
|
+
makes no difference to it. It _does_ affect `clientIpHeader` — see below.
|
|
132
|
+
|
|
133
|
+
During local development there is no edge to set the header and the caller
|
|
134
|
+
really is the loopback interface, so the address is reported as `127.0.0.1`. An
|
|
135
|
+
allow list that does not cover `127.0.0.1` will reject your local requests — add
|
|
136
|
+
it to the list to test locally:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"options": {
|
|
141
|
+
"allow": ["198.51.100.0/24", "127.0.0.1", "::1"]
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Note that `allowUnknownIpAddress` does **not** help here. Locally the address is
|
|
147
|
+
known — it is `127.0.0.1` — and that option only applies when no address could
|
|
148
|
+
be determined at all.
|
|
149
|
+
|
|
150
|
+
### Requests With No Client IP Address
|
|
151
|
+
|
|
152
|
+
When no client IP address can be determined, the request is rejected if an
|
|
153
|
+
`allow` list is set, since an unknown address cannot be on it. With only a
|
|
154
|
+
`deny` list the request continues, because there is nothing for it to match.
|
|
155
|
+
|
|
156
|
+
Set `allowUnknownIpAddress` to `true` to let these requests through even with an
|
|
157
|
+
`allow` list. This is most useful when you run your own proxy and cannot
|
|
158
|
+
guarantee it sets the trusted header on every request.
|
|
159
|
+
|
|
160
|
+
### Custom Client IP Header
|
|
161
|
+
|
|
162
|
+
If a proxy in front of the gateway reports the client address in a header other
|
|
163
|
+
than the standard ones, name it with `clientIpHeader`:
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{
|
|
167
|
+
"options": {
|
|
168
|
+
"allow": ["198.51.100.0/24"],
|
|
169
|
+
"clientIpHeader": "x-client-ip"
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Only the named header is read when this is set. Prefer a header your proxy sets
|
|
175
|
+
to a single address.
|
|
176
|
+
|
|
177
|
+
Unlike the default path, this option reads the request as policies see it, after
|
|
178
|
+
the gateway has stripped `x-real-ip` and the `cf-*` headers (from compatibility
|
|
179
|
+
date **2024-01-15**). Naming one of those here resolves nothing, and with an
|
|
180
|
+
`allow` list that rejects every request. Use a dedicated header your proxy sets,
|
|
181
|
+
such as `x-client-ip`.
|
|
182
|
+
|
|
183
|
+
If the header holds a comma-separated list, the **last** entry is used, because
|
|
184
|
+
proxies append and so the nearest proxy's entry is the rightmost one —
|
|
185
|
+
everything to its left is whatever the caller sent. For a single-value header
|
|
186
|
+
this makes no difference. This assumes one appending proxy in front of the
|
|
187
|
+
gateway; if you have more than one, use a dedicated single-value header, since
|
|
188
|
+
the trustworthy entry is then further left and its position depends on how many
|
|
189
|
+
hops you control.
|
|
190
|
+
|
|
191
|
+
### Security Considerations
|
|
192
|
+
|
|
193
|
+
:::warning
|
|
194
|
+
|
|
195
|
+
This policy is only as trustworthy as the header it reads the client address
|
|
196
|
+
from. A header is a security control **only** if the proxy in front of your
|
|
197
|
+
gateway replaces any client-supplied value rather than appending to it.
|
|
198
|
+
|
|
199
|
+
The default path is not configurable for this reason: it reads a single header
|
|
200
|
+
designated by the platform running the gateway, and never falls back to another
|
|
201
|
+
one. On Zuplo's managed edge that header is set by the edge on every request and
|
|
202
|
+
a caller cannot override it.
|
|
203
|
+
|
|
204
|
+
Two specifics worth knowing if you self-host or run a CDN in front of Zuplo:
|
|
205
|
+
|
|
206
|
+
- **`true-client-ip` is spoofable unless your own edge sets it.** Cloudflare
|
|
207
|
+
documents this directly: in a stacked-CDN setup, if you do not add the header
|
|
208
|
+
yourself, "its value can be spoofed to any value".
|
|
209
|
+
- **`x-forwarded-for` is a list, and the untrustworthy end comes first.** When a
|
|
210
|
+
request already carries an `X-Forwarded-For` header,
|
|
211
|
+
[Cloudflare appends to it](https://developers.cloudflare.com/fundamentals/reference/http-headers/#x-forwarded-for)
|
|
212
|
+
rather than replacing it, and most proxies behave the same way. The entry your
|
|
213
|
+
own proxy added is therefore at the **end** of the list, while the entries at
|
|
214
|
+
the front are whatever the caller chose to send. The policy never reads this
|
|
215
|
+
header on the default path — but if you point `clientIpHeader` at it, this is
|
|
216
|
+
why only the last entry is used.
|
|
217
|
+
|
|
218
|
+
If a proxy you control reports the client address in a dedicated header that it
|
|
219
|
+
always overwrites, name that header with `clientIpHeader`. That is the
|
|
220
|
+
configuration to prefer whenever you have it, because it removes the ambiguity
|
|
221
|
+
entirely.
|
|
222
|
+
|
|
223
|
+
:::
|
|
224
|
+
|
|
225
|
+
- Rejected requests get a `403 Forbidden` with no detail about the address seen
|
|
226
|
+
or the rule matched. The address and the matching entry are written to the
|
|
227
|
+
request log at debug level instead, so an unauthorized caller is not told what
|
|
228
|
+
the gateway sees or that it is filtering by address at all.
|
|
229
|
+
- An IP address identifies a network path, not a caller. Combine this policy
|
|
230
|
+
with an authentication policy rather than using it as your only control —
|
|
231
|
+
addresses within an allowed range can be shared, reassigned, or spoofed
|
|
232
|
+
upstream of your gateway.
|
|
233
|
+
- Prefer the narrowest ranges that work. A `/8` allow entry covers 16 million
|
|
234
|
+
addresses.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Restrict which clients can reach a route based on their IP address. Configure an
|
|
2
|
+
allow list to expose an endpoint only to a set of known networks, a deny list to
|
|
3
|
+
shut out abusive traffic, or both. Entries can be individual IP addresses or
|
|
4
|
+
CIDR ranges, in IPv4 or IPv6.
|
|
5
|
+
|
|
6
|
+
With this policy, you'll benefit from:
|
|
7
|
+
|
|
8
|
+
- **Private APIs on a Public Gateway**: Limit admin, partner, or internal
|
|
9
|
+
endpoints to your office, VPN, or data center ranges
|
|
10
|
+
- **Immediate Blocking**: Drop traffic from a bad actor's address or subnet
|
|
11
|
+
without a deploy of your backend
|
|
12
|
+
- **Per-Route Control**: Apply different lists to different routes, so a public
|
|
13
|
+
endpoint and a restricted one can live in the same API
|
|
14
|
+
- **IPv4 and IPv6**: Single addresses and CIDR ranges in both families, with
|
|
15
|
+
IPv4-mapped IPv6 addresses handled automatically
|
|
16
|
+
- **Fail-Closed Defaults**: Requests whose address cannot be determined are
|
|
17
|
+
rejected when an allow list is set
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft-07/schema",
|
|
3
|
+
"$id": "https://cdn.zuplo.com/policies/runtime/schemas/ip-address-restriction-inbound.json",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"title": "IP Address Restriction",
|
|
6
|
+
"isDeprecated": false,
|
|
7
|
+
"isPaidAddOn": false,
|
|
8
|
+
"isEnterprise": false,
|
|
9
|
+
"isInternal": false,
|
|
10
|
+
"isBeta": false,
|
|
11
|
+
"isHidden": false,
|
|
12
|
+
"requiresAI": false,
|
|
13
|
+
"products": ["api-gateway"],
|
|
14
|
+
"description": "Restricts which client IP addresses can call a route using an allow list, a deny list, or both. Entries are individual IP addresses or CIDR ranges, in IPv4 or IPv6.",
|
|
15
|
+
"deprecatedMessage": "",
|
|
16
|
+
"required": ["handler"],
|
|
17
|
+
"properties": {
|
|
18
|
+
"handler": {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"default": {},
|
|
21
|
+
"required": ["export", "module", "options"],
|
|
22
|
+
"properties": {
|
|
23
|
+
"export": {
|
|
24
|
+
"const": "IpAddressRestrictionInboundPolicy",
|
|
25
|
+
"description": "The name of the exported type"
|
|
26
|
+
},
|
|
27
|
+
"module": {
|
|
28
|
+
"const": "$import(@zuplo/runtime)",
|
|
29
|
+
"description": "The module containing the policy"
|
|
30
|
+
},
|
|
31
|
+
"options": {
|
|
32
|
+
"title": "IpAddressRestrictionInboundPolicyOptions",
|
|
33
|
+
"type": "object",
|
|
34
|
+
"description": "The options for this policy.",
|
|
35
|
+
"additionalProperties": false,
|
|
36
|
+
"required": [],
|
|
37
|
+
"if": {
|
|
38
|
+
"not": {
|
|
39
|
+
"required": ["allow"]
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"then": {
|
|
43
|
+
"required": ["deny"]
|
|
44
|
+
},
|
|
45
|
+
"properties": {
|
|
46
|
+
"allow": {
|
|
47
|
+
"type": "array",
|
|
48
|
+
"minItems": 1,
|
|
49
|
+
"items": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"pattern": "^\\s*([^,\\s]*\\$env\\([^)]+\\)[^,\\s]*|[0-9A-Fa-f.:]+(/[0-9]{1,3})?)\\s*$",
|
|
52
|
+
"description": "An IP address (`203.0.113.42`, `2001:db8::1`) or a CIDR range (`198.51.100.0/24`, `2001:db8::/32`)."
|
|
53
|
+
},
|
|
54
|
+
"examples": [["203.0.113.42", "198.51.100.0/24"]],
|
|
55
|
+
"description": "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`."
|
|
56
|
+
},
|
|
57
|
+
"deny": {
|
|
58
|
+
"type": "array",
|
|
59
|
+
"minItems": 1,
|
|
60
|
+
"items": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"pattern": "^\\s*([^,\\s]*\\$env\\([^)]+\\)[^,\\s]*|[0-9A-Fa-f.:]+(/[0-9]{1,3})?)\\s*$",
|
|
63
|
+
"description": "An IP address (`203.0.113.42`, `2001:db8::1`) or a CIDR range (`198.51.100.0/24`, `2001:db8::/32`)."
|
|
64
|
+
},
|
|
65
|
+
"examples": [["203.0.113.99", "192.0.2.0/24"]],
|
|
66
|
+
"description": "The IP addresses and CIDR ranges that are rejected with a `403 Forbidden`. Evaluated before `allow`, so an address matching both lists is rejected."
|
|
67
|
+
},
|
|
68
|
+
"clientIpHeader": {
|
|
69
|
+
"type": "string",
|
|
70
|
+
"examples": ["x-client-ip"],
|
|
71
|
+
"x-show-example": false,
|
|
72
|
+
"x-advanced": true,
|
|
73
|
+
"description": "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."
|
|
74
|
+
},
|
|
75
|
+
"allowUnknownIpAddress": {
|
|
76
|
+
"type": "boolean",
|
|
77
|
+
"default": false,
|
|
78
|
+
"x-show-example": false,
|
|
79
|
+
"x-advanced": true,
|
|
80
|
+
"description": "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."
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"examples": [
|
|
86
|
+
{
|
|
87
|
+
"export": "IpAddressRestrictionInboundPolicy",
|
|
88
|
+
"module": "$import(@zuplo/runtime)",
|
|
89
|
+
"options": {
|
|
90
|
+
"allow": ["203.0.113.42", "198.51.100.0/24"],
|
|
91
|
+
"deny": ["203.0.113.99", "192.0.2.0/24"]
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zuplo",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The programmable API Gateway",
|
|
6
6
|
"author": "Zuplo, Inc.",
|
|
@@ -12,16 +12,16 @@
|
|
|
12
12
|
"docs"
|
|
13
13
|
],
|
|
14
14
|
"engines": {
|
|
15
|
-
"node": ">=
|
|
15
|
+
"node": ">=24.0.0",
|
|
16
16
|
"npm": ">=10"
|
|
17
17
|
},
|
|
18
18
|
"bin": {
|
|
19
19
|
"zuplo": "zuplo.js"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@zuplo/cli": "
|
|
23
|
-
"@zuplo/core": "
|
|
24
|
-
"@zuplo/runtime": "
|
|
25
|
-
"@zuplo/test": "
|
|
22
|
+
"@zuplo/cli": "7.0.0",
|
|
23
|
+
"@zuplo/core": "7.0.0",
|
|
24
|
+
"@zuplo/runtime": "7.0.0",
|
|
25
|
+
"@zuplo/test": "7.0.0"
|
|
26
26
|
}
|
|
27
27
|
}
|