From 4ff76fa65be63a3a5e0697926f6d37264d1ef5e0 Mon Sep 17 00:00:00 2001 From: dinex-dev Date: Tue, 23 Jun 2026 17:10:27 +0530 Subject: [PATCH] fix(proxy): make HTTP response handling Node 24 / Electron 42 safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vendored MITM eagerly called proxyToClientResponse.writeHead() when upstream headers arrived, but the Requestly middleware buffers the whole body and writes the FINAL (rule-modified) response itself in onResponseEnd. That is two writeHead() calls: harmless on Node 18 (the later call overrode), but Node 24 (Electron 42) commits headers on the first writeHead and throws ERR_HTTP_HEADERS_SENT ("Cannot write headers after they are sent") on the second — an uncaught rejection that broke interception entirely. - src/lib/proxy/lib/proxy.ts: skip the eager writeHead when response-end handlers exist (the middleware is the authoritative writer); the eager headers were also wrong, predating the rule modifications computed at response end. - proxy-middleware + rule_action_processor: guard writeHead sites with `!headersSent` so any residual double-write degrades to a safe no-op instead of an uncaught Node-24 throw. Prerequisite for the Electron 23->42 upgrade (desktop #358): without it the upgraded app boots but cannot intercept traffic. Verified on the Electron 42 desktop build: 25 writeHead errors -> 0; all requests 200; proxy stable. Co-Authored-By: Claude Opus 4.8 (1M context) --- dist/components/proxy-middleware/index.js | 30 ++++++--- .../rule_action_processor/index.js | 4 +- dist/lib/proxy/lib/proxy.js | 14 ++++- src/components/proxy-middleware/index.js | 62 +++++++++++-------- .../rule_action_processor/index.js | 4 +- src/lib/proxy/lib/proxy.ts | 20 ++++-- 6 files changed, 91 insertions(+), 43 deletions(-) diff --git a/dist/components/proxy-middleware/index.js b/dist/components/proxy-middleware/index.js index f178de8..032f7a3 100644 --- a/dist/components/proxy-middleware/index.js +++ b/dist/components/proxy-middleware/index.js @@ -103,7 +103,9 @@ class ProxyMiddlewareManager { if (modifyResponseActionExist) { const statusCode = ctx.rq_response_status_code || 404; const responseHeaders = (0, proxy_ctx_helper_1.getResponseHeaders)(ctx) || {}; - ctx.proxyToClientResponse.writeHead(statusCode, http_1.default.STATUS_CODES[statusCode], responseHeaders); + if (!ctx.proxyToClientResponse.headersSent) { + ctx.proxyToClientResponse.writeHead(statusCode, http_1.default.STATUS_CODES[statusCode], responseHeaders); + } ctx.proxyToClientResponse.end(ctx.rq_response_body); ctx.rq.set_final_response({ status_code: statusCode, @@ -121,10 +123,12 @@ class ProxyMiddlewareManager { // Serve a clear SSL error instead of a misleading ERR_NAME_NOT_RESOLVED. if ((0, handleUnreachableAddress_1.isCertificateError)(err)) { const { status, contentType, body, errorToken } = (0, handleUnreachableAddress_1.dataToServeCertErrorPage)(host, err === null || err === void 0 ? void 0 : err.code); - ctx.proxyToClientResponse.writeHead(status, http_1.default.STATUS_CODES[status], { - "Content-Type": contentType, - "x-rq-error": errorToken, - }); + if (!ctx.proxyToClientResponse.headersSent) { + ctx.proxyToClientResponse.writeHead(status, http_1.default.STATUS_CODES[status], { + "Content-Type": contentType, + "x-rq-error": errorToken, + }); + } ctx.proxyToClientResponse.end(body); ctx.rq.set_final_response({ status_code: status, @@ -150,10 +154,12 @@ class ProxyMiddlewareManager { const isAddressUnreachable = await (0, handleUnreachableAddress_1.isAddressUnreachableError)(hostname); if (isAddressUnreachable) { const { status, contentType, body } = (0, handleUnreachableAddress_1.dataToServeUnreachablePage)(host); - ctx.proxyToClientResponse.writeHead(status, http_1.default.STATUS_CODES[status], { - "Content-Type": contentType, - "x-rq-error": "ERR_NAME_NOT_RESOLVED" - }); + if (!ctx.proxyToClientResponse.headersSent) { + ctx.proxyToClientResponse.writeHead(status, http_1.default.STATUS_CODES[status], { + "Content-Type": contentType, + "x-rq-error": "ERR_NAME_NOT_RESOLVED" + }); + } ctx.proxyToClientResponse.end(body); ctx.rq.set_final_response({ status_code: status, @@ -246,7 +252,11 @@ class ProxyMiddlewareManager { delete responseHeaders['transfer-encoding']; delete responseHeaders['Transfer-Encoding']; } - ctx.proxyToClientResponse.writeHead(statusCode, http_1.default.STATUS_CODES[statusCode], responseHeaders); + // Node 24 commits headers on writeHead and throws on a second call; + // guard so a re-entrant write can't crash the response pipeline. + if (!ctx.proxyToClientResponse.headersSent) { + ctx.proxyToClientResponse.writeHead(statusCode, http_1.default.STATUS_CODES[statusCode], responseHeaders); + } ctx.proxyToClientResponse.write(ctx.rq_response_body); ctx.rq.set_final_response({ ...(0, proxy_ctx_helper_1.get_response_options)(ctx), diff --git a/dist/components/proxy-middleware/rule_action_processor/index.js b/dist/components/proxy-middleware/rule_action_processor/index.js index 8947148..4276fac 100644 --- a/dist/components/proxy-middleware/rule_action_processor/index.js +++ b/dist/components/proxy-middleware/rule_action_processor/index.js @@ -44,7 +44,9 @@ class RuleActionProcessor { if (typeof (body) !== 'string') { body = JSON.stringify(body); } - ctx.proxyToClientResponse.writeHead(status_code, headers).end(body); + if (!ctx.proxyToClientResponse.headersSent) { + ctx.proxyToClientResponse.writeHead(status_code, headers).end(body); + } ctx.rq.request_finished = true; ctx.rq.set_final_response({ status_code: status_code, diff --git a/dist/lib/proxy/lib/proxy.js b/dist/lib/proxy/lib/proxy.js index 7d6a4be..a36247e 100644 --- a/dist/lib/proxy/lib/proxy.js +++ b/dist/lib/proxy/lib/proxy.js @@ -915,7 +915,19 @@ Proxy.prototype._onHttpServerRequest = function (isSSL, clientToProxyRequest, pr if (err) { return self._onError("ON_RESPONSEHEADERS_ERROR", ctx, err); } - ctx.proxyToClientResponse.writeHead(ctx.serverToProxyResponse.statusCode, Proxy.filterAndCanonizeHeaders(ctx.serverToProxyResponse.headers)); + // NOTE (RQ / Node 24 + Electron 42 compat): do NOT writeHead eagerly here. + // The Requestly response middleware buffers the whole body (onResponseData + // returns no chunk) and writes the FINAL response — status, rule-modified + // headers, and body — in its onResponseEnd handler. Sending headers here + // caused a double writeHead: harmless on Node 18 (the later call overrode), + // but Node 24 commits headers on the first writeHead and throws + // ERR_HTTP_HEADERS_SENT on the second, breaking interception. Eager headers + // were also wrong — they predate the rule modifications computed at response + // end. The guard keeps the generic streaming path working when no + // response-end handler will write. + if (!ctx.proxyToClientResponse.headersSent && !ctx.onResponseEndHandlers.length) { + ctx.proxyToClientResponse.writeHead(ctx.serverToProxyResponse.statusCode, Proxy.filterAndCanonizeHeaders(ctx.serverToProxyResponse.headers)); + } ctx.responseFilters.push(new ProxyFinalResponseFilter(self, ctx)); var prevResponsePipeElem = ctx.serverToProxyResponse; ctx.responseFilters.forEach(function (filter) { diff --git a/src/components/proxy-middleware/index.js b/src/components/proxy-middleware/index.js index 19c461b..d47a8e4 100644 --- a/src/components/proxy-middleware/index.js +++ b/src/components/proxy-middleware/index.js @@ -158,11 +158,13 @@ class ProxyMiddlewareManager { if (modifyResponseActionExist) { const statusCode = ctx.rq_response_status_code || 404; const responseHeaders = getResponseHeaders(ctx) || {} - ctx.proxyToClientResponse.writeHead( - statusCode, - http.STATUS_CODES[statusCode], - responseHeaders - ); + if (!ctx.proxyToClientResponse.headersSent) { + ctx.proxyToClientResponse.writeHead( + statusCode, + http.STATUS_CODES[statusCode], + responseHeaders + ); + } ctx.proxyToClientResponse.end(ctx.rq_response_body); ctx.rq.set_final_response({ @@ -185,14 +187,16 @@ class ProxyMiddlewareManager { // Serve a clear SSL error instead of a misleading ERR_NAME_NOT_RESOLVED. if (isCertificateError(err)) { const { status, contentType, body, errorToken } = dataToServeCertErrorPage(host, err?.code); - ctx.proxyToClientResponse.writeHead( - status, - http.STATUS_CODES[status], - { - "Content-Type": contentType, - "x-rq-error": errorToken, - } - ); + if (!ctx.proxyToClientResponse.headersSent) { + ctx.proxyToClientResponse.writeHead( + status, + http.STATUS_CODES[status], + { + "Content-Type": contentType, + "x-rq-error": errorToken, + } + ); + } ctx.proxyToClientResponse.end(body); ctx.rq.set_final_response({ status_code: status, @@ -222,14 +226,16 @@ class ProxyMiddlewareManager { const isAddressUnreachable = await isAddressUnreachableError(hostname); if (isAddressUnreachable) { const { status, contentType, body } = dataToServeUnreachablePage(host); - ctx.proxyToClientResponse.writeHead( - status, - http.STATUS_CODES[status], - { - "Content-Type": contentType , - "x-rq-error": "ERR_NAME_NOT_RESOLVED" - } - ); + if (!ctx.proxyToClientResponse.headersSent) { + ctx.proxyToClientResponse.writeHead( + status, + http.STATUS_CODES[status], + { + "Content-Type": contentType , + "x-rq-error": "ERR_NAME_NOT_RESOLVED" + } + ); + } ctx.proxyToClientResponse.end(body); ctx.rq.set_final_response({ status_code: status, @@ -348,11 +354,15 @@ class ProxyMiddlewareManager { delete responseHeaders['Transfer-Encoding']; } - ctx.proxyToClientResponse.writeHead( - statusCode, - http.STATUS_CODES[statusCode], - responseHeaders, - ); + // Node 24 commits headers on writeHead and throws on a second call; + // guard so a re-entrant write can't crash the response pipeline. + if (!ctx.proxyToClientResponse.headersSent) { + ctx.proxyToClientResponse.writeHead( + statusCode, + http.STATUS_CODES[statusCode], + responseHeaders, + ); + } ctx.proxyToClientResponse.write(ctx.rq_response_body); diff --git a/src/components/proxy-middleware/rule_action_processor/index.js b/src/components/proxy-middleware/rule_action_processor/index.js index 8f8baa3..f1224e3 100644 --- a/src/components/proxy-middleware/rule_action_processor/index.js +++ b/src/components/proxy-middleware/rule_action_processor/index.js @@ -48,7 +48,9 @@ class RuleActionProcessor { body = JSON.stringify(body); } - ctx.proxyToClientResponse.writeHead(status_code, headers).end(body); + if (!ctx.proxyToClientResponse.headersSent) { + ctx.proxyToClientResponse.writeHead(status_code, headers).end(body); + } ctx.rq.request_finished = true; ctx.rq.set_final_response({ diff --git a/src/lib/proxy/lib/proxy.ts b/src/lib/proxy/lib/proxy.ts index de79fd4..d315cd0 100644 --- a/src/lib/proxy/lib/proxy.ts +++ b/src/lib/proxy/lib/proxy.ts @@ -1080,10 +1080,22 @@ Proxy.prototype._onHttpServerRequest = function ( if (err) { return self._onError("ON_RESPONSEHEADERS_ERROR", ctx, err); } - ctx.proxyToClientResponse.writeHead( - ctx.serverToProxyResponse.statusCode, - Proxy.filterAndCanonizeHeaders(ctx.serverToProxyResponse.headers) - ); + // NOTE (RQ / Node 24 + Electron 42 compat): do NOT writeHead eagerly here. + // The Requestly response middleware buffers the whole body (onResponseData + // returns no chunk) and writes the FINAL response — status, rule-modified + // headers, and body — in its onResponseEnd handler. Sending headers here + // caused a double writeHead: harmless on Node 18 (the later call overrode), + // but Node 24 commits headers on the first writeHead and throws + // ERR_HTTP_HEADERS_SENT on the second, breaking interception. Eager headers + // were also wrong — they predate the rule modifications computed at response + // end. The guard keeps the generic streaming path working when no + // response-end handler will write. + if (!ctx.proxyToClientResponse.headersSent && !ctx.onResponseEndHandlers.length) { + ctx.proxyToClientResponse.writeHead( + ctx.serverToProxyResponse.statusCode, + Proxy.filterAndCanonizeHeaders(ctx.serverToProxyResponse.headers) + ); + } ctx.responseFilters.push(new ProxyFinalResponseFilter(self, ctx)); var prevResponsePipeElem = ctx.serverToProxyResponse; ctx.responseFilters.forEach(function (filter) {