From 3ca4f7edb696573d547262ba21850506f38f411f Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Sat, 23 May 2026 23:57:22 +0100 Subject: [PATCH 1/4] Specify hosted rate limiting task --- tasks/hosted-rate-limits.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tasks/hosted-rate-limits.md diff --git a/tasks/hosted-rate-limits.md b/tasks/hosted-rate-limits.md new file mode 100644 index 0000000..ca16076 --- /dev/null +++ b/tasks/hosted-rate-limits.md @@ -0,0 +1,30 @@ +--- +status: in-progress +size: medium +--- + +# Hosted captun.sh rate limits + +Status summary: Spec is being carved into a first reviewable slice. The intended first PR adds Worker-level hosted throttles with tests; ownership, paid/custom names, and deeper abuse controls remain follow-up work. + +## First hosted throttling slice + +- [ ] Add a hosted-only rate-limiter Durable Object. _Keep self-hosted deployments unaffected unless they opt into the hosted `CUSTOM_HOSTNAME=captun.sh` path._ +- [ ] Limit tunnel connect attempts per client IP. _Repeated `__captun-connect` upgrades from the same IP should eventually return `429` before reaching a shard._ +- [ ] Limit forwarded HTTP requests per client IP and per tunnel name. _A noisy tunnel or source IP should receive `429` without breaking unrelated tunnels._ +- [ ] Return useful `429` responses. _Include `Retry-After`, plain text body, and conservative no-store headers._ +- [ ] Make limits configurable by Worker vars. _Use safe defaults for the public deployment and allow tests to set tiny limits._ +- [ ] Cover limits in Miniflare tests. _Exercise connect throttles, forwarded-request throttles, hosted-only behavior, and reset behavior._ +- [ ] Deploy to `captun-public` after merge-ready checks. _Verify hosted public e2e still passes._ + +## Follow-up safety work + +- [ ] Add tunnel ownership tokens so a different anonymous client cannot evict an active tunnel. _This should return `409` for conflicting reconnects rather than silently replacing the active client._ +- [ ] Add active tunnel caps and reconnect-churn limits. _Likely needs a global-ish Durable Object keyed separately from the shard count._ +- [ ] Add request body, response, and in-flight request caps. _Protect against tunnels used for bulk transfer or resource exhaustion._ +- [ ] Add Cloudflare-native Rate Limiting bindings where available. _Use edge throttles for cheaper rejection before Durable Objects wake up._ +- [ ] Add observability for 429s, high-volume IPs, high-volume tunnel names, and emergency shutdowns. _Needed before the public hosted service is advertised._ + +## Implementation Notes + +- 2026-05-23: Initial unsafe hosted service is intentionally live but obscure. This task starts the first throttling layer before publicising `captun.sh`. From e3b61b46ab294c137485676c1c9461f52f041d6a Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Sun, 24 May 2026 00:01:16 +0100 Subject: [PATCH 2/4] Add hosted rate limiter --- src/worker.ts | 155 +++++++++++++++++++++++++++++++++++- tasks/hosted-rate-limits.md | 20 ++--- test/miniflare.ts | 1 + test/worker.test.ts | 75 +++++++++++++++++ wrangler.jsonc | 10 ++- 5 files changed, 249 insertions(+), 12 deletions(-) diff --git a/src/worker.ts b/src/worker.ts index c315be7..44d0e93 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -11,14 +11,40 @@ import { type CaptunEnv = { CaptunServerShard: DurableObjectNamespace; + HostedRateLimiter?: DurableObjectNamespace; CAPTUN_SECRET?: string; SHARD_COUNT?: string; CUSTOM_HOSTNAME?: string; + HOSTED_RATE_LIMIT_WINDOW_SECONDS?: string; + HOSTED_CONNECTS_PER_IP_PER_WINDOW?: string; + HOSTED_REQUESTS_PER_IP_PER_WINDOW?: string; + HOSTED_REQUESTS_PER_TUNNEL_PER_WINDOW?: string; }; /** Set by the top-level Worker on the WebSocket-upgrade request so the DO knows the tunnel. */ const TUNNEL_NAME_HEADER = "x-captun-tunnel-name"; +const HOSTED_RATE_LIMITER_NAME = "global"; +const DEFAULT_HOSTED_RATE_LIMIT_WINDOW_SECONDS = 60; +const DEFAULT_HOSTED_CONNECTS_PER_IP_PER_WINDOW = 30; +const DEFAULT_HOSTED_REQUESTS_PER_IP_PER_WINDOW = 600; +const DEFAULT_HOSTED_REQUESTS_PER_TUNNEL_PER_WINDOW = 1200; + +type HostedRateLimitKind = "connect" | "request"; + +type HostedRateLimitInput = { + kind: HostedRateLimitKind; + clientKey: string; + tunnelName: string; +}; + +type HostedRateLimitResult = { ok: true } | { ok: false; limit: number; retryAfterSeconds: number }; + +type HostedRateLimitBucket = { + count: number; + resetAt: number; +}; + /** * A shard Durable Object owns many named tunnels. * @@ -71,8 +97,55 @@ export class CaptunServerShard extends DurableObject { } } +export class HostedRateLimiter extends DurableObject { + private readonly buckets = new Map(); + + check(input: HostedRateLimitInput): HostedRateLimitResult { + const config = hostedRateLimitConfig(this.env); + const now = Date.now(); + const resetAt = now + config.windowSeconds * 1000; + const checks = + input.kind === "connect" + ? [{ key: `connect:ip:${input.clientKey}`, limit: config.connectsPerIp }] + : [ + { key: `request:ip:${input.clientKey}`, limit: config.requestsPerIp }, + { key: `request:tunnel:${input.tunnelName}`, limit: config.requestsPerTunnel }, + ]; + + const blocked = checks + .map((check) => ({ ...check, bucket: this.activeBucket(check.key, now, resetAt) })) + .find((check) => check.bucket.count >= check.limit); + if (blocked) { + return { + ok: false, + limit: blocked.limit, + retryAfterSeconds: Math.max(1, Math.ceil((blocked.bucket.resetAt - now) / 1000)), + }; + } + + for (const check of checks) this.activeBucket(check.key, now, resetAt).count++; + this.cleanupExpiredBuckets(now); + return { ok: true }; + } + + private activeBucket(key: string, now: number, resetAt: number) { + const existing = this.buckets.get(key); + if (existing && existing.resetAt > now) return existing; + const bucket = { count: 0, resetAt }; + this.buckets.set(key, bucket); + return bucket; + } + + private cleanupExpiredBuckets(now: number) { + if (this.buckets.size < 10_000) return; + for (const [key, bucket] of this.buckets) { + if (bucket.resetAt <= now) this.buckets.delete(key); + } + } +} + export default { - fetch(request: Request, env: CaptunEnv): Response | Promise { + async fetch(request: Request, env: CaptunEnv): Promise { const hostedResponse = hostedCaptunResponse(request, env); if (hostedResponse) return hostedResponse; @@ -98,11 +171,27 @@ export default { const forwarded = new Request(url, request); if (forwardedPath === "/__captun-connect") { + const rateLimited = await hostedRateLimitResponse({ + env, + request, + tunnelName, + kind: "connect", + }); + if (rateLimited) return rateLimited; + const headers = new Headers(forwarded.headers); headers.set(TUNNEL_NAME_HEADER, tunnelName); return shard.fetch(new Request(forwarded, { headers })); } + const rateLimited = await hostedRateLimitResponse({ + env, + request, + tunnelName, + kind: "request", + }); + if (rateLimited) return rateLimited; + // Advertise the canonical tunnel URL back to the tunnel client. The CLI // reads this so it doesn't have to mirror the Worker's routing convention. const tunnelUrl = getTunnelUrl({ @@ -116,6 +205,70 @@ export default { }, } satisfies ExportedHandler; +async function hostedRateLimitResponse(input: { + env: CaptunEnv; + request: Request; + tunnelName: string; + kind: HostedRateLimitKind; +}): Promise { + if (input.env.CUSTOM_HOSTNAME !== HOSTED_CAPTUN_HOSTNAME) return undefined; + if (!input.env.HostedRateLimiter) return undefined; + + const limiter = input.env.HostedRateLimiter.getByName(HOSTED_RATE_LIMITER_NAME); + const result = await limiter.check({ + kind: input.kind, + clientKey: hostedClientKey(input.request), + tunnelName: input.tunnelName, + }); + if (result.ok) return undefined; + + return new Response(`Rate limit exceeded. Try again in ${result.retryAfterSeconds}s.\n`, { + status: 429, + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "no-store", + "retry-after": String(result.retryAfterSeconds), + "x-captun-rate-limit": String(result.limit), + }, + }); +} + +function hostedClientKey(request: Request) { + return ( + request.headers.get("cf-connecting-ip") || + request.headers.get("x-real-ip") || + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + "unknown" + ); +} + +function hostedRateLimitConfig(env: CaptunEnv) { + return { + windowSeconds: positiveInteger( + env.HOSTED_RATE_LIMIT_WINDOW_SECONDS, + DEFAULT_HOSTED_RATE_LIMIT_WINDOW_SECONDS, + ), + connectsPerIp: positiveInteger( + env.HOSTED_CONNECTS_PER_IP_PER_WINDOW, + DEFAULT_HOSTED_CONNECTS_PER_IP_PER_WINDOW, + ), + requestsPerIp: positiveInteger( + env.HOSTED_REQUESTS_PER_IP_PER_WINDOW, + DEFAULT_HOSTED_REQUESTS_PER_IP_PER_WINDOW, + ), + requestsPerTunnel: positiveInteger( + env.HOSTED_REQUESTS_PER_TUNNEL_PER_WINDOW, + DEFAULT_HOSTED_REQUESTS_PER_TUNNEL_PER_WINDOW, + ), + }; +} + +function positiveInteger(value: string | undefined, fallback: number) { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) return fallback; + return parsed; +} + function hostedCaptunResponse(request: Request, env: CaptunEnv): Response | undefined { if (env.CUSTOM_HOSTNAME !== HOSTED_CAPTUN_HOSTNAME) return undefined; diff --git a/tasks/hosted-rate-limits.md b/tasks/hosted-rate-limits.md index ca16076..c8fb31e 100644 --- a/tasks/hosted-rate-limits.md +++ b/tasks/hosted-rate-limits.md @@ -1,21 +1,21 @@ --- -status: in-progress +status: review size: medium --- # Hosted captun.sh rate limits -Status summary: Spec is being carved into a first reviewable slice. The intended first PR adds Worker-level hosted throttles with tests; ownership, paid/custom names, and deeper abuse controls remain follow-up work. +Status summary: First hosted throttling slice is implemented and locally verified. It adds hosted-only connect and forwarded-request limits with configurable Worker vars; ownership, paid/custom names, and deeper abuse controls remain follow-up work. ## First hosted throttling slice -- [ ] Add a hosted-only rate-limiter Durable Object. _Keep self-hosted deployments unaffected unless they opt into the hosted `CUSTOM_HOSTNAME=captun.sh` path._ -- [ ] Limit tunnel connect attempts per client IP. _Repeated `__captun-connect` upgrades from the same IP should eventually return `429` before reaching a shard._ -- [ ] Limit forwarded HTTP requests per client IP and per tunnel name. _A noisy tunnel or source IP should receive `429` without breaking unrelated tunnels._ -- [ ] Return useful `429` responses. _Include `Retry-After`, plain text body, and conservative no-store headers._ -- [ ] Make limits configurable by Worker vars. _Use safe defaults for the public deployment and allow tests to set tiny limits._ -- [ ] Cover limits in Miniflare tests. _Exercise connect throttles, forwarded-request throttles, hosted-only behavior, and reset behavior._ -- [ ] Deploy to `captun-public` after merge-ready checks. _Verify hosted public e2e still passes._ +- [x] Add a hosted-only rate-limiter Durable Object. _`HostedRateLimiter` is bound in `wrangler.jsonc` and only consulted when `CUSTOM_HOSTNAME=captun.sh`._ +- [x] Limit tunnel connect attempts per client IP. _`__captun-connect` requests check `connect:ip:` before shard dispatch._ +- [x] Limit forwarded HTTP requests per client IP and per tunnel name. _Forwarded hosted requests check both `request:ip:` and `request:tunnel:` buckets._ +- [x] Return useful `429` responses. _Hosted throttles return plain text with `Retry-After`, `cache-control: no-store`, and `x-captun-rate-limit`._ +- [x] Make limits configurable by Worker vars. _Window and connect/IP/tunnel limits are controlled by `HOSTED_*_PER_WINDOW` vars with public-service defaults._ +- [x] Cover limits in Miniflare tests. _`test/worker.test.ts` covers connect, per-IP request, per-tunnel request, and self-hosted bypass behavior._ +- [ ] Deploy to `captun-public` after merge-ready checks. _Not deployed yet; this stacked PR should deploy after review or on explicit request._ ## Follow-up safety work @@ -28,3 +28,5 @@ Status summary: Spec is being carved into a first reviewable slice. The intended ## Implementation Notes - 2026-05-23: Initial unsafe hosted service is intentionally live but obscure. This task starts the first throttling layer before publicising `captun.sh`. +- 2026-05-24: Implemented fixed-window in-memory buckets inside a single hosted rate-limiter Durable Object named `global`. This is intentionally a first abuse guardrail, not billing-grade quota accounting. +- 2026-05-24: Verified with `pnpm run check`, `pnpm test`, and `pnpm run build`. diff --git a/test/miniflare.ts b/test/miniflare.ts index cfaabe9..6e2edce 100644 --- a/test/miniflare.ts +++ b/test/miniflare.ts @@ -52,6 +52,7 @@ export function createCaptunWorkerFixture(bindings: Record) { entryPoint: "src/worker.ts", durableObjects: { CaptunServerShard: { className: "CaptunServerShard" }, + HostedRateLimiter: { className: "HostedRateLimiter" }, }, bindings, }); diff --git a/test/worker.test.ts b/test/worker.test.ts index a5a541f..2584993 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -325,6 +325,81 @@ test.each([ expect(await response.text()).toBe("Reserved captun.sh subdomain\n"); }); +test("Hosted Captun rate limits tunnel connect attempts per client IP", async () => { + await using fixture = await createCaptunWorkerFixture({ + CUSTOM_HOSTNAME: "captun.sh", + CAPTUN_SECRET: "secret", + HOSTED_CONNECTS_PER_IP_PER_WINDOW: "1", + }); + const headers = { "cf-connecting-ip": "203.0.113.10" }; + + const first = await fixture.worker.fetch("https://one.captun.sh/__captun-connect", { + headers, + }); + const second = await fixture.worker.fetch("https://two.captun.sh/__captun-connect", { + headers, + }); + + expect(first).toMatchObject({ status: 401 }); + expect(second).toMatchObject({ status: 429 }); + expect(second.headers.get("retry-after")).toBe("60"); + expect(second.headers.get("cache-control")).toBe("no-store"); + expect(await second.text()).toBe("Rate limit exceeded. Try again in 60s.\n"); +}); + +test("Hosted Captun rate limits forwarded requests per client IP", async () => { + await using fixture = await createCaptunWorkerFixture({ + CUSTOM_HOSTNAME: "captun.sh", + HOSTED_REQUESTS_PER_IP_PER_WINDOW: "2", + HOSTED_REQUESTS_PER_TUNNEL_PER_WINDOW: "100", + }); + const headers = { "cf-connecting-ip": "203.0.113.20" }; + + const first = await fixture.worker.fetch("https://one.captun.sh/hello", { headers }); + const second = await fixture.worker.fetch("https://two.captun.sh/hello", { headers }); + const third = await fixture.worker.fetch("https://three.captun.sh/hello", { headers }); + + expect(first).toMatchObject({ status: 503 }); + expect(second).toMatchObject({ status: 503 }); + expect(third).toMatchObject({ status: 429 }); + expect(third.headers.get("x-captun-rate-limit")).toBe("2"); +}); + +test("Hosted Captun rate limits forwarded requests per tunnel name", async () => { + await using fixture = await createCaptunWorkerFixture({ + CUSTOM_HOSTNAME: "captun.sh", + HOSTED_REQUESTS_PER_IP_PER_WINDOW: "100", + HOSTED_REQUESTS_PER_TUNNEL_PER_WINDOW: "1", + }); + + const first = await fixture.worker.fetch("https://one.captun.sh/hello", { + headers: { "cf-connecting-ip": "203.0.113.30" }, + }); + const second = await fixture.worker.fetch("https://one.captun.sh/hello", { + headers: { "cf-connecting-ip": "203.0.113.31" }, + }); + const otherTunnel = await fixture.worker.fetch("https://two.captun.sh/hello", { + headers: { "cf-connecting-ip": "203.0.113.31" }, + }); + + expect(first).toMatchObject({ status: 503 }); + expect(second).toMatchObject({ status: 429 }); + expect(otherTunnel).toMatchObject({ status: 503 }); +}); + +test("Hosted Captun rate limits do not affect self-hosted folder routing", async () => { + await using fixture = await createCaptunWorkerFixture({ + HOSTED_REQUESTS_PER_IP_PER_WINDOW: "1", + }); + const headers = { "cf-connecting-ip": "203.0.113.40" }; + + const first = await fixture.worker.fetch(`${fixture.origin}/one/hello`, { headers }); + const second = await fixture.worker.fetch(`${fixture.origin}/two/hello`, { headers }); + + expect(first).toMatchObject({ status: 503 }); + expect(second).toMatchObject({ status: 503 }); +}); + test("Captun Worker rejects missing tunnel names before Durable Object dispatch", async () => { await using fixture = await createCaptunWorkerFixture({}); diff --git a/wrangler.jsonc b/wrangler.jsonc index 5ca49da..b67f507 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -10,7 +10,13 @@ "head_sampling_rate": 1, }, "durable_objects": { - "bindings": [{ "name": "CaptunServerShard", "class_name": "CaptunServerShard" }], + "bindings": [ + { "name": "CaptunServerShard", "class_name": "CaptunServerShard" }, + { "name": "HostedRateLimiter", "class_name": "HostedRateLimiter" }, + ], }, - "migrations": [{ "tag": "v1", "new_sqlite_classes": ["CaptunServerShard"] }], + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["CaptunServerShard"] }, + { "tag": "v2", "new_sqlite_classes": ["HostedRateLimiter"] }, + ], } From cac2726485879cddcb75c14def2bb718b5690dab Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 23:01:47 +0000 Subject: [PATCH 3/4] [autofix.ci] apply automated fixes --- tasks/hosted-rate-limits.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks/hosted-rate-limits.md b/tasks/hosted-rate-limits.md index c8fb31e..89bc46f 100644 --- a/tasks/hosted-rate-limits.md +++ b/tasks/hosted-rate-limits.md @@ -13,7 +13,7 @@ Status summary: First hosted throttling slice is implemented and locally verifie - [x] Limit tunnel connect attempts per client IP. _`__captun-connect` requests check `connect:ip:` before shard dispatch._ - [x] Limit forwarded HTTP requests per client IP and per tunnel name. _Forwarded hosted requests check both `request:ip:` and `request:tunnel:` buckets._ - [x] Return useful `429` responses. _Hosted throttles return plain text with `Retry-After`, `cache-control: no-store`, and `x-captun-rate-limit`._ -- [x] Make limits configurable by Worker vars. _Window and connect/IP/tunnel limits are controlled by `HOSTED_*_PER_WINDOW` vars with public-service defaults._ +- [x] Make limits configurable by Worker vars. _Window and connect/IP/tunnel limits are controlled by `HOSTED_\*_PER_WINDOW` vars with public-service defaults._ - [x] Cover limits in Miniflare tests. _`test/worker.test.ts` covers connect, per-IP request, per-tunnel request, and self-hosted bypass behavior._ - [ ] Deploy to `captun-public` after merge-ready checks. _Not deployed yet; this stacked PR should deploy after review or on explicit request._ From a92a0a56505336305cb12ba2f07adabdff39753b Mon Sep 17 00:00:00 2001 From: Misha Kaletsky <15040698+mmkal@users.noreply.github.com> Date: Sun, 24 May 2026 00:07:57 +0100 Subject: [PATCH 4/4] Harden hosted rate limiter --- src/worker.ts | 108 +++++++++++++++++++++--------------- tasks/hosted-rate-limits.md | 7 ++- test/worker.test.ts | 61 +++++++++++++++++++- 3 files changed, 126 insertions(+), 50 deletions(-) diff --git a/src/worker.ts b/src/worker.ts index 44d0e93..32c7223 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -19,12 +19,12 @@ type CaptunEnv = { HOSTED_CONNECTS_PER_IP_PER_WINDOW?: string; HOSTED_REQUESTS_PER_IP_PER_WINDOW?: string; HOSTED_REQUESTS_PER_TUNNEL_PER_WINDOW?: string; + HOSTED_RATE_LIMIT_DISABLED?: string; }; /** Set by the top-level Worker on the WebSocket-upgrade request so the DO knows the tunnel. */ const TUNNEL_NAME_HEADER = "x-captun-tunnel-name"; -const HOSTED_RATE_LIMITER_NAME = "global"; const DEFAULT_HOSTED_RATE_LIMIT_WINDOW_SECONDS = 60; const DEFAULT_HOSTED_CONNECTS_PER_IP_PER_WINDOW = 30; const DEFAULT_HOSTED_REQUESTS_PER_IP_PER_WINDOW = 600; @@ -32,11 +32,7 @@ const DEFAULT_HOSTED_REQUESTS_PER_TUNNEL_PER_WINDOW = 1200; type HostedRateLimitKind = "connect" | "request"; -type HostedRateLimitInput = { - kind: HostedRateLimitKind; - clientKey: string; - tunnelName: string; -}; +type HostedRateLimitInput = { limit: number; windowSeconds: number }; type HostedRateLimitResult = { ok: true } | { ok: false; limit: number; retryAfterSeconds: number }; @@ -98,50 +94,29 @@ export class CaptunServerShard extends DurableObject { } export class HostedRateLimiter extends DurableObject { - private readonly buckets = new Map(); + private bucket: HostedRateLimitBucket | undefined; check(input: HostedRateLimitInput): HostedRateLimitResult { - const config = hostedRateLimitConfig(this.env); const now = Date.now(); - const resetAt = now + config.windowSeconds * 1000; - const checks = - input.kind === "connect" - ? [{ key: `connect:ip:${input.clientKey}`, limit: config.connectsPerIp }] - : [ - { key: `request:ip:${input.clientKey}`, limit: config.requestsPerIp }, - { key: `request:tunnel:${input.tunnelName}`, limit: config.requestsPerTunnel }, - ]; - - const blocked = checks - .map((check) => ({ ...check, bucket: this.activeBucket(check.key, now, resetAt) })) - .find((check) => check.bucket.count >= check.limit); - if (blocked) { + const bucket = this.activeBucket(now, now + input.windowSeconds * 1000); + if (bucket.count >= input.limit) { return { ok: false, - limit: blocked.limit, - retryAfterSeconds: Math.max(1, Math.ceil((blocked.bucket.resetAt - now) / 1000)), + limit: input.limit, + retryAfterSeconds: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)), }; } - for (const check of checks) this.activeBucket(check.key, now, resetAt).count++; - this.cleanupExpiredBuckets(now); + bucket.count++; return { ok: true }; } - private activeBucket(key: string, now: number, resetAt: number) { - const existing = this.buckets.get(key); - if (existing && existing.resetAt > now) return existing; + private activeBucket(now: number, resetAt: number) { + if (this.bucket && this.bucket.resetAt > now) return this.bucket; const bucket = { count: 0, resetAt }; - this.buckets.set(key, bucket); + this.bucket = bucket; return bucket; } - - private cleanupExpiredBuckets(now: number) { - if (this.buckets.size < 10_000) return; - for (const [key, bucket] of this.buckets) { - if (bucket.resetAt <= now) this.buckets.delete(key); - } - } } export default { @@ -212,16 +187,37 @@ async function hostedRateLimitResponse(input: { kind: HostedRateLimitKind; }): Promise { if (input.env.CUSTOM_HOSTNAME !== HOSTED_CAPTUN_HOSTNAME) return undefined; - if (!input.env.HostedRateLimiter) return undefined; + if (!input.env.HostedRateLimiter) { + if (input.env.HOSTED_RATE_LIMIT_DISABLED === "1") return undefined; + return new Response("Hosted rate limiter is not configured\n", { + status: 503, + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "no-store", + }, + }); + } - const limiter = input.env.HostedRateLimiter.getByName(HOSTED_RATE_LIMITER_NAME); - const result = await limiter.check({ + const config = hostedRateLimitConfig(input.env); + const checks = hostedRateLimitChecks({ kind: input.kind, clientKey: hostedClientKey(input.request), tunnelName: input.tunnelName, + config, }); - if (result.ok) return undefined; + for (const check of checks) { + const limiter = input.env.HostedRateLimiter.getByName(hostedRateLimiterName(check.key)); + const result = await limiter.check({ + limit: check.limit, + windowSeconds: config.windowSeconds, + }); + if (!result.ok) return hostedRateLimitedResponse(result); + } + + return undefined; +} +function hostedRateLimitedResponse(result: Extract) { return new Response(`Rate limit exceeded. Try again in ${result.retryAfterSeconds}s.\n`, { status: 429, headers: { @@ -234,12 +230,32 @@ async function hostedRateLimitResponse(input: { } function hostedClientKey(request: Request) { - return ( - request.headers.get("cf-connecting-ip") || - request.headers.get("x-real-ip") || - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - "unknown" - ); + return request.headers.get("cf-connecting-ip") || "unknown"; +} + +function hostedRateLimitChecks(input: { + kind: HostedRateLimitKind; + clientKey: string; + tunnelName: string; + config: ReturnType; +}) { + if (input.kind === "connect") { + return [{ key: `connect:ip:${input.clientKey}`, limit: input.config.connectsPerIp }]; + } + + return [ + { key: `request:ip:${input.clientKey}`, limit: input.config.requestsPerIp }, + { key: `request:tunnel:${input.tunnelName}`, limit: input.config.requestsPerTunnel }, + ]; +} + +function hostedRateLimiterName(key: string) { + let hash = 2166136261; + for (let index = 0; index < key.length; index++) { + hash ^= key.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return `bucket-${(hash >>> 0).toString(36)}`; } function hostedRateLimitConfig(env: CaptunEnv) { diff --git a/tasks/hosted-rate-limits.md b/tasks/hosted-rate-limits.md index c8fb31e..a66850a 100644 --- a/tasks/hosted-rate-limits.md +++ b/tasks/hosted-rate-limits.md @@ -13,7 +13,7 @@ Status summary: First hosted throttling slice is implemented and locally verifie - [x] Limit tunnel connect attempts per client IP. _`__captun-connect` requests check `connect:ip:` before shard dispatch._ - [x] Limit forwarded HTTP requests per client IP and per tunnel name. _Forwarded hosted requests check both `request:ip:` and `request:tunnel:` buckets._ - [x] Return useful `429` responses. _Hosted throttles return plain text with `Retry-After`, `cache-control: no-store`, and `x-captun-rate-limit`._ -- [x] Make limits configurable by Worker vars. _Window and connect/IP/tunnel limits are controlled by `HOSTED_*_PER_WINDOW` vars with public-service defaults._ +- [x] Make limits configurable by Worker vars. _Window and connect/IP/tunnel limits are controlled by `HOSTED_\*_PER_WINDOW` vars with public-service defaults._ - [x] Cover limits in Miniflare tests. _`test/worker.test.ts` covers connect, per-IP request, per-tunnel request, and self-hosted bypass behavior._ - [ ] Deploy to `captun-public` after merge-ready checks. _Not deployed yet; this stacked PR should deploy after review or on explicit request._ @@ -28,5 +28,6 @@ Status summary: First hosted throttling slice is implemented and locally verifie ## Implementation Notes - 2026-05-23: Initial unsafe hosted service is intentionally live but obscure. This task starts the first throttling layer before publicising `captun.sh`. -- 2026-05-24: Implemented fixed-window in-memory buckets inside a single hosted rate-limiter Durable Object named `global`. This is intentionally a first abuse guardrail, not billing-grade quota accounting. -- 2026-05-24: Verified with `pnpm run check`, `pnpm test`, and `pnpm run build`. +- 2026-05-24: Implemented fixed-window in-memory buckets in hosted rate-limiter Durable Objects named by hashed bucket key. This is intentionally a first abuse guardrail, not billing-grade quota accounting. +- 2026-05-24: Review follow-up changed the limiter to fail closed when the binding is missing, added an explicit `HOSTED_RATE_LIMIT_DISABLED=1` escape hatch, and stopped trusting spoofable forwarded-IP headers. +- 2026-05-24: Verified with `pnpm run check`, `pnpm test`, `pnpm run build`, and `CAPTUN_PUBLIC_E2E=1 pnpm vitest run test/public-hosted.test.ts` after retrying one transient live WebSocket-open failure. diff --git a/test/worker.test.ts b/test/worker.test.ts index 2584993..127afa8 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -7,7 +7,7 @@ import { getTunnelUrl, TUNNEL_URL_HEADER, } from "../src/routing.js"; -import { createCaptunWorkerFixture } from "./miniflare.js"; +import { createCaptunWorkerFixture, createMiniflareWorkerFixture } from "./miniflare.js"; const tunnelNameCases: Array< [url: string, customHostname: string | undefined, name: string | null] @@ -400,6 +400,65 @@ test("Hosted Captun rate limits do not affect self-hosted folder routing", async expect(second).toMatchObject({ status: 503 }); }); +test("Hosted Captun fails closed when the rate limiter binding is missing", async () => { + await using fixture = await createMiniflareWorkerFixture({ + entryPoint: "src/worker.ts", + durableObjects: { + CaptunServerShard: { className: "CaptunServerShard" }, + }, + bindings: { CUSTOM_HOSTNAME: "captun.sh" }, + }); + + const response = await fixture.worker.fetch("https://one.captun.sh/hello", { + headers: { "cf-connecting-ip": "203.0.113.50" }, + }); + + expect(response).toMatchObject({ status: 503 }); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.text()).toBe("Hosted rate limiter is not configured\n"); +}); + +test("Hosted Captun only bypasses a missing rate limiter binding when explicitly disabled", async () => { + await using fixture = await createMiniflareWorkerFixture({ + entryPoint: "src/worker.ts", + durableObjects: { + CaptunServerShard: { className: "CaptunServerShard" }, + }, + bindings: { + CUSTOM_HOSTNAME: "captun.sh", + HOSTED_RATE_LIMIT_DISABLED: "1", + HOSTED_REQUESTS_PER_IP_PER_WINDOW: "1", + }, + }); + + const first = await fixture.worker.fetch("https://one.captun.sh/hello", { + headers: { "cf-connecting-ip": "203.0.113.51" }, + }); + const second = await fixture.worker.fetch("https://two.captun.sh/hello", { + headers: { "cf-connecting-ip": "203.0.113.51" }, + }); + + expect(first).toMatchObject({ status: 503 }); + expect(second).toMatchObject({ status: 503 }); +}); + +test("Hosted Captun does not trust spoofable forwarded IP headers for rate limiting", async () => { + await using fixture = await createCaptunWorkerFixture({ + CUSTOM_HOSTNAME: "captun.sh", + HOSTED_REQUESTS_PER_IP_PER_WINDOW: "1", + }); + + const first = await fixture.worker.fetch("https://one.captun.sh/hello", { + headers: { "x-forwarded-for": "203.0.113.60" }, + }); + const second = await fixture.worker.fetch("https://two.captun.sh/hello", { + headers: { "x-forwarded-for": "203.0.113.61" }, + }); + + expect(first).toMatchObject({ status: 503 }); + expect(second).toMatchObject({ status: 429 }); +}); + test("Captun Worker rejects missing tunnel names before Durable Object dispatch", async () => { await using fixture = await createCaptunWorkerFixture({});