Skip to content

Commit 6e45067

Browse files
committed
TC-85: security review fixes -- body/frame size limits, connection rate limiting, ordered-pair headers
Addresses the round-1 review of the tunnel relay (PR #1): 1. Body/frame size limits: proxied request bodies are now read with a streaming cap (host-router.ts's readLimitedBody, 413 past the cap) and response bodies accumulated in proxy.ts are capped too (502 + an `error` frame back to the node past the cap). Default 25MB, overridable via TUNNEL_MAX_BODY_BYTES. The relay's WebSocketServer now sets `maxPayload` (1MB, protocol.ts's MAX_FRAME_PAYLOAD_BYTES) so no single WS frame can be unbounded; request bodies are chunked into <=256KB requestBody frames (protocol.ts's BODY_CHUNK_BYTES) to respect it. Also adds a 'ws' `error` listener in upgrade.ts -- without one, a maxPayload violation's unhandled 'error' event would crash the whole process, not just the one tunnel. 2. Rate/connection limiting on the WS upgrade path (upgrade.ts, new rate-limit.ts): a per-IP connection-attempt limiter (30/min), a per-name churn limiter (10/min), and a global concurrent-tunnel cap (1000, env TUNNEL_MAX_CONCURRENT) all run in the raw HTTP `upgrade` handler, before the WS handshake and before authenticate()'s nameStore.get Postgres read. Deliberately in-memory (not the Postgres-backed cert rate limiter): these are short, per-minute windows checked on every upgrade attempt. 3. Frame headers are now ordered `[name, value]` pairs (protocol.ts's TunnelRequestFrame/TunnelResponseFrame) instead of a Record, so duplicate header names -- most importantly Set-Cookie -- survive the roundtrip instead of colliding on one object key. host-router.ts rebuilds the final client-facing Response via repeated Headers.append() calls so multiple Set-Cookie entries are preserved end to end. Tests: 9 new (66 -> 75) covering each fix -- request/response body caps, multi-chunk body reassembly, WS maxPayload enforcement, per-IP/per-name/ global connection limits, header-pair passthrough, and a two-Set-Cookie roundtrip.
1 parent dd60632 commit 6e45067

9 files changed

Lines changed: 495 additions & 38 deletions

File tree

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,10 @@ ATTESTATION_DOCUMENT=
2626
# else under *.tinycloud.link are routed through the matching tunnel (TC-85)
2727
# instead of the /v1 API. Defaults to api.tinycloud.link.
2828
API_HOSTNAME=api.tinycloud.link
29+
30+
# Max bytes accepted for a single proxied tunnel request body. Defaults to
31+
# 26214400 (25MB).
32+
TUNNEL_MAX_BODY_BYTES=
33+
# Max concurrently registered (authenticated) tunnels; further connection
34+
# attempts are dropped once at the cap. Defaults to 1000.
35+
TUNNEL_MAX_CONCURRENT=

src/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ const cloudflareZoneId = process.env.CLOUDFLARE_ZONE_ID;
1515
const acmeEmail = process.env.ACME_EMAIL;
1616
const acmeDirectory =
1717
process.env.ACME_DIRECTORY ?? "https://acme-staging-v02.api.letsencrypt.org/directory";
18+
const tunnelMaxBodyBytes = process.env.TUNNEL_MAX_BODY_BYTES
19+
? Number.parseInt(process.env.TUNNEL_MAX_BODY_BYTES, 10)
20+
: undefined;
21+
const tunnelMaxConcurrent = process.env.TUNNEL_MAX_CONCURRENT
22+
? Number.parseInt(process.env.TUNNEL_MAX_CONCURRENT, 10)
23+
: undefined;
1824

1925
if (!databaseUrl) throw new Error("DATABASE_URL is required");
2026
if (!cloudflareApiToken) throw new Error("CLOUDFLARE_API_TOKEN is required");
@@ -59,10 +65,15 @@ const app = createServer({
5965
attestationDocument: process.env.ATTESTATION_DOCUMENT,
6066
tunnelRegistry,
6167
apiHostname,
68+
tunnelMaxBodyBytes,
6269
});
6370

6471
const server = serve({ fetch: app.fetch, port });
65-
attachTunnelUpgrade(server, { registry: tunnelRegistry, nameStore });
72+
attachTunnelUpgrade(server, {
73+
registry: tunnelRegistry,
74+
nameStore,
75+
maxConcurrentTunnels: tunnelMaxConcurrent,
76+
});
6677
console.log(`tinycloud-link listening on :${port}`);
6778

6879
const shutdown = async () => {

src/server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export interface ServerConfig {
3131
tunnelRegistry?: TunnelRegistry;
3232
/** The control-plane API's own hostname, exempted from tunnel routing. Defaults to "api.tinycloud.link". */
3333
apiHostname?: string;
34+
/** Max bytes accepted for a proxied tunnel request body. Defaults to protocol.ts's DEFAULT_MAX_BODY_BYTES (25MB); overridable via the TUNNEL_MAX_BODY_BYTES env var (see index.ts). */
35+
tunnelMaxBodyBytes?: number;
3436
}
3537

3638
// Prefixes name-update entries so they share the cert rate-limit store without
@@ -49,6 +51,7 @@ export function createServer(config: ServerConfig): Hono {
4951
"*",
5052
createTunnelMiddleware(config.tunnelRegistry, {
5153
apiHostname: config.apiHostname ?? DEFAULT_API_HOSTNAME,
54+
maxBodyBytes: config.tunnelMaxBodyBytes,
5255
})
5356
);
5457
}

src/tunnel.test.ts

Lines changed: 266 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,25 @@ import { createServer } from "./server.js";
1111
import { FakeAcmeClient } from "./test-support/fake-acme-client.js";
1212
import { InMemoryCertRateLimiter, InMemoryNameStore } from "./test-support/memory-stores.js";
1313
import { didKeySigner, type Signer } from "./test-support/signing.js";
14-
import { encodeFrame, parseFrame } from "./tunnel/protocol.js";
14+
import { MAX_FRAME_PAYLOAD_BYTES, encodeFrame, parseFrame } from "./tunnel/protocol.js";
1515
import { SUPERSEDED_CLOSE_CODE, TunnelRegistry } from "./tunnel/registry.js";
1616
import {
1717
CLOSE_INVALID_SIGNATURE,
1818
CLOSE_NAME_NOT_CLAIMED,
1919
CLOSE_NOT_OWNER,
2020
CLOSE_STALE_SEQUENCE,
21+
type AttachTunnelUpgradeOptions,
2122
attachTunnelUpgrade,
2223
} from "./tunnel/upgrade.js";
2324

2425
const API_HOSTNAME = "api.tinycloud.link";
2526

26-
async function startTunnelServer() {
27+
type UpgradeOverrides = Partial<Omit<AttachTunnelUpgradeOptions, "registry" | "nameStore">>;
28+
29+
async function startTunnelServer(
30+
upgradeOverrides: UpgradeOverrides = {},
31+
tunnelMaxBodyBytes?: number
32+
) {
2733
const nameStore = new InMemoryNameStore();
2834
const dnsProvider = new InMemoryDnsProvider();
2935
const rateLimiter = new InMemoryCertRateLimiter();
@@ -44,12 +50,13 @@ async function startTunnelServer() {
4450
rateLimiter,
4551
tunnelRegistry: registry,
4652
apiHostname: API_HOSTNAME,
53+
tunnelMaxBodyBytes,
4754
});
4855

4956
const server = await new Promise<ReturnType<typeof serve>>((resolve) => {
5057
const s = serve({ fetch: app.fetch, port: 0 }, () => resolve(s));
5158
});
52-
attachTunnelUpgrade(server, { registry, nameStore, authTimeoutMs: 1000 });
59+
attachTunnelUpgrade(server, { registry, nameStore, authTimeoutMs: 1000, ...upgradeOverrides });
5360
const port = (server.address() as AddressInfo).port;
5461

5562
const sockets = new Set<WebSocket>();
@@ -78,15 +85,28 @@ async function startTunnelServer() {
7885
type TunnelTestHarness = Awaited<ReturnType<typeof startTunnelServer>>;
7986

8087
/** Runs a test body against a fresh harness, guaranteeing the server (and any sockets it opened) is torn down even if the body throws. */
81-
async function withHarness(body: (harness: TunnelTestHarness) => Promise<void>): Promise<void> {
82-
const harness = await startTunnelServer();
88+
async function withHarness(
89+
body: (harness: TunnelTestHarness) => Promise<void>,
90+
upgradeOverrides: UpgradeOverrides = {},
91+
tunnelMaxBodyBytes?: number
92+
): Promise<void> {
93+
const harness = await startTunnelServer(upgradeOverrides, tunnelMaxBodyBytes);
8394
try {
8495
await body(harness);
8596
} finally {
8697
await harness.close();
8798
}
8899
}
89100

101+
/** Resolves true if the WS connection is rejected (errors or closes) before ever reaching 'open', false if it opens. Used to assert an upgrade-time limiter dropped the connection pre-handshake. */
102+
function connectionWasRejected(ws: WebSocket): Promise<boolean> {
103+
return new Promise((resolve) => {
104+
ws.once("open", () => resolve(false));
105+
ws.once("error", () => resolve(true));
106+
ws.once("close", () => resolve(true));
107+
});
108+
}
109+
90110
async function claim(app: Hono, name: string, signer: Signer, sequence: number): Promise<void> {
91111
const unsigned = {
92112
version: 1 as const,
@@ -153,7 +173,7 @@ function runEchoNode(ws: WebSocket): void {
153173
type: "response",
154174
id,
155175
status: 200,
156-
headers: { "content-type": "text/plain" },
176+
headers: [["content-type", "text/plain"]],
157177
})
158178
);
159179
ws.send(encodeFrame({ type: "responseBody", id, chunk: frame.chunk, done: true }));
@@ -291,3 +311,243 @@ test("newest tunnel connection wins: the older socket is evicted and the new one
291311
});
292312
assert.equal(res.status, 200);
293313
}));
314+
315+
test("request headers travel to the node as an array of [name, value] pairs", () =>
316+
withHarness(async (harness) => {
317+
const signer = didKeySigner(111);
318+
await claim(harness.app, "headerscheck", signer, 1);
319+
320+
const ws = harness.connect("headerscheck");
321+
const ackPromise = waitForMessage(ws);
322+
await sendAuth(ws, signer, "headerscheck", 2);
323+
await ackPromise;
324+
325+
let capturedHeaders: Array<[string, string]> | undefined;
326+
ws.on("message", (data) => {
327+
const frame = parseFrame(data.toString());
328+
if (frame.type === "request") {
329+
capturedHeaders = frame.headers;
330+
}
331+
});
332+
runEchoNode(ws);
333+
334+
const res = await harness.app.request("/x", {
335+
headers: { host: "headerscheck.tinycloud.link", "x-custom": "hello" },
336+
});
337+
assert.equal(res.status, 200);
338+
assert.ok(Array.isArray(capturedHeaders));
339+
assert.ok(capturedHeaders?.some(([key, value]) => key.toLowerCase() === "x-custom" && value === "hello"));
340+
}));
341+
342+
test("duplicate Set-Cookie response headers survive the tunnel roundtrip", () =>
343+
withHarness(async (harness) => {
344+
const signer = didKeySigner(112);
345+
await claim(harness.app, "cookienode", signer, 1);
346+
347+
const ws = harness.connect("cookienode");
348+
const ackPromise = waitForMessage(ws);
349+
await sendAuth(ws, signer, "cookienode", 2);
350+
await ackPromise;
351+
352+
ws.on("message", (data) => {
353+
const frame = parseFrame(data.toString());
354+
if (frame.type !== "requestBody" || !frame.done) return;
355+
const { id } = frame;
356+
ws.send(
357+
encodeFrame({
358+
type: "response",
359+
id,
360+
status: 200,
361+
headers: [
362+
["set-cookie", "a=1"],
363+
["set-cookie", "b=2"],
364+
],
365+
})
366+
);
367+
ws.send(encodeFrame({ type: "responseBody", id, chunk: "", done: true }));
368+
});
369+
370+
const res = await harness.app.request("/set-cookies", {
371+
headers: { host: "cookienode.tinycloud.link" },
372+
});
373+
assert.equal(res.status, 200);
374+
assert.deepEqual(res.headers.getSetCookie(), ["a=1", "b=2"]);
375+
}));
376+
377+
test("a request body larger than one body-frame chunk is split across multiple requestBody frames and reassembles correctly", () =>
378+
withHarness(async (harness) => {
379+
const signer = didKeySigner(113);
380+
await claim(harness.app, "bigbody", signer, 1);
381+
382+
const ws = harness.connect("bigbody");
383+
const ackPromise = waitForMessage(ws);
384+
await sendAuth(ws, signer, "bigbody", 2);
385+
await ackPromise;
386+
387+
// Larger than protocol.ts's BODY_CHUNK_BYTES (256KB), so the relay must
388+
// split it across at least two requestBody frames.
389+
const bigBody = "x".repeat(300 * 1024);
390+
391+
let requestBodyFrameCount = 0;
392+
const chunks: string[] = [];
393+
ws.on("message", (data) => {
394+
const frame = parseFrame(data.toString());
395+
if (frame.type !== "requestBody") return;
396+
requestBodyFrameCount += 1;
397+
chunks.push(frame.chunk);
398+
if (!frame.done) return;
399+
const id = frame.id;
400+
const body = Buffer.concat(chunks.map((c) => Buffer.from(c, "base64"))).toString("utf8");
401+
ws.send(encodeFrame({ type: "response", id, status: 200, headers: [["content-type", "text/plain"]] }));
402+
ws.send(encodeFrame({ type: "responseBody", id, chunk: Buffer.from(body).toString("base64"), done: true }));
403+
});
404+
405+
const res = await harness.app.request("/upload", {
406+
method: "POST",
407+
headers: { host: "bigbody.tinycloud.link" },
408+
body: bigBody,
409+
});
410+
assert.equal(res.status, 200);
411+
assert.equal(await res.text(), bigBody);
412+
assert.ok(requestBodyFrameCount > 1, `expected multiple requestBody frames, got ${requestBodyFrameCount}`);
413+
}));
414+
415+
test("a request body over the configured limit is rejected with 413 before reaching the tunnel", () =>
416+
withHarness(
417+
async (harness) => {
418+
const signer = didKeySigner(114);
419+
await claim(harness.app, "toobig", signer, 1);
420+
421+
const ws = harness.connect("toobig");
422+
const ackPromise = waitForMessage(ws);
423+
await sendAuth(ws, signer, "toobig", 2);
424+
await ackPromise;
425+
runEchoNode(ws);
426+
427+
const res = await harness.app.request("/upload", {
428+
method: "POST",
429+
headers: { host: "toobig.tinycloud.link" },
430+
body: "x".repeat(200),
431+
});
432+
assert.equal(res.status, 413);
433+
},
434+
{},
435+
100 // TUNNEL_MAX_BODY_BYTES override for this test
436+
));
437+
438+
test("a response body over the configured limit is aborted with 502 and the node is told via an error frame", () =>
439+
withHarness(
440+
async (harness) => {
441+
const signer = didKeySigner(115);
442+
await claim(harness.app, "hugeresponse", signer, 1);
443+
444+
const ws = harness.connect("hugeresponse");
445+
const ackPromise = waitForMessage(ws);
446+
await sendAuth(ws, signer, "hugeresponse", 2);
447+
await ackPromise;
448+
449+
let sawErrorFrame = false;
450+
ws.on("message", (data) => {
451+
const frame = parseFrame(data.toString());
452+
if (frame.type === "error") {
453+
sawErrorFrame = true;
454+
return;
455+
}
456+
if (frame.type !== "requestBody" || !frame.done) return;
457+
const { id } = frame;
458+
ws.send(encodeFrame({ type: "response", id, status: 200, headers: [] }));
459+
// Stream a body well past the 100-byte test limit.
460+
ws.send(encodeFrame({ type: "responseBody", id, chunk: Buffer.from("x".repeat(200)).toString("base64"), done: false }));
461+
ws.send(encodeFrame({ type: "responseBody", id, chunk: "", done: true }));
462+
});
463+
464+
const res = await harness.app.request("/download", {
465+
headers: { host: "hugeresponse.tinycloud.link" },
466+
});
467+
assert.equal(res.status, 502);
468+
// Give the node's message handler a tick to observe the error frame the relay sent back.
469+
await new Promise((resolve) => setTimeout(resolve, 50));
470+
assert.equal(sawErrorFrame, true);
471+
},
472+
{},
473+
100 // TUNNEL_MAX_BODY_BYTES override for this test
474+
));
475+
476+
test("the WebSocketServer enforces a max frame payload: an oversized single frame from the node closes the tunnel", () =>
477+
withHarness(async (harness) => {
478+
const signer = didKeySigner(116);
479+
await claim(harness.app, "oversizedframe", signer, 1);
480+
481+
const ws = harness.connect("oversizedframe");
482+
const ackPromise = waitForMessage(ws);
483+
await sendAuth(ws, signer, "oversizedframe", 2);
484+
await ackPromise;
485+
486+
const closed = waitForClose(ws);
487+
const oversizedChunk = "a".repeat(MAX_FRAME_PAYLOAD_BYTES + 1024);
488+
ws.send(encodeFrame({ type: "responseBody", id: "irrelevant", chunk: oversizedChunk, done: true }));
489+
490+
const { code } = await closed;
491+
assert.equal(code, 1009); // RFC 6455 CLOSE_TOO_LARGE
492+
}));
493+
494+
test("per-IP connection attempts beyond the configured limit are dropped before the WS handshake completes", () =>
495+
withHarness(
496+
async (harness) => {
497+
const signer = didKeySigner(117);
498+
await claim(harness.app, "ratelimited", signer, 1);
499+
500+
const first = harness.connect("ratelimited");
501+
assert.equal(await connectionWasRejected(first), false);
502+
first.terminate();
503+
504+
const second = harness.connect("ratelimited");
505+
assert.equal(await connectionWasRejected(second), false);
506+
second.terminate();
507+
508+
// The limit is 2/minute; this third attempt within the window must be dropped pre-handshake.
509+
const third = harness.connect("ratelimited");
510+
assert.equal(await connectionWasRejected(third), true);
511+
},
512+
{ ipConnectionLimitPerMinute: 2 }
513+
));
514+
515+
test("per-name churn beyond the configured limit drops further connection attempts for that name", () =>
516+
withHarness(
517+
async (harness) => {
518+
const signer = didKeySigner(118);
519+
await claim(harness.app, "churny", signer, 1);
520+
521+
const first = harness.connect("churny");
522+
assert.equal(await connectionWasRejected(first), false);
523+
first.terminate();
524+
525+
const second = harness.connect("churny");
526+
assert.equal(await connectionWasRejected(second), false);
527+
second.terminate();
528+
529+
// The name-churn limit is 2/minute; this third attempt for the same name must be dropped.
530+
const third = harness.connect("churny");
531+
assert.equal(await connectionWasRejected(third), true);
532+
},
533+
{ ipConnectionLimitPerMinute: 100, nameChurnLimitPerMinute: 2 }
534+
));
535+
536+
test("a global concurrent-tunnel cap drops further connection attempts once reached", () =>
537+
withHarness(
538+
async (harness) => {
539+
const ownerA = didKeySigner(119);
540+
const ownerB = didKeySigner(120);
541+
await claim(harness.app, "capped-a", ownerA, 1);
542+
await claim(harness.app, "capped-b", ownerB, 1);
543+
544+
const first = harness.connect("capped-a");
545+
const firstAck = waitForMessage(first);
546+
await sendAuth(first, ownerA, "capped-a", 2);
547+
await firstAck; // registry.size() === 1, at the configured cap.
548+
549+
const second = harness.connect("capped-b");
550+
assert.equal(await connectionWasRejected(second), true);
551+
},
552+
{ maxConcurrentTunnels: 1 }
553+
));

0 commit comments

Comments
 (0)