@@ -11,19 +11,25 @@ import { createServer } from "./server.js";
1111import { FakeAcmeClient } from "./test-support/fake-acme-client.js" ;
1212import { InMemoryCertRateLimiter , InMemoryNameStore } from "./test-support/memory-stores.js" ;
1313import { 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" ;
1515import { SUPERSEDED_CLOSE_CODE , TunnelRegistry } from "./tunnel/registry.js" ;
1616import {
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
2425const 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() {
7885type 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+
90110async 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