@@ -1209,6 +1209,30 @@ async def _relay_http_stream(self, host: str, port: int, reader, writer):
12091209
12101210 log .info ("MITM → %s %s" , method , url )
12111211
1212+ # ── CORS: extract relevant request headers ─────────────
1213+ origin = self ._header_value (headers , "origin" )
1214+ acr_method = self ._header_value (
1215+ headers , "access-control-request-method" ,
1216+ )
1217+ acr_headers = self ._header_value (
1218+ headers , "access-control-request-headers" ,
1219+ )
1220+
1221+ # CORS preflight — respond directly. Apps Script's
1222+ # UrlFetchApp does not support the OPTIONS method, so
1223+ # forwarding preflights would always fail and break every
1224+ # cross-origin fetch/XHR the browser runs through us.
1225+ if method .upper () == "OPTIONS" and acr_method :
1226+ log .debug (
1227+ "CORS preflight → %s (responding locally)" ,
1228+ url [:60 ],
1229+ )
1230+ writer .write (self ._cors_preflight_response (
1231+ origin , acr_method , acr_headers ,
1232+ ))
1233+ await writer .drain ()
1234+ continue
1235+
12121236 if await self ._maybe_stream_download (method , url , headers , body , writer ):
12131237 continue
12141238
@@ -1240,6 +1264,13 @@ async def _relay_http_stream(self, host: str, port: int, reader, writer):
12401264 self ._cache .put (url , response , ttl )
12411265 log .debug ("Cached (%ds): %s" , ttl , url [:60 ])
12421266
1267+ # Inject permissive CORS headers whenever the browser sent
1268+ # an Origin (cross-origin XHR / fetch). Without this, the
1269+ # browser blocks the response even though the relay fetched
1270+ # it successfully.
1271+ if origin and response :
1272+ response = self ._inject_cors_headers (response , origin )
1273+
12431274 self ._log_response_summary (url , response )
12441275
12451276 writer .write (response )
@@ -1255,6 +1286,61 @@ async def _relay_http_stream(self, host: str, port: int, reader, writer):
12551286 log .error ("MITM handler error (%s): %s" , host , e )
12561287 break
12571288
1289+ # ── CORS helpers ──────────────────────────────────────────────
1290+
1291+ @staticmethod
1292+ def _cors_preflight_response (origin : str , acr_method : str ,
1293+ acr_headers : str ) -> bytes :
1294+ """Build a 204 response that satisfies a CORS preflight locally.
1295+
1296+ Apps Script's UrlFetchApp does not support OPTIONS, so we have to
1297+ answer preflights here instead of forwarding them.
1298+ """
1299+ allow_origin = origin or "*"
1300+ allow_methods = (
1301+ f"{ acr_method } , GET, POST, PUT, DELETE, PATCH, OPTIONS"
1302+ if acr_method else
1303+ "GET, POST, PUT, DELETE, PATCH, OPTIONS"
1304+ )
1305+ allow_headers = acr_headers or "*"
1306+ return (
1307+ "HTTP/1.1 204 No Content\r \n "
1308+ f"Access-Control-Allow-Origin: { allow_origin } \r \n "
1309+ f"Access-Control-Allow-Methods: { allow_methods } \r \n "
1310+ f"Access-Control-Allow-Headers: { allow_headers } \r \n "
1311+ "Access-Control-Allow-Credentials: true\r \n "
1312+ "Access-Control-Max-Age: 86400\r \n "
1313+ "Vary: Origin\r \n "
1314+ "Content-Length: 0\r \n "
1315+ "\r \n "
1316+ ).encode ()
1317+
1318+ @staticmethod
1319+ def _inject_cors_headers (response : bytes , origin : str ) -> bytes :
1320+ """Strip existing Access-Control-* headers and add permissive ones.
1321+
1322+ Keeps the body untouched; only rewrites the header block. Using
1323+ the exact browser-supplied Origin (rather than "*") is required
1324+ when the request is credentialed (cookies, Authorization).
1325+ """
1326+ sep = b"\r \n \r \n "
1327+ if sep not in response :
1328+ return response
1329+ header_section , body = response .split (sep , 1 )
1330+ lines = header_section .decode (errors = "replace" ).split ("\r \n " )
1331+ lines = [ln for ln in lines
1332+ if not ln .lower ().startswith ("access-control-" )]
1333+ allow_origin = origin or "*"
1334+ lines += [
1335+ f"Access-Control-Allow-Origin: { allow_origin } " ,
1336+ "Access-Control-Allow-Credentials: true" ,
1337+ "Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS" ,
1338+ "Access-Control-Allow-Headers: *" ,
1339+ "Access-Control-Expose-Headers: *" ,
1340+ "Vary: Origin" ,
1341+ ]
1342+ return ("\r \n " .join (lines ) + "\r \n \r \n " ).encode () + body
1343+
12581344 async def _relay_smart (self , method , url , headers , body ):
12591345 """Choose optimal relay strategy based on request type.
12601346
@@ -1359,6 +1445,18 @@ async def _do_http(self, header_block: bytes, reader, writer):
13591445 k , v = raw_line .decode (errors = "replace" ).split (":" , 1 )
13601446 headers [k .strip ()] = v .strip ()
13611447
1448+ # ── CORS preflight over plain HTTP ─────────────────────────────
1449+ origin = self ._header_value (headers , "origin" )
1450+ acr_method = self ._header_value (headers , "access-control-request-method" )
1451+ acr_headers = self ._header_value (headers , "access-control-request-headers" )
1452+ if method .upper () == "OPTIONS" and acr_method :
1453+ log .debug ("CORS preflight (HTTP) → %s (responding locally)" , url [:60 ])
1454+ writer .write (self ._cors_preflight_response (
1455+ origin , acr_method , acr_headers ,
1456+ ))
1457+ await writer .drain ()
1458+ return
1459+
13621460 if await self ._maybe_stream_download (method , url , headers , body , writer ):
13631461 return
13641462
@@ -1377,6 +1475,9 @@ async def _do_http(self, header_block: bytes, reader, writer):
13771475 if ttl > 0 :
13781476 self ._cache .put (url , response , ttl )
13791477
1478+ if origin and response :
1479+ response = self ._inject_cors_headers (response , origin )
1480+
13801481 self ._log_response_summary (url , response )
13811482
13821483 writer .write (response )
0 commit comments