okengine 0.3.2 → 0.3.5
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/README.md +2 -2
- package/package.json +1 -2
- package/site/content/docs/plugins/cors.mdx +15 -9
- package/site/content/docs/plugins/ip-allowlist.mdx +13 -12
- package/src/cli/openbao-bootstrap.test.ts +2 -0
- package/src/cli/openbao-bootstrap.ts +34 -0
- package/src/plugins/cors.test.ts +39 -5
- package/src/plugins/cors.ts +29 -5
- package/src/plugins/ip-allowlist.test.ts +39 -5
- package/src/plugins/ip-allowlist.ts +62 -9
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<p align="center">
|
|
2
2
|
<picture>
|
|
3
|
-
<source media="(prefers-color-scheme: dark)" srcset="site/public/OKE-W.svg" />
|
|
4
|
-
<img alt="OKE" src="site/public/OKE-B.svg" width="220" />
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="site/public/logo/OKE-W.svg" />
|
|
4
|
+
<img alt="OKE" src="site/public/logo/OKE-B.svg" width="220" />
|
|
5
5
|
</picture>
|
|
6
6
|
</p>
|
|
7
7
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "okengine",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "One law. Eight elements. Ten exports. One package. One manifest. Every backend need is derived, never added.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -59,7 +59,6 @@
|
|
|
59
59
|
"gate": "bun test src/cli/doc-staleness.test.ts src/drivers/vault-driver-removal.test.ts src/kernel/errors.registry.test.ts src/upgrade/codemods.test.ts",
|
|
60
60
|
"dev": "bun run --cwd site dev",
|
|
61
61
|
"site:build": "bun run --cwd site build",
|
|
62
|
-
"ci:workflow": "bun scripts/ci.ts --workflow",
|
|
63
62
|
"ci": "bun scripts/ci.ts",
|
|
64
63
|
"bump": "bun run scripts/bump-version.ts",
|
|
65
64
|
"release": "bun run scripts/publish.ts",
|
|
@@ -27,14 +27,14 @@ Browsers on `https://app.example.com` can now call every flow; every other origi
|
|
|
27
27
|
|
|
28
28
|
## Options
|
|
29
29
|
|
|
30
|
-
| Option | Type | Default | Does
|
|
31
|
-
| ---------------- | ----------------------------- | ------------------------------------------------------ |
|
|
32
|
-
| `origin` | `"*"` · `string` · `string[]` | none (closed) | Origins allowed cross-origin; lists are exact matches
|
|
33
|
-
| `methods` | `string[]` | GET · HEAD · POST · PUT · PATCH · DELETE · OPTIONS | Methods answered on preflight
|
|
34
|
-
| `allowedHeaders` | `string[]` | reflect the request's `Access-Control-Request-Headers` | `Access-Control-Allow-Headers` on preflight
|
|
35
|
-
| `exposedHeaders` | `string[]` | omit | `Access-Control-Expose-Headers` on actual responses
|
|
36
|
-
| `credentials` | `boolean` | `false` | Send `Access-Control-Allow-Credentials`;
|
|
37
|
-
| `maxAge` | `number` | omit | `Access-Control-Max-Age` seconds on preflight
|
|
30
|
+
| Option | Type | Default | Does |
|
|
31
|
+
| ---------------- | ----------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------- |
|
|
32
|
+
| `origin` | `"*"` · `string` · `string[]` | none (closed) | Origins allowed cross-origin; lists are exact matches |
|
|
33
|
+
| `methods` | `string[]` | GET · HEAD · POST · PUT · PATCH · DELETE · OPTIONS | Methods answered on preflight |
|
|
34
|
+
| `allowedHeaders` | `string[]` | reflect the request's `Access-Control-Request-Headers` | `Access-Control-Allow-Headers` on preflight |
|
|
35
|
+
| `exposedHeaders` | `string[]` | omit | `Access-Control-Expose-Headers` on actual responses |
|
|
36
|
+
| `credentials` | `boolean` | `false` | Send `Access-Control-Allow-Credentials`; requires an explicit origin list |
|
|
37
|
+
| `maxAge` | `number` | omit | `Access-Control-Max-Age` seconds on preflight |
|
|
38
38
|
|
|
39
39
|
```typescript
|
|
40
40
|
.plug(cors({
|
|
@@ -44,13 +44,19 @@ Browsers on `https://app.example.com` can now call every flow; every other origi
|
|
|
44
44
|
}))
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
+
<Callout type="error">
|
|
48
|
+
`cors({ origin: "*", credentials: true })` throws at construction. Browsers reject that literal
|
|
49
|
+
pair; reflecting the request origin would grant **any** site credentialed access. List exact
|
|
50
|
+
origins for cookies/`Authorization` — no any-origin + credentials shortcut.
|
|
51
|
+
</Callout>
|
|
52
|
+
|
|
47
53
|
## Notes
|
|
48
54
|
|
|
49
55
|
| Behavior | Detail |
|
|
50
56
|
| ----------------- | ------------------------------------------------------------------------------------------ |
|
|
51
57
|
| Preflight | Answered by the plugin's **edge handler** — runs even when no flow matches the path/method |
|
|
52
58
|
| Denied preflight | `204` with no CORS headers — the correct, quiet failure; the browser blocks it |
|
|
53
|
-
| Credentials + `*` |
|
|
59
|
+
| Credentials + `*` | Construction throws — enumerate origins; never reflect `*` into credentialed access |
|
|
54
60
|
| `Vary` | `Origin` (plus request-method/headers on preflight) is appended, never duplicated |
|
|
55
61
|
| Non-HTTP triggers | No-op |
|
|
56
62
|
|
|
@@ -32,28 +32,29 @@ A client whose IP is not on the list receives `403` with a typed denial:
|
|
|
32
32
|
|
|
33
33
|
## Options
|
|
34
34
|
|
|
35
|
-
| Option
|
|
36
|
-
|
|
|
37
|
-
| `allow`
|
|
38
|
-
| `deny`
|
|
39
|
-
| `header`
|
|
35
|
+
| Option | Type | Default | Does |
|
|
36
|
+
| ------------------- | ---------- | ------------------- | ---------------------------------------------------------------------- |
|
|
37
|
+
| `allow` | `string[]` | — (everyone passes) | Exact IPs permitted — every other client is denied |
|
|
38
|
+
| `deny` | `string[]` | — (nobody blocked) | Exact IPs blocked — checked first, so deny wins on overlap |
|
|
39
|
+
| `header` | `string` | `"x-forwarded-for"` | Header carrying the client IP |
|
|
40
|
+
| `trustedProxyDepth` | `number` | `1` | Trusted proxies that append XFF; client IP is that many from the right |
|
|
40
41
|
|
|
41
42
|
```typescript
|
|
42
|
-
.plug(ipAllowlist({ deny: ["198.51.100.9"],
|
|
43
|
+
.plug(ipAllowlist({ deny: ["198.51.100.9"], trustedProxyDepth: 1 }))
|
|
43
44
|
```
|
|
44
45
|
|
|
45
|
-
<Callout type="
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
<Callout type="error">
|
|
47
|
+
Standard reverse proxies **append** to `X-Forwarded-For` — left-side hops are attacker-controlled.
|
|
48
|
+
The plugin trusts the hop `trustedProxyDepth` from the **right** (default `1` = last hop). Set
|
|
49
|
+
this to your real proxy count; wrong depth bypasses the allowlist — topology-dependent, not
|
|
50
|
+
drop-in.
|
|
50
51
|
</Callout>
|
|
51
52
|
|
|
52
53
|
## Notes
|
|
53
54
|
|
|
54
55
|
| Behavior | Detail |
|
|
55
56
|
| ----------------- | ---------------------------------------------------------------------------------- |
|
|
56
|
-
| XFF parsing |
|
|
57
|
+
| XFF parsing | Last hop (depth `1`) is the client; left-side spoofed entries are ignored |
|
|
57
58
|
| Missing header | Denied when `allow` is set (unknown is not allowed); permitted for deny-only rules |
|
|
58
59
|
| Deny wins | An IP in both lists is blocked |
|
|
59
60
|
| Non-HTTP triggers | No-op — there is no client IP outside HTTP |
|
|
@@ -34,6 +34,8 @@ function fakeOpenBao(calls: { url: string; method: string; body?: string }[]) {
|
|
|
34
34
|
const routes: Record<string, () => Response> = {
|
|
35
35
|
"GET /v1/sys/seal-status": () =>
|
|
36
36
|
Response.json({ sealed: false, initialized: false, t: 1, n: 1, progress: 0 }),
|
|
37
|
+
"GET /v1/sys/health": () =>
|
|
38
|
+
Response.json({ initialized: true, sealed: false, standby: false }, { status: 200 }),
|
|
37
39
|
"POST /v1/sys/init": () =>
|
|
38
40
|
Response.json({ keys: ["unseal-key-1"], keys_base64: ["dW5zZWFs"], root_token: "root-tok" }),
|
|
39
41
|
"POST /v1/sys/unseal": () => Response.json({ sealed: false, t: 1, n: 1, progress: 0 }),
|
|
@@ -122,6 +122,36 @@ async function api<T>(fetchFn: OpenBaoFetch, url: string, init?: RequestInit): P
|
|
|
122
122
|
return (text ? JSON.parse(text) : {}) as T;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Wait until OpenBao Raft storage is active (writable).
|
|
127
|
+
*
|
|
128
|
+
* `/v1/sys/health` returns 200 only when initialized, unsealed, and the
|
|
129
|
+
* active leader — not while sealed (503) or standby (429). Polling seal-status
|
|
130
|
+
* alone is not enough after restart.
|
|
131
|
+
*
|
|
132
|
+
* @param fetchFn - Injected fetch
|
|
133
|
+
* @param url - Base URL without trailing slash
|
|
134
|
+
*/
|
|
135
|
+
async function waitForActiveStorage(fetchFn: OpenBaoFetch, url: string): Promise<void> {
|
|
136
|
+
const deadline = Date.now() + 60_000;
|
|
137
|
+
let lastStatus = 0;
|
|
138
|
+
while (Date.now() < deadline) {
|
|
139
|
+
try {
|
|
140
|
+
const res = await fetchFn(`${url}/v1/sys/health`);
|
|
141
|
+
lastStatus = res.status;
|
|
142
|
+
// Drain the body so keep-alive clients don't stall.
|
|
143
|
+
await res.arrayBuffer().catch(() => undefined);
|
|
144
|
+
if (res.status === 200) return;
|
|
145
|
+
} catch {
|
|
146
|
+
// process up, Raft not ready yet
|
|
147
|
+
}
|
|
148
|
+
await Bun.sleep(250);
|
|
149
|
+
}
|
|
150
|
+
throw new OpenBaoBootstrapError(
|
|
151
|
+
`openbao bootstrap: storage not active after unseal (health → ${lastStatus || "unreachable"})`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
125
155
|
/**
|
|
126
156
|
* Ensure OpenBao is initialized + unsealed, mint/reuse the app token.
|
|
127
157
|
*
|
|
@@ -218,6 +248,10 @@ export async function ensureOpenBao(
|
|
|
218
248
|
}
|
|
219
249
|
}
|
|
220
250
|
|
|
251
|
+
// Single-node Raft can report unsealed before storage is writable
|
|
252
|
+
// (`cannot write to readonly storage` / `cannot find peer` during election).
|
|
253
|
+
await waitForActiveStorage(fetchFn, url);
|
|
254
|
+
|
|
221
255
|
const rootHeaders = { "X-Vault-Token": rootToken, "content-type": "application/json" };
|
|
222
256
|
|
|
223
257
|
// Enable KV v2 at `secret/` once (mount exists after our first init).
|
package/src/plugins/cors.test.ts
CHANGED
|
@@ -80,13 +80,26 @@ describe("cors plugin — preflight", () => {
|
|
|
80
80
|
expect(reflected.headers.get("access-control-allow-origin")).toBe("*");
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
test("credentials
|
|
83
|
+
test('origin: "*" + credentials: true throws at construction — no silent reflect', () => {
|
|
84
|
+
expect(() => cors({ origin: "*", credentials: true })).toThrow(
|
|
85
|
+
/origin:\s*"\*"\s+cannot be combined with credentials:\s*true/i,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("allowlist + credentials reflects only listed origins", async () => {
|
|
84
90
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
85
|
-
const app = oke({ name: "cors-cred" }).plug(
|
|
91
|
+
const app = oke({ name: "cors-cred" }).plug(
|
|
92
|
+
cors({ origin: ["https://app.example.com", "https://admin.example.com"], credentials: true }),
|
|
93
|
+
);
|
|
86
94
|
|
|
87
|
-
const
|
|
88
|
-
expect(
|
|
89
|
-
expect(
|
|
95
|
+
const allowed = await app.fetch(preflight());
|
|
96
|
+
expect(allowed.headers.get("access-control-allow-origin")).toBe("https://app.example.com");
|
|
97
|
+
expect(allowed.headers.get("access-control-allow-credentials")).toBe("true");
|
|
98
|
+
|
|
99
|
+
const denied = await app.fetch(preflight("/x", { origin: "https://evil.example.com" }));
|
|
100
|
+
expect(denied.status).toBe(204);
|
|
101
|
+
expect(denied.headers.get("access-control-allow-origin")).toBeNull();
|
|
102
|
+
expect(denied.headers.get("access-control-allow-credentials")).toBeNull();
|
|
90
103
|
});
|
|
91
104
|
});
|
|
92
105
|
|
|
@@ -106,6 +119,27 @@ describe("cors plugin — actual requests", () => {
|
|
|
106
119
|
expect(res.headers.get("access-control-expose-headers")).toBe("x-total");
|
|
107
120
|
});
|
|
108
121
|
|
|
122
|
+
test("allowlist + credentials on matched responses reflects only listed origins", async () => {
|
|
123
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
124
|
+
const app = oke({ name: "cors-actual-cred" }).plug(
|
|
125
|
+
cors({ origin: ["https://app.example.com"], credentials: true }),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const ok = await app.fetch(
|
|
129
|
+
new Request("http://localhost/x", { headers: { origin: "https://app.example.com" } }),
|
|
130
|
+
);
|
|
131
|
+
expect(ok.status).toBe(200);
|
|
132
|
+
expect(ok.headers.get("access-control-allow-origin")).toBe("https://app.example.com");
|
|
133
|
+
expect(ok.headers.get("access-control-allow-credentials")).toBe("true");
|
|
134
|
+
|
|
135
|
+
const denied = await app.fetch(
|
|
136
|
+
new Request("http://localhost/x", { headers: { origin: "https://evil.example.com" } }),
|
|
137
|
+
);
|
|
138
|
+
expect(denied.status).toBe(200);
|
|
139
|
+
expect(denied.headers.get("access-control-allow-origin")).toBeNull();
|
|
140
|
+
expect(denied.headers.get("access-control-allow-credentials")).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
|
|
109
143
|
test("denied origins get no CORS headers on matched responses", async () => {
|
|
110
144
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
111
145
|
const app = oke({ name: "cors-actual-deny" }).plug(cors({ origin: "https://ok.example.com" }));
|
package/src/plugins/cors.ts
CHANGED
|
@@ -22,6 +22,10 @@ export interface CorsOptions {
|
|
|
22
22
|
* exact-match list (`"https://app.example.com"`). Default **none** —
|
|
23
23
|
* cross-origin is closed until you open it. Same-origin traffic needs
|
|
24
24
|
* no CORS headers at all.
|
|
25
|
+
*
|
|
26
|
+
* `"*"` cannot be combined with {@link CorsOptions.credentials} —
|
|
27
|
+
* construction throws. List exact origins when cookies/auth headers are
|
|
28
|
+
* involved; there is no "any origin + credentials" shortcut.
|
|
25
29
|
*/
|
|
26
30
|
readonly origin?: "*" | string | readonly string[];
|
|
27
31
|
/**
|
|
@@ -37,9 +41,10 @@ export interface CorsOptions {
|
|
|
37
41
|
/** `Access-Control-Expose-Headers` on actual responses. Omitted unless provided. */
|
|
38
42
|
readonly exposedHeaders?: readonly string[];
|
|
39
43
|
/**
|
|
40
|
-
* Send `Access-Control-Allow-Credentials: true`.
|
|
41
|
-
*
|
|
42
|
-
*
|
|
44
|
+
* Send `Access-Control-Allow-Credentials: true`. Requires an explicit
|
|
45
|
+
* origin allowlist (string or list) — never `"*"`. Construction throws
|
|
46
|
+
* on `origin: "*" + credentials: true` (that pair would otherwise be
|
|
47
|
+
* silently rewritten into reflected-origin access for every site).
|
|
43
48
|
*/
|
|
44
49
|
readonly credentials?: boolean;
|
|
45
50
|
/** `Access-Control-Max-Age` seconds on preflight. Omitted unless provided. */
|
|
@@ -48,6 +53,21 @@ export interface CorsOptions {
|
|
|
48
53
|
|
|
49
54
|
const DEFAULT_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] as const;
|
|
50
55
|
|
|
56
|
+
const WILDCARD_CREDENTIALS_ERROR =
|
|
57
|
+
'cors: origin: "*" cannot be combined with credentials: true — list exact origins instead ' +
|
|
58
|
+
'(browsers reject "*" with credentials; reflecting the request origin would grant any site ' +
|
|
59
|
+
"credentialed access)";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Reject the dangerous `origin: "*" + credentials: true` pair.
|
|
63
|
+
* Fail loud at construction (and again if runtime config introduces it).
|
|
64
|
+
*/
|
|
65
|
+
function assertSafeCorsOptions(options: CorsOptions): void {
|
|
66
|
+
if (options.origin === "*" && options.credentials === true) {
|
|
67
|
+
throw new Error(WILDCARD_CREDENTIALS_ERROR);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
51
71
|
/** True when `origin` is permitted by the configured origin rule. */
|
|
52
72
|
export function originAllowed(origin: string, rule: CorsOptions["origin"]): boolean {
|
|
53
73
|
if (rule === undefined) return false;
|
|
@@ -58,7 +78,7 @@ export function originAllowed(origin: string, rule: CorsOptions["origin"]): bool
|
|
|
58
78
|
|
|
59
79
|
/** The `Access-Control-Allow-Origin` value for a permitted origin. */
|
|
60
80
|
function allowOriginValue(origin: string, options: CorsOptions): string {
|
|
61
|
-
return options.origin === "*"
|
|
81
|
+
return options.origin === "*" ? "*" : origin;
|
|
62
82
|
}
|
|
63
83
|
|
|
64
84
|
/** Is this request a CORS preflight? */
|
|
@@ -82,10 +102,13 @@ function isPreflight(request: Request, method: string): boolean {
|
|
|
82
102
|
* @param options - Origin rules and friends, or a config source
|
|
83
103
|
*/
|
|
84
104
|
export function cors(options: CorsOptions | ConfigSource<CorsOptions> = {}): PluginDef {
|
|
85
|
-
|
|
105
|
+
assertSafeCorsOptions(pluginConfigSnapshot(options));
|
|
106
|
+
|
|
107
|
+
const def = plugin("cors", { version: "0.0.2", config: pluginConfigSnapshot(options) })
|
|
86
108
|
.edge((request, info) => {
|
|
87
109
|
if (!isPreflight(request, info.method)) return undefined;
|
|
88
110
|
const resolved = resolvePluginOptions(options);
|
|
111
|
+
assertSafeCorsOptions(resolved);
|
|
89
112
|
const origin = request.headers.get("origin")!;
|
|
90
113
|
if (!originAllowed(origin, resolved.origin)) {
|
|
91
114
|
return new Response(null, { status: 204 });
|
|
@@ -113,6 +136,7 @@ export function cors(options: CorsOptions | ConfigSource<CorsOptions> = {}): Plu
|
|
|
113
136
|
const origin = ctx.request.headers.get("origin");
|
|
114
137
|
if (origin === null) return;
|
|
115
138
|
const resolved = resolvePluginOptions(options);
|
|
139
|
+
assertSafeCorsOptions(resolved);
|
|
116
140
|
if (!originAllowed(origin, resolved.origin)) return;
|
|
117
141
|
|
|
118
142
|
ctx.response = withHeaders(ctx.response, (headers) => {
|
|
@@ -61,15 +61,49 @@ describe("ipAllowlist plugin", () => {
|
|
|
61
61
|
expect(res.status).toBe(403);
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
test("XFF
|
|
64
|
+
test("XFF last hop is the client; a spoofed first hop cannot bypass allow", async () => {
|
|
65
65
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
66
66
|
const app = oke({ name: "ips-xff" }).plug(ipAllowlist({ allow: ["203.0.113.7"] }));
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
// Attacker-controlled first hop + trusted proxy appended the real client (last).
|
|
69
|
+
const spoofedFirst = await app.fetch(get("203.0.113.7, 198.51.100.9"));
|
|
70
|
+
expect(spoofedFirst.status).toBe(403);
|
|
70
71
|
|
|
71
|
-
const
|
|
72
|
-
expect(
|
|
72
|
+
const realLast = await app.fetch(get("198.51.100.9, 203.0.113.7"));
|
|
73
|
+
expect(realLast.status).toBe(200);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("spoofed first hop cannot bypass a deny rule", async () => {
|
|
77
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
78
|
+
const app = oke({ name: "ips-xff-deny" }).plug(ipAllowlist({ deny: ["198.51.100.9"] }));
|
|
79
|
+
|
|
80
|
+
// Spoof a non-denied IP first; proxy appended the real (denied) client last.
|
|
81
|
+
const spoofed = await app.fetch(get("203.0.113.7, 198.51.100.9"));
|
|
82
|
+
expect(spoofed.status).toBe(403);
|
|
83
|
+
const body = (await spoofed.json()) as { error: { data: { reason: string; ip: string } } };
|
|
84
|
+
expect(body.error.data.reason).toBe("ip_denied");
|
|
85
|
+
expect(body.error.data.ip).toBe("198.51.100.9");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("trustedProxyDepth selects the hop behind N trusted proxies", async () => {
|
|
89
|
+
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
90
|
+
// Chain: spoofed, real-client, cdn-egress — depth 2 skips the nearest proxy hop.
|
|
91
|
+
const app = oke({ name: "ips-depth" }).plug(
|
|
92
|
+
ipAllowlist({ allow: ["203.0.113.7"], trustedProxyDepth: 2 }),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const ok = await app.fetch(get("198.51.100.1, 203.0.113.7, 10.0.0.2"));
|
|
96
|
+
expect(ok.status).toBe(200);
|
|
97
|
+
|
|
98
|
+
// Second-from-last is not allow-listed → denied (depth must match topology).
|
|
99
|
+
const denied = await app.fetch(get("198.51.100.1, 198.51.100.9, 10.0.0.2"));
|
|
100
|
+
expect(denied.status).toBe(403);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("trustedProxyDepth < 1 throws at construction", () => {
|
|
104
|
+
expect(() => ipAllowlist({ allow: ["203.0.113.7"], trustedProxyDepth: 0 })).toThrow(
|
|
105
|
+
/trustedProxyDepth/i,
|
|
106
|
+
);
|
|
73
107
|
});
|
|
74
108
|
|
|
75
109
|
test("missing header: denied when allow is set, permitted for deny-only", async () => {
|
|
@@ -26,19 +26,68 @@ export interface IpAllowlistOptions {
|
|
|
26
26
|
*/
|
|
27
27
|
readonly deny?: readonly string[];
|
|
28
28
|
/**
|
|
29
|
-
* Header carrying the client IP. Default `"x-forwarded-for"
|
|
30
|
-
*
|
|
31
|
-
*
|
|
29
|
+
* Header carrying the client IP. Default `"x-forwarded-for"`.
|
|
30
|
+
*
|
|
31
|
+
* For XFF, reverse proxies (nginx `$proxy_add_x_forwarded_for`, etc.)
|
|
32
|
+
* **append** the connecting peer — they do not overwrite. The trusted
|
|
33
|
+
* client IP is therefore taken from the **right** of the chain (see
|
|
34
|
+
* {@link IpAllowlistOptions.trustedProxyDepth}), not the left. A client
|
|
35
|
+
* connecting directly can still forge the whole header; this plugin is
|
|
36
|
+
* only trustworthy behind a proxy that appends (or sets) it.
|
|
32
37
|
*/
|
|
33
38
|
readonly header?: string;
|
|
39
|
+
/**
|
|
40
|
+
* How many trusted proxies sit in front of the app and append to XFF.
|
|
41
|
+
* Default `1` (single reverse proxy — the usual docker / edge shape).
|
|
42
|
+
*
|
|
43
|
+
* The client IP is the hop `trustedProxyDepth` entries from the right:
|
|
44
|
+
* depth `1` = last hop (what the nearest proxy observed); depth `2` =
|
|
45
|
+
* second-from-last (CDN + internal LB both appending), and so on.
|
|
46
|
+
*
|
|
47
|
+
* **This must match the real number of trusted proxies in your
|
|
48
|
+
* deployment.** Too low trusts a spoofable left-side hop; too high may
|
|
49
|
+
* pick a proxy address instead of the client. Wrong depth bypasses the
|
|
50
|
+
* allowlist — this is a topology-dependent security control, not a
|
|
51
|
+
* drop-in default you can ignore.
|
|
52
|
+
*/
|
|
53
|
+
readonly trustedProxyDepth?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Reject a non-positive or non-integer `trustedProxyDepth`.
|
|
58
|
+
* Fail loud at construction (and again if runtime config introduces it).
|
|
59
|
+
*/
|
|
60
|
+
function assertSafeIpAllowlistOptions(options: IpAllowlistOptions): void {
|
|
61
|
+
const depth = options.trustedProxyDepth;
|
|
62
|
+
if (depth === undefined) return;
|
|
63
|
+
if (!Number.isInteger(depth) || depth < 1) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`ip-allowlist: trustedProxyDepth must be an integer >= 1 (got ${String(depth)}) — ` +
|
|
66
|
+
"set it to the real number of trusted proxies that append X-Forwarded-For",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
34
69
|
}
|
|
35
70
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Extract the client IP from the configured header.
|
|
73
|
+
*
|
|
74
|
+
* For comma-separated XFF chains, trust the hop `trustedProxyDepth` from
|
|
75
|
+
* the right (nearest trusted proxy's observation at depth 1). Left-side
|
|
76
|
+
* entries are attacker-controlled when clients can set the header before
|
|
77
|
+
* a proxy that appends. Fewer hops than `trustedProxyDepth` → undefined
|
|
78
|
+
* (fail closed).
|
|
79
|
+
*/
|
|
80
|
+
function clientIp(request: Request, header: string, trustedProxyDepth: number): string | undefined {
|
|
38
81
|
const raw = request.headers.get(header);
|
|
39
82
|
if (raw === null) return undefined;
|
|
40
|
-
const
|
|
41
|
-
|
|
83
|
+
const hops = raw
|
|
84
|
+
.split(",")
|
|
85
|
+
.map((h) => h.trim())
|
|
86
|
+
.filter((h) => h.length > 0);
|
|
87
|
+
if (hops.length === 0) return undefined;
|
|
88
|
+
const index = hops.length - trustedProxyDepth;
|
|
89
|
+
if (index < 0) return undefined;
|
|
90
|
+
return hops[index];
|
|
42
91
|
}
|
|
43
92
|
|
|
44
93
|
/**
|
|
@@ -55,14 +104,18 @@ function clientIp(request: Request, header: string): string | undefined {
|
|
|
55
104
|
export function ipAllowlist(
|
|
56
105
|
options: IpAllowlistOptions | ConfigSource<IpAllowlistOptions>,
|
|
57
106
|
): PluginDef {
|
|
107
|
+
assertSafeIpAllowlistOptions(pluginConfigSnapshot(options));
|
|
108
|
+
|
|
58
109
|
const def = plugin("ip-allowlist", {
|
|
59
|
-
version: "0.0.
|
|
110
|
+
version: "0.0.3",
|
|
60
111
|
config: pluginConfigSnapshot(options),
|
|
61
112
|
}).hook("onAuth", (ctx) => {
|
|
62
113
|
if (!ctx.request) return;
|
|
63
114
|
const resolved = resolvePluginOptions(options);
|
|
115
|
+
assertSafeIpAllowlistOptions(resolved);
|
|
64
116
|
const header = (resolved.header ?? "x-forwarded-for").toLowerCase();
|
|
65
|
-
const
|
|
117
|
+
const depth = resolved.trustedProxyDepth ?? 1;
|
|
118
|
+
const ip = clientIp(ctx.request, header, depth);
|
|
66
119
|
|
|
67
120
|
if (ip !== undefined && (resolved.deny ?? []).includes(ip)) {
|
|
68
121
|
return fail("Forbidden", { reason: "ip_denied", ip });
|