@@ -209,7 +209,31 @@ async def wipe(_request):
209209 seen ["todos" ] = []
210210 return web .json_response ({"ok" : True })
211211
212+ # The app's native API as a typical adopted codebase ships it: wide-open
213+ # CORS (the thing the proxy must NOT reflect) and more than one cookie.
214+ async def echo (request ):
215+ seen .setdefault ("echo" , []).append (request .method )
216+ resp = web .json_response ({"method" : request .method })
217+ resp .headers ["Access-Control-Allow-Origin" ] = "*"
218+ resp .headers ["Access-Control-Allow-Credentials" ] = "true"
219+ resp .headers ["Access-Control-Allow-Methods" ] = "GET, POST, PUT, PATCH, DELETE"
220+ resp .headers ["Access-Control-Allow-Headers" ] = "Content-Type, X-Custom"
221+ resp .headers ["Vary" ] = "Accept-Encoding"
222+ resp .set_cookie ("app_a" , "1" )
223+ resp .set_cookie ("app_b" , "2" )
224+ return resp
225+
226+ async def ws_echo (request ):
227+ ws = web .WebSocketResponse ()
228+ await ws .prepare (request )
229+ seen .setdefault ("ws" , []).append (request .headers .get ("Origin" , "" ))
230+ async for msg in ws :
231+ await ws .send_str ("echo:" + msg .data )
232+ return ws
233+
212234 app = web .Application ()
235+ app .router .add_route ("*" , "/api/echo" , echo )
236+ app .router .add_get ("/ws" , ws_echo )
213237 app .router .add_get ("/" , root )
214238 app .router .add_post ("/api/todos" , create_todo )
215239 app .router .add_get ("/api/todos" , list_todos )
@@ -376,6 +400,153 @@ async def post(headers, cookie=None, title="x"):
376400 assert titles == ["agent" , "agent+origin" , "legacy" , "ui" , "visitor" , "remote agent" ], titles
377401
378402
403+ async def _passthrough_matrix (http , base : str , tmp : Path , seen ) -> None :
404+ """The app's NATIVE surface (everything not /api/_a2app*, /api/_ops,
405+ /api/ops/*) under the same guard: it used to be passed through with no
406+ Origin or caller check, the app's own CORS reflected verbatim, and its
407+ WebSocket open to any site (browsers apply no CORS to the handshake)."""
408+ import aiohttp
409+
410+ echo = f"{ base } /api/echo"
411+ ws_url = f"ws://127.0.0.1:{ PROXY_PORT } /ws"
412+ loopback = {"Origin" : f"http://127.0.0.1:{ PROXY_PORT } " }
413+ evil = {"Origin" : "https://evil.example" }
414+
415+ def jar (cookie ):
416+ return {"Cookie" : f"{ cookie [0 ]} ={ cookie [1 ]} " }
417+
418+ async def send (method , headers , url = echo ):
419+ before = len (seen .get ("echo" , []))
420+ async with http .request (method , url , json = {"t" : 1 }, headers = headers ) as r :
421+ body = await r .json ()
422+ reached = len (seen .get ("echo" , [])) > before
423+ return r .status , body , reached
424+
425+ async def ws (headers ):
426+ """(status, echoed) — status 101 on upgrade, else the refusal."""
427+ try :
428+ async with http .ws_connect (ws_url , headers = headers ) as w :
429+ await w .send_str ("hi" )
430+ msg = await w .receive (timeout = 5 )
431+ return 101 , msg .data
432+ except aiohttp .WSServerHandshakeError as e :
433+ return e .status , None
434+
435+ async with http .get (f"{ base } /" ) as r :
436+ local = _set_cookie (r )
437+
438+ # ── HTTP writes: foreign origins refused, callers must hold a credential
439+ for method in ("POST" , "PUT" , "PATCH" , "DELETE" ):
440+ status , body , reached = await send (method , evil )
441+ assert status == 403 and body ["code" ] == "forbidden_origin" , (method , status )
442+ assert not reached , f"foreign-origin { method } reached the app"
443+ status , _ , reached = await send ("POST" , {** evil , "X-A2App-Token" : TOKEN })
444+ assert status == 403 and not reached , "a token does not launder a foreign origin"
445+ status , _ , reached = await send ("POST" , {** evil , ** jar (local )})
446+ assert status == 403 and not reached , "a session does not launder a foreign origin"
447+ for label , headers in (
448+ ("no Origin, no credential" , {}),
449+ ("loopback Origin, no credential" , loopback ),
450+ ("wrong token" , {"X-A2App-Token" : "nope" }),
451+ ):
452+ status , body , reached = await send ("POST" , headers )
453+ assert status == 401 and body ["code" ] == "unauthorized" , (label , status )
454+ assert not reached , label
455+ async with http .post (f"{ base } /api/todos" , json = {"title" : "evil" }, headers = evil ) as r :
456+ assert r .status == 403 , "a real app route, not just the echo"
457+
458+ # the app's own UI (loopback page + the session its HTML page issued)
459+ for method in ("POST" , "PUT" , "PATCH" , "DELETE" ):
460+ status , body , reached = await send (method , {** loopback , ** jar (local )})
461+ assert status == 200 and body ["method" ] == method and reached , method
462+ # the agent / a local program
463+ status , _ , reached = await send ("POST" , {"X-A2App-Token" : TOKEN })
464+ assert status == 200 and reached
465+ # local reads stay open (CORS, below, keeps them from foreign pages)
466+ status , _ , reached = await send ("GET" , {})
467+ assert status == 200 and reached
468+
469+ # ── CORS: the proxy's policy, never the app's `*` ──
470+ async with http .get (echo , headers = evil ) as r :
471+ acx = [k for k in r .headers if k .lower ().startswith ("access-control-" )]
472+ assert r .status == 200 and not acx , f"upstream CORS reflected: { acx } "
473+ other_local = {"Origin" : "http://localhost:5173" }
474+ async with http .get (echo , headers = other_local ) as r :
475+ assert r .headers ["Access-Control-Allow-Origin" ] == "http://localhost:5173"
476+ assert r .headers ["Access-Control-Allow-Credentials" ] == "true"
477+ vary = {v .strip () for v in r .headers ["Vary" ].split ("," )}
478+ assert vary == {"Accept-Encoding" , "Origin" }, vary
479+ cookies = {c .split ("=" , 1 )[0 ] for c in r .headers .getall ("Set-Cookie" )}
480+ assert {"app_a" , "app_b" } <= cookies , "every upstream Set-Cookie survives"
481+ preflight = {"Access-Control-Request-Method" : "PUT" }
482+ async with http .options (echo , headers = {** evil , ** preflight }) as r :
483+ acx = [k for k in r .headers if k .lower ().startswith ("access-control-" )]
484+ assert not acx , f"foreign preflight granted: { acx } "
485+ async with http .options (echo , headers = {** other_local , ** preflight }) as r :
486+ assert r .headers ["Access-Control-Allow-Origin" ] == "http://localhost:5173"
487+ assert "PUT" in r .headers ["Access-Control-Allow-Methods" ]
488+ assert r .headers ["Access-Control-Allow-Headers" ] == "Content-Type, X-Custom"
489+
490+ # ── WebSocket: judged at the handshake, refused before upgrading ──
491+ ws_seen = len (seen .get ("ws" , []))
492+ for label , headers , want in (
493+ ("foreign Origin" , evil , 403 ),
494+ ("foreign Origin + token" , {** evil , "X-A2App-Token" : TOKEN }, 403 ),
495+ ("foreign Origin + session" , {** evil , ** jar (local )}, 403 ),
496+ ("no Origin, no credential" , {}, 401 ),
497+ ("loopback Origin, no credential" , loopback , 401 ),
498+ ):
499+ status , _ = await ws (headers )
500+ assert status == want , (label , status )
501+ assert len (seen .get ("ws" , [])) == ws_seen , "a refused handshake reached the app"
502+ assert await ws ({** loopback , ** jar (local )}) == (101 , "echo:hi" ), "the app's own UI"
503+ assert await ws ({"X-A2App-Token" : TOKEN }) == (101 , "echo:hi" ), "the agent"
504+
505+ # ── through the tunnel ──
506+ secret = "share-secret-for-passthrough-0123456789"
507+ (tmp / ".tunnel-origin" ).write_text (SHARED , encoding = "utf-8" )
508+ (tmp / ".tunnel-secret" ).write_text (secret , encoding = "utf-8" )
509+ try :
510+ async with http .get (f"{ base } /" , headers = VIA_TUNNEL ) as r :
511+ body = await r .json ()
512+ assert r .status == 401 and body ["code" ] == "share_session_required" , (
513+ "a bare tunnel URL must not read the app"
514+ )
515+ async with http .get (
516+ f"{ base } /?a2app_share={ secret } " , headers = VIA_TUNNEL , allow_redirects = False
517+ ) as r :
518+ assert r .status == 302
519+ shared = _set_cookie (r )
520+ visitor = {** VIA_TUNNEL , "Origin" : SHARED , ** jar (shared )}
521+
522+ async with http .get (f"{ base } /" , headers = {** VIA_TUNNEL , ** jar (shared )}) as r :
523+ assert r .status == 200 and (await r .text ()) == "UPSTREAM OK"
524+ status , _ , reached = await send ("POST" , visitor )
525+ assert status == 200 and reached , "the shared UI writes"
526+ async with http .get (echo , headers = visitor ) as r :
527+ assert r .headers ["Access-Control-Allow-Origin" ] == SHARED
528+ assert await ws (visitor ) == (101 , "echo:hi" ), "the shared UI's WebSocket"
529+
530+ for label , headers , want in (
531+ ("no session" , {** VIA_TUNNEL , "Origin" : SHARED }, 401 ),
532+ ("forged loopback Origin" , {** VIA_TUNNEL , ** loopback }, 401 ),
533+ ("local session" , {** VIA_TUNNEL , "Origin" : SHARED , ** jar (local )}, 401 ),
534+ ("foreign Origin + session" , {** VIA_TUNNEL , ** evil , ** jar (shared )}, 403 ),
535+ ):
536+ status , _ , reached = await send ("POST" , headers )
537+ assert status == want and not reached , ("http" , label , status )
538+ status , _ = await ws (headers )
539+ assert status == want , ("ws" , label , status )
540+ status , _ , reached = await send ("POST" , {** VIA_TUNNEL , "X-A2App-Token" : TOKEN })
541+ assert status == 200 and reached , "a remote agent with the token"
542+ finally :
543+ (tmp / ".tunnel-secret" ).unlink (missing_ok = True )
544+ (tmp / ".tunnel-origin" ).unlink (missing_ok = True )
545+ # Let the proxy finish closing its upstream WebSockets while this loop
546+ # (which also runs the upstream) is still free to answer them.
547+ await asyncio .sleep (0.3 )
548+
549+
379550async def _proxy_suite (tmp : Path ) -> None :
380551 import aiohttp
381552
@@ -449,6 +620,7 @@ async def _proxy_suite(tmp: Path) -> None:
449620 assert r .status == 200 and (await r .json ())["title" ] == "call John"
450621
451622 await _auth_matrix (http , base , tmp , seen )
623+ await _passthrough_matrix (http , base , tmp , seen )
452624
453625 # unknown op -> 404 envelope, never a silent passthrough
454626 async with http .post (f"{ base } /api/ops/nope" , json = {}, headers = auth ) as r :
0 commit comments