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 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.2",
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`; `"*"` is then reflected per-origin |
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 + `*` | Browsers reject that pair, so the plugin reflects the request origin instead |
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 | 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 (first hop wins) |
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"], header: "x-real-ip" }))
43
+ .plug(ipAllowlist({ deny: ["198.51.100.9"], trustedProxyDepth: 1 }))
43
44
  ```
44
45
 
45
- <Callout type="warn">
46
- The IP header is only trustworthy behind a proxy that **sets or overwrites** it (your load
47
- balancer, reverse proxy, or platform edge). A client connecting directly can send any
48
- `x-forwarded-for` value it likes an allowlist is a strong boundary only when the proxy owns the
49
- header.
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 | First comma-separated hop is the client; proxy-appended tails are ignored |
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).
@@ -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 reflect the origin instead of stamping *", async () => {
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(cors({ origin: "*", credentials: true }));
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 res = await app.fetch(preflight());
88
- expect(res.headers.get("access-control-allow-origin")).toBe("https://app.example.com");
89
- expect(res.headers.get("access-control-allow-credentials")).toBe("true");
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" }));
@@ -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`. When set, an `origin`
41
- * of `"*"` is answered by reflecting the request origin — browsers
42
- * reject `"*"` together with credentials.
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 === "*" && options.credentials !== true ? "*" : 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
- const def = plugin("cors", { version: "0.0.1", config: pluginConfigSnapshot(options) })
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 first hop is the client; later hops are ignored", async () => {
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
- const proxied = await app.fetch(get("203.0.113.7, 10.0.0.1, 10.0.0.2"));
69
- expect(proxied.status).toBe(200);
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 spoofedTail = await app.fetch(get("198.51.100.9, 203.0.113.7"));
72
- expect(spoofedTail.status).toBe(403);
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"` (first hop
30
- * wins, the value your proxy appends). Only trustworthy behind a proxy
31
- * that sets or overwrites this header — a direct client can lie.
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
- /** Extract the client IP from the configured header (first hop for XFF). */
37
- function clientIp(request: Request, header: string): string | undefined {
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 first = raw.split(",")[0]?.trim();
41
- return first === undefined || first.length === 0 ? undefined : first;
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.2",
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 ip = clientIp(ctx.request, header);
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 });