Skip to content

Commit 427be7b

Browse files
committed
fix: implement CORS support for preflight requests and inject headers
1 parent c2aaf37 commit 427be7b

1 file changed

Lines changed: 101 additions & 0 deletions

File tree

src/proxy_server.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)