From 148de9556cea2242350f00c280b5fe73f07493e3 Mon Sep 17 00:00:00 2001 From: dinex-dev Date: Thu, 18 Jun 2026 11:43:42 +0530 Subject: [PATCH 1/4] fix(security): RQ-2425 verify upstream TLS certs by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy hardcoded `rejectUnauthorized = false` on every outbound request, silently accepting any (expired/self-signed/wrong-host/attacker) certificate from origin servers — letting a network attacker MITM all proxied HTTPS traffic with no signal to the user. Verify upstream certificates by default and only skip when the user explicitly opts in via the new `ProxyConfig.allowInsecureCerts` flag (for self-signed / internal upstreams). Defaults to secure (verify) when unset. Follow-up (desktop app): expose this as a user setting / per-host cert exception. Co-Authored-By: Claude Opus 4.8 (1M context) --- dist/components/proxy-middleware/index.js | 6 +++++- dist/types/index.d.ts | 1 + src/components/proxy-middleware/index.js | 5 ++++- src/types/index.ts | 3 +++ 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/dist/components/proxy-middleware/index.js b/dist/components/proxy-middleware/index.js index 200892f..48fd301 100644 --- a/dist/components/proxy-middleware/index.js +++ b/dist/components/proxy-middleware/index.js @@ -75,9 +75,13 @@ class ProxyMiddlewareManager { const is_detachable = true; const logger_middleware = new logger_middleware_1.default(this.config[exports.MIDDLEWARE_TYPE.LOGGER], this.loggerService); const idx = this.init_request_handler(async (ctx, callback) => { + var _a; ctx.rq = new ctx_rq_namespace_1.default(); ctx.rq.set_original_request((0, proxy_ctx_helper_1.get_request_options)(ctx)); - ctx.proxyToServerRequestOptions.rejectUnauthorized = false; + // RQ-2425: verify upstream TLS certificates by default. Only skip + // verification when the user has explicitly opted in (e.g. to reach a + // self-signed / internal upstream). Defaults to secure when unset. + ctx.proxyToServerRequestOptions.rejectUnauthorized = !((_a = this.proxyConfig) === null || _a === void 0 ? void 0 : _a.allowInsecureCerts); // Figure out a way to enable/disable middleware dynamically // instead of re-initing this again const rules_middleware = new rules_middleware_1.default(this.config[exports.MIDDLEWARE_TYPE.RULES], ctx, this.rulesHelper); diff --git a/dist/types/index.d.ts b/dist/types/index.d.ts index b19b02d..0178822 100644 --- a/dist/types/index.d.ts +++ b/dist/types/index.d.ts @@ -4,6 +4,7 @@ export interface ProxyConfig { certPath: String; rootCertPath: String; onCARegenerated?: Function; + allowInsecureCerts?: boolean; } export interface Rule { id: string; diff --git a/src/components/proxy-middleware/index.js b/src/components/proxy-middleware/index.js index 45eb82e..4005585 100644 --- a/src/components/proxy-middleware/index.js +++ b/src/components/proxy-middleware/index.js @@ -126,7 +126,10 @@ class ProxyMiddlewareManager { const idx = this.init_request_handler(async (ctx, callback) => { ctx.rq = new CtxRQNamespace(); ctx.rq.set_original_request(get_request_options(ctx)); - ctx.proxyToServerRequestOptions.rejectUnauthorized = false; + // RQ-2425: verify upstream TLS certificates by default. Only skip + // verification when the user has explicitly opted in (e.g. to reach a + // self-signed / internal upstream). Defaults to secure when unset. + ctx.proxyToServerRequestOptions.rejectUnauthorized = !this.proxyConfig?.allowInsecureCerts; // Figure out a way to enable/disable middleware dynamically // instead of re-initing this again const rules_middleware = new RulesMiddleware( diff --git a/src/types/index.ts b/src/types/index.ts index c430044..fcf458e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,6 +4,9 @@ export interface ProxyConfig { certPath: String; rootCertPath: String; onCARegenerated?: Function; + // RQ-2425: when true, the proxy skips upstream TLS certificate verification + // (for self-signed / internal upstreams). Defaults to false (verify) when unset. + allowInsecureCerts?: boolean; } export interface Rule { From d5b690a830d7704ddccf328b0213c30bad1a3780 Mon Sep 17 00:00:00 2001 From: dinex-dev Date: Thu, 18 Jun 2026 13:02:05 +0530 Subject: [PATCH 2/4] feat(security): RQ-2425 live-update insecure-certs without proxy restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add setAllowInsecureCerts() on RQProxy + ProxyMiddlewareManager. Because the per-request handler reads proxyConfig.allowInsecureCerts, mutating it via this setter applies the new TLS-verification policy on the next request — no proxy restart and no app restart. Lets the desktop toggle take effect immediately. Co-Authored-By: Claude Opus 4.8 (1M context) --- dist/components/proxy-middleware/index.js | 8 ++++++++ dist/rq-proxy.js | 5 +++++ src/components/proxy-middleware/index.js | 9 +++++++++ src/rq-proxy.ts | 5 +++++ 4 files changed, 27 insertions(+) diff --git a/dist/components/proxy-middleware/index.js b/dist/components/proxy-middleware/index.js index 48fd301..9e02a55 100644 --- a/dist/components/proxy-middleware/index.js +++ b/dist/components/proxy-middleware/index.js @@ -29,6 +29,14 @@ exports.MIDDLEWARE_TYPE = { }; class ProxyMiddlewareManager { constructor(proxy, proxyConfig, rulesHelper, loggerService, sslConfigFetcher) { + // RQ-2425: update the upstream-TLS-verification policy on the running proxy. + // The per-request handler reads `this.proxyConfig.allowInsecureCerts`, so + // mutating it here takes effect on the next request — no proxy restart. + this.setAllowInsecureCerts = (value) => { + if (this.proxyConfig) { + this.proxyConfig.allowInsecureCerts = !!value; + } + }; /* NOT USEFUL */ this.init_config = (config = {}) => { Object.keys(exports.MIDDLEWARE_TYPE).map((middleware_key) => { diff --git a/dist/rq-proxy.js b/dist/rq-proxy.js index dab0874..d4e31466 100644 --- a/dist/rq-proxy.js +++ b/dist/rq-proxy.js @@ -47,6 +47,11 @@ class RQProxy { }); // }; + // RQ-2425: live-update upstream TLS verification without restarting the proxy. + this.setAllowInsecureCerts = (value) => { + var _a, _b; + (_b = (_a = this.proxyMiddlewareManager) === null || _a === void 0 ? void 0 : _a.setAllowInsecureCerts) === null || _b === void 0 ? void 0 : _b.call(_a, value); + }; this.doSomething = () => { console.log("do something"); }; diff --git a/src/components/proxy-middleware/index.js b/src/components/proxy-middleware/index.js index 4005585..c27f84c 100644 --- a/src/components/proxy-middleware/index.js +++ b/src/components/proxy-middleware/index.js @@ -61,6 +61,15 @@ class ProxyMiddlewareManager { // this.sslProxyingManager = new SSLProxyingManager(sslConfigFetcher); } + // RQ-2425: update the upstream-TLS-verification policy on the running proxy. + // The per-request handler reads `this.proxyConfig.allowInsecureCerts`, so + // mutating it here takes effect on the next request — no proxy restart. + setAllowInsecureCerts = (value) => { + if (this.proxyConfig) { + this.proxyConfig.allowInsecureCerts = !!value; + } + }; + /* NOT USEFUL */ init_config = (config = {}) => { Object.keys(MIDDLEWARE_TYPE).map((middleware_key) => { diff --git a/src/rq-proxy.ts b/src/rq-proxy.ts index eade402..09cc231 100644 --- a/src/rq-proxy.ts +++ b/src/rq-proxy.ts @@ -71,6 +71,11 @@ class RQProxy { // } + // RQ-2425: live-update upstream TLS verification without restarting the proxy. + setAllowInsecureCerts = (value: boolean) => { + this.proxyMiddlewareManager?.setAllowInsecureCerts?.(value); + } + doSomething = () => { console.log("do something"); } From 3283add6e278c5aa1ae3e41997320919a40be238 Mon Sep 17 00:00:00 2001 From: dinex-dev Date: Thu, 18 Jun 2026 14:13:20 +0530 Subject: [PATCH 3/4] RQ-2425: report upstream TLS cert failures clearly, not as DNS errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When "Allow insecure SSL in proxy interceptor" is OFF (default) and an origin presents an untrusted/expired/self-signed cert, the proxy used to serve an ERR_NAME_NOT_RESOLVED page — misleading, since the host resolves fine. - Detect TLS cert verification errors (isCertificateError) and serve a dedicated page that names the cause (e.g. CERT_HAS_EXPIRED -> ERR_CERT_DATE_INVALID) and tells the user they can enable the insecure-SSL toggle if they trust the host. - Fix the DNS-unreachable check to use a clean hostname (headers.host minus port) instead of the full request URL, so dns.lookup() no longer reports every upstream error as ENOTFOUND. - Rebuild dist. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../helpers/handleUnreachableAddress.d.ts | 8 ++ .../helpers/handleUnreachableAddress.js | 114 +++++++++++++++++ dist/components/proxy-middleware/index.d.ts | 1 + dist/components/proxy-middleware/index.js | 27 +++- dist/rq-proxy.d.ts | 1 + .../helpers/handleUnreachableAddress.js | 117 +++++++++++++++++- src/components/proxy-middleware/index.js | 38 +++++- 7 files changed, 299 insertions(+), 7 deletions(-) diff --git a/dist/components/proxy-middleware/helpers/handleUnreachableAddress.d.ts b/dist/components/proxy-middleware/helpers/handleUnreachableAddress.d.ts index 0323518..a860f08 100644 --- a/dist/components/proxy-middleware/helpers/handleUnreachableAddress.d.ts +++ b/dist/components/proxy-middleware/helpers/handleUnreachableAddress.d.ts @@ -1,4 +1,12 @@ export function isAddressUnreachableError(host: any): Promise; +export function isCertificateError(err: any): boolean; +export function certErrorToken(code: any): "ERR_CERT_DATE_INVALID" | "ERR_CERT_COMMON_NAME_INVALID" | "ERR_CERT_REVOKED" | "ERR_CERT_AUTHORITY_INVALID"; +export function dataToServeCertErrorPage(host: any, code: any): { + status: number; + contentType: string; + errorToken: string; + body: string; +}; export function dataToServeUnreachablePage(host: any): { status: number; contentType: string; diff --git a/dist/components/proxy-middleware/helpers/handleUnreachableAddress.js b/dist/components/proxy-middleware/helpers/handleUnreachableAddress.js index ae04646..27d3a95 100644 --- a/dist/components/proxy-middleware/helpers/handleUnreachableAddress.js +++ b/dist/components/proxy-middleware/helpers/handleUnreachableAddress.js @@ -4,6 +4,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) { }; Object.defineProperty(exports, "__esModule", { value: true }); exports.isAddressUnreachableError = isAddressUnreachableError; +exports.isCertificateError = isCertificateError; +exports.certErrorToken = certErrorToken; +exports.dataToServeCertErrorPage = dataToServeCertErrorPage; exports.dataToServeUnreachablePage = dataToServeUnreachablePage; const dns_1 = __importDefault(require("dns")); function isAddressUnreachableError(host) { @@ -23,6 +26,117 @@ function isAddressUnreachableError(host) { }); }); } +// RQ-2425: upstream TLS certificate verification failures. These surface when +// "Allow insecure SSL in proxy interceptor" is OFF (the default) and the origin +// presents an untrusted/expired/self-signed cert. Without this, such failures +// were misreported as ERR_NAME_NOT_RESOLVED, which is confusing. +const TLS_CERT_ERROR_CODES = new Set([ + 'CERT_HAS_EXPIRED', + 'CERT_NOT_YET_VALID', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'SELF_SIGNED_CERT_IN_CHAIN', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + 'UNABLE_TO_GET_ISSUER_CERT', + 'CERT_UNTRUSTED', + 'CERT_REVOKED', + 'CERT_REJECTED', + 'HOSTNAME_MISMATCH', + 'ERR_TLS_CERT_ALTNAME_INVALID', +]); +function isCertificateError(err) { + if (!err) + return false; + if (err.code && TLS_CERT_ERROR_CODES.has(err.code)) + return true; + const message = String((err && (err.message || err.reason)) || ''); + return /\bcertificate\b/i.test(message) || /ERR_TLS_CERT/i.test(message); +} +// Maps a Node/OpenSSL cert error code to a Chrome-style token users recognise. +function certErrorToken(code) { + switch (code) { + case 'CERT_HAS_EXPIRED': + case 'CERT_NOT_YET_VALID': + return 'ERR_CERT_DATE_INVALID'; + case 'HOSTNAME_MISMATCH': + case 'ERR_TLS_CERT_ALTNAME_INVALID': + return 'ERR_CERT_COMMON_NAME_INVALID'; + case 'CERT_REVOKED': + return 'ERR_CERT_REVOKED'; + default: + return 'ERR_CERT_AUTHORITY_INVALID'; + } +} +function dataToServeCertErrorPage(host, code) { + const token = certErrorToken(code); + return { + status: 502, + contentType: 'text/html', + errorToken: token, + body: ` + + + + + ${token} + + + + +
+
:(
+

This site's SSL certificate isn't trusted

+

Requestly couldn't verify the TLS certificate of ${host}${code ? ` (${code})` : ''}.

+

If you trust this host, enable “Allow insecure SSL in proxy interceptor” in Requestly desktop settings and reload.

+

${token}

+
+ + + `.trim() + }; +} function dataToServeUnreachablePage(host) { return { status: 502, diff --git a/dist/components/proxy-middleware/index.d.ts b/dist/components/proxy-middleware/index.d.ts index 45836bb..70eb7a6 100644 --- a/dist/components/proxy-middleware/index.d.ts +++ b/dist/components/proxy-middleware/index.d.ts @@ -14,6 +14,7 @@ declare class ProxyMiddlewareManager { rulesHelper: any; loggerService: any; sslConfigFetcher: any; + setAllowInsecureCerts: (value: any) => void; init_config: (config?: {}) => void; init: (config?: {}) => void; request_handler_idx: number; diff --git a/dist/components/proxy-middleware/index.js b/dist/components/proxy-middleware/index.js index 9e02a55..1a461c6 100644 --- a/dist/components/proxy-middleware/index.js +++ b/dist/components/proxy-middleware/index.js @@ -94,6 +94,7 @@ class ProxyMiddlewareManager { // instead of re-initing this again const rules_middleware = new rules_middleware_1.default(this.config[exports.MIDDLEWARE_TYPE.RULES], ctx, this.rulesHelper); ctx.onError(async function (ctx, err, kind, callback) { + var _a, _b; // Should only modify response body & headers ctx.rq_response_body = "" + kind + ": " + err, "utf8"; const { action_result_objs, continue_request } = await rules_middleware.on_response(ctx); @@ -114,9 +115,31 @@ class ProxyMiddlewareManager { } } else if (kind === "PROXY_TO_SERVER_REQUEST_ERROR") { + const host = (0, proxy_ctx_helper_1.get_request_url)(ctx); + // RQ-2425: upstream TLS cert verification failed (e.g. "Allow insecure + // SSL" is OFF and the origin has an untrusted/expired/self-signed cert). + // 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, + }); + ctx.proxyToClientResponse.end(body); + ctx.rq.set_final_response({ + status_code: status, + headers: { "Content-Type": contentType }, + body: body, + }); + logger_middleware.send_network_log(ctx, rules_middleware.action_result_objs, requestly_core_1.CONSTANTS.REQUEST_STATE.COMPLETE); + return; + } try { - const host = (0, proxy_ctx_helper_1.get_request_url)(ctx); - const isAddressUnreachable = await (0, handleUnreachableAddress_1.isAddressUnreachableError)(host); + // Resolve against a clean hostname (headers.host minus any port), not + // the full request URL — dns.lookup() can't parse a URL and would + // otherwise report every upstream error as ENOTFOUND. + const hostname = (((_b = (_a = ctx.clientToProxyRequest) === null || _a === void 0 ? void 0 : _a.headers) === null || _b === void 0 ? void 0 : _b.host) || "").split(":")[0]; + 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], { diff --git a/dist/rq-proxy.d.ts b/dist/rq-proxy.d.ts index 796d56f..7d414cc 100644 --- a/dist/rq-proxy.d.ts +++ b/dist/rq-proxy.d.ts @@ -13,6 +13,7 @@ declare class RQProxy { globalState: State; constructor(proxyConfig: ProxyConfig, rulesDataSource: IRulesDataSource, loggerService: ILoggerService, initialGlobalState?: IInitialState); initProxy: (proxyConfig: ProxyConfig) => void; + setAllowInsecureCerts: (value: boolean) => void; doSomething: () => void; } export default RQProxy; diff --git a/src/components/proxy-middleware/helpers/handleUnreachableAddress.js b/src/components/proxy-middleware/helpers/handleUnreachableAddress.js index 8ea43b4..4fc0f8c 100644 --- a/src/components/proxy-middleware/helpers/handleUnreachableAddress.js +++ b/src/components/proxy-middleware/helpers/handleUnreachableAddress.js @@ -7,15 +7,128 @@ export function isAddressUnreachableError(host) { if (err.code === 'ENOTFOUND') { resolve(true); } else { - reject(err); + reject(err); } } else { - resolve(false); + resolve(false); } }); }); } +// RQ-2425: upstream TLS certificate verification failures. These surface when +// "Allow insecure SSL in proxy interceptor" is OFF (the default) and the origin +// presents an untrusted/expired/self-signed cert. Without this, such failures +// were misreported as ERR_NAME_NOT_RESOLVED, which is confusing. +const TLS_CERT_ERROR_CODES = new Set([ + 'CERT_HAS_EXPIRED', + 'CERT_NOT_YET_VALID', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'SELF_SIGNED_CERT_IN_CHAIN', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + 'UNABLE_TO_GET_ISSUER_CERT', + 'CERT_UNTRUSTED', + 'CERT_REVOKED', + 'CERT_REJECTED', + 'HOSTNAME_MISMATCH', + 'ERR_TLS_CERT_ALTNAME_INVALID', +]); + +export function isCertificateError(err) { + if (!err) return false; + if (err.code && TLS_CERT_ERROR_CODES.has(err.code)) return true; + const message = String((err && (err.message || err.reason)) || ''); + return /\bcertificate\b/i.test(message) || /ERR_TLS_CERT/i.test(message); +} + +// Maps a Node/OpenSSL cert error code to a Chrome-style token users recognise. +export function certErrorToken(code) { + switch (code) { + case 'CERT_HAS_EXPIRED': + case 'CERT_NOT_YET_VALID': + return 'ERR_CERT_DATE_INVALID'; + case 'HOSTNAME_MISMATCH': + case 'ERR_TLS_CERT_ALTNAME_INVALID': + return 'ERR_CERT_COMMON_NAME_INVALID'; + case 'CERT_REVOKED': + return 'ERR_CERT_REVOKED'; + default: + return 'ERR_CERT_AUTHORITY_INVALID'; + } +} + +export function dataToServeCertErrorPage(host, code) { + const token = certErrorToken(code); + return { + status: 502, + contentType: 'text/html', + errorToken: token, + body: ` + + + + + ${token} + + + + +
+
:(
+

This site's SSL certificate isn't trusted

+

Requestly couldn't verify the TLS certificate of ${host}${code ? ` (${code})` : ''}.

+

If you trust this host, enable “Allow insecure SSL in proxy interceptor” in Requestly desktop settings and reload.

+

${token}

+
+ + + `.trim() + }; +} + export function dataToServeUnreachablePage(host) { return { status: 502, diff --git a/src/components/proxy-middleware/index.js b/src/components/proxy-middleware/index.js index c27f84c..6d26e7a 100644 --- a/src/components/proxy-middleware/index.js +++ b/src/components/proxy-middleware/index.js @@ -21,7 +21,7 @@ import CtxRQNamespace from "./helpers/ctx_rq_namespace"; import { bodyParser, getContentType } from "./helpers/http_helpers"; import { RQ_INTERCEPTED_CONTENT_TYPES_REGEX, RULE_ACTION } from "./constants"; import { CONSTANTS as GLOBAL_CONSTANTS } from "@requestly/requestly-core"; -import { dataToServeUnreachablePage, isAddressUnreachableError } from "./helpers/handleUnreachableAddress"; +import { dataToServeUnreachablePage, isAddressUnreachableError, isCertificateError, dataToServeCertErrorPage } from "./helpers/handleUnreachableAddress"; // import SSLProxyingConfigFetcher from "renderer/lib/fetcher/ssl-proxying-config-fetcher"; // import SSLProxyingManager from "../ssl-proxying/ssl-proxying-manager"; @@ -178,9 +178,41 @@ class ProxyMiddlewareManager { ) } } else if (kind === "PROXY_TO_SERVER_REQUEST_ERROR") { + const host = get_request_url(ctx); + + // RQ-2425: upstream TLS cert verification failed (e.g. "Allow insecure + // SSL" is OFF and the origin has an untrusted/expired/self-signed cert). + // 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, + } + ); + ctx.proxyToClientResponse.end(body); + ctx.rq.set_final_response({ + status_code: status, + headers: { "Content-Type": contentType }, + body: body, + }); + logger_middleware.send_network_log( + ctx, + rules_middleware.action_result_objs, + GLOBAL_CONSTANTS.REQUEST_STATE.COMPLETE + ); + return; + } + try { - const host = get_request_url(ctx); - const isAddressUnreachable = await isAddressUnreachableError(host); + // Resolve against a clean hostname (headers.host minus any port), not + // the full request URL — dns.lookup() can't parse a URL and would + // otherwise report every upstream error as ENOTFOUND. + const hostname = (ctx.clientToProxyRequest?.headers?.host || "").split(":")[0]; + const isAddressUnreachable = await isAddressUnreachableError(hostname); if (isAddressUnreachable) { const { status, contentType, body } = dataToServeUnreachablePage(host); ctx.proxyToClientResponse.writeHead( From 51d3229a123d72478c79e9dfdc1abb2cadfa3a30 Mon Sep 17 00:00:00 2001 From: dinex-dev Date: Mon, 22 Jun 2026 11:18:39 +0530 Subject: [PATCH 4/4] RQ-2425: address CodeRabbit review on PR #108 - handleUnreachableAddress.js: HTML-escape host/code before embedding into the cert-error and unreachable error pages (prevents reflected markup/script injection via a crafted request URL/host header). - index.js: parse the upstream hostname via URL() (strip IPv6 brackets/port) instead of split(":")[0], so IPv6 hosts aren't misclassified as ERR_NAME_NOT_RESOLVED. - rq-proxy.ts: remember allowInsecureCerts on RQProxy and replay it once the middleware manager is created, so a toggle set before init isn't lost. Rebuilt dist. Verified: malicious host is escaped in the error page; IPv6 host parsing (host:port, [::1]:443, [2001:db8::1]) resolves correctly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../helpers/handleUnreachableAddress.js | 16 ++++++++++++++-- dist/components/proxy-middleware/index.js | 12 ++++++++++-- dist/rq-proxy.d.ts | 1 + dist/rq-proxy.js | 10 +++++++++- .../helpers/handleUnreachableAddress.js | 17 +++++++++++++++-- src/components/proxy-middleware/index.js | 11 +++++++++-- src/rq-proxy.ts | 13 ++++++++++++- 7 files changed, 70 insertions(+), 10 deletions(-) diff --git a/dist/components/proxy-middleware/helpers/handleUnreachableAddress.js b/dist/components/proxy-middleware/helpers/handleUnreachableAddress.js index 27d3a95..66a52b7 100644 --- a/dist/components/proxy-middleware/helpers/handleUnreachableAddress.js +++ b/dist/components/proxy-middleware/helpers/handleUnreachableAddress.js @@ -9,6 +9,16 @@ exports.certErrorToken = certErrorToken; exports.dataToServeCertErrorPage = dataToServeCertErrorPage; exports.dataToServeUnreachablePage = dataToServeUnreachablePage; const dns_1 = __importDefault(require("dns")); +// Escape request-derived values before embedding them into the HTML error pages +// so a crafted host/URL can't inject markup/script (reflected injection). +function escapeHtml(value) { + return String(value === undefined || value === null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} function isAddressUnreachableError(host) { return new Promise((resolve, reject) => { dns_1.default.lookup(host, (err, address) => { @@ -69,6 +79,8 @@ function certErrorToken(code) { } function dataToServeCertErrorPage(host, code) { const token = certErrorToken(code); + const safeHost = escapeHtml(host); + const safeCode = escapeHtml(code); return { status: 502, contentType: 'text/html', @@ -128,7 +140,7 @@ function dataToServeCertErrorPage(host, code) {
:(

This site's SSL certificate isn't trusted

-

Requestly couldn't verify the TLS certificate of ${host}${code ? ` (${code})` : ''}.

+

Requestly couldn't verify the TLS certificate of ${safeHost}${code ? ` (${safeCode})` : ''}.

If you trust this host, enable “Allow insecure SSL in proxy interceptor” in Requestly desktop settings and reload.

${token}

@@ -190,7 +202,7 @@ function dataToServeUnreachablePage(host) {
:(

This site can’t be reached

-

The webpage at ${host}/ might be temporarily down or it may have moved permanently to a new web address.

+

The webpage at ${escapeHtml(host)}/ might be temporarily down or it may have moved permanently to a new web address.

ERR_NAME_NOT_RESOLVED

diff --git a/dist/components/proxy-middleware/index.js b/dist/components/proxy-middleware/index.js index 1a461c6..f178de8 100644 --- a/dist/components/proxy-middleware/index.js +++ b/dist/components/proxy-middleware/index.js @@ -137,8 +137,16 @@ class ProxyMiddlewareManager { try { // Resolve against a clean hostname (headers.host minus any port), not // the full request URL — dns.lookup() can't parse a URL and would - // otherwise report every upstream error as ENOTFOUND. - const hostname = (((_b = (_a = ctx.clientToProxyRequest) === null || _a === void 0 ? void 0 : _a.headers) === null || _b === void 0 ? void 0 : _b.host) || "").split(":")[0]; + // otherwise report every upstream error as ENOTFOUND. Parse via URL so + // IPv6 literals (e.g. "[::1]:443") are handled correctly, not split on ":". + const rawHost = ((_b = (_a = ctx.clientToProxyRequest) === null || _a === void 0 ? void 0 : _a.headers) === null || _b === void 0 ? void 0 : _b.host) || ""; + let hostname = rawHost; + try { + hostname = new URL(`http://${rawHost}`).hostname.replace(/^\[|\]$/g, ""); + } + catch (e) { + hostname = rawHost.replace(/:\d+$/, ""); + } const isAddressUnreachable = await (0, handleUnreachableAddress_1.isAddressUnreachableError)(hostname); if (isAddressUnreachable) { const { status, contentType, body } = (0, handleUnreachableAddress_1.dataToServeUnreachablePage)(host); diff --git a/dist/rq-proxy.d.ts b/dist/rq-proxy.d.ts index 7d414cc..bca9653 100644 --- a/dist/rq-proxy.d.ts +++ b/dist/rq-proxy.d.ts @@ -11,6 +11,7 @@ declare class RQProxy { rulesHelper: RulesHelper; loggerService: ILoggerService; globalState: State; + private allowInsecureCerts?; constructor(proxyConfig: ProxyConfig, rulesDataSource: IRulesDataSource, loggerService: ILoggerService, initialGlobalState?: IInitialState); initProxy: (proxyConfig: ProxyConfig) => void; setAllowInsecureCerts: (value: boolean) => void; diff --git a/dist/rq-proxy.js b/dist/rq-proxy.js index d4e31466..08839f8 100644 --- a/dist/rq-proxy.js +++ b/dist/rq-proxy.js @@ -24,6 +24,7 @@ class RQProxy { sslCaDir: proxyConfig.certPath, host: "0.0.0.0", }, (err) => { + var _a, _b; console.log("Proxy Listen"); if (err) { console.log(err); @@ -32,6 +33,10 @@ class RQProxy { console.log("Proxy Started"); this.proxyMiddlewareManager = new proxy_middleware_1.default(this.proxy, proxyConfig, this.rulesHelper, this.loggerService, null); this.proxyMiddlewareManager.init(); + // Replay a toggle that was set before the manager existed. + if (this.allowInsecureCerts !== undefined) { + (_b = (_a = this.proxyMiddlewareManager).setAllowInsecureCerts) === null || _b === void 0 ? void 0 : _b.call(_a, this.allowInsecureCerts); + } } }); // For Testing // @@ -48,9 +53,12 @@ class RQProxy { // }; // RQ-2425: live-update upstream TLS verification without restarting the proxy. + // Remembers the value so it survives even if set before the middleware manager + // is ready (replayed in initProxy once the manager is created). this.setAllowInsecureCerts = (value) => { var _a, _b; - (_b = (_a = this.proxyMiddlewareManager) === null || _a === void 0 ? void 0 : _a.setAllowInsecureCerts) === null || _b === void 0 ? void 0 : _b.call(_a, value); + this.allowInsecureCerts = !!value; + (_b = (_a = this.proxyMiddlewareManager) === null || _a === void 0 ? void 0 : _a.setAllowInsecureCerts) === null || _b === void 0 ? void 0 : _b.call(_a, this.allowInsecureCerts); }; this.doSomething = () => { console.log("do something"); diff --git a/src/components/proxy-middleware/helpers/handleUnreachableAddress.js b/src/components/proxy-middleware/helpers/handleUnreachableAddress.js index 4fc0f8c..2a813d7 100644 --- a/src/components/proxy-middleware/helpers/handleUnreachableAddress.js +++ b/src/components/proxy-middleware/helpers/handleUnreachableAddress.js @@ -1,5 +1,16 @@ import dns from 'dns'; +// Escape request-derived values before embedding them into the HTML error pages +// so a crafted host/URL can't inject markup/script (reflected injection). +function escapeHtml(value) { + return String(value === undefined || value === null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + export function isAddressUnreachableError(host) { return new Promise((resolve, reject) => { dns.lookup(host, (err, address) => { @@ -60,6 +71,8 @@ export function certErrorToken(code) { export function dataToServeCertErrorPage(host, code) { const token = certErrorToken(code); + const safeHost = escapeHtml(host); + const safeCode = escapeHtml(code); return { status: 502, contentType: 'text/html', @@ -119,7 +132,7 @@ export function dataToServeCertErrorPage(host, code) {
:(

This site's SSL certificate isn't trusted

-

Requestly couldn't verify the TLS certificate of ${host}${code ? ` (${code})` : ''}.

+

Requestly couldn't verify the TLS certificate of ${safeHost}${code ? ` (${safeCode})` : ''}.

If you trust this host, enable “Allow insecure SSL in proxy interceptor” in Requestly desktop settings and reload.

${token}

@@ -182,7 +195,7 @@ export function dataToServeUnreachablePage(host) {
:(

This site can’t be reached

-

The webpage at ${host}/ might be temporarily down or it may have moved permanently to a new web address.

+

The webpage at ${escapeHtml(host)}/ might be temporarily down or it may have moved permanently to a new web address.

ERR_NAME_NOT_RESOLVED

diff --git a/src/components/proxy-middleware/index.js b/src/components/proxy-middleware/index.js index 6d26e7a..19c461b 100644 --- a/src/components/proxy-middleware/index.js +++ b/src/components/proxy-middleware/index.js @@ -210,8 +210,15 @@ class ProxyMiddlewareManager { try { // Resolve against a clean hostname (headers.host minus any port), not // the full request URL — dns.lookup() can't parse a URL and would - // otherwise report every upstream error as ENOTFOUND. - const hostname = (ctx.clientToProxyRequest?.headers?.host || "").split(":")[0]; + // otherwise report every upstream error as ENOTFOUND. Parse via URL so + // IPv6 literals (e.g. "[::1]:443") are handled correctly, not split on ":". + const rawHost = ctx.clientToProxyRequest?.headers?.host || ""; + let hostname = rawHost; + try { + hostname = new URL(`http://${rawHost}`).hostname.replace(/^\[|\]$/g, ""); + } catch (e) { + hostname = rawHost.replace(/:\d+$/, ""); + } const isAddressUnreachable = await isAddressUnreachableError(hostname); if (isAddressUnreachable) { const { status, contentType, body } = dataToServeUnreachablePage(host); diff --git a/src/rq-proxy.ts b/src/rq-proxy.ts index 09cc231..cc47b01 100644 --- a/src/rq-proxy.ts +++ b/src/rq-proxy.ts @@ -16,6 +16,10 @@ class RQProxy { loggerService: ILoggerService; globalState: State; + // RQ-2425: remembered so a toggle set before the middleware manager exists + // (early in startup) isn't lost — it's replayed once the manager is created. + private allowInsecureCerts?: boolean; + constructor( proxyConfig: ProxyConfig, rulesDataSource: IRulesDataSource, @@ -52,6 +56,10 @@ class RQProxy { console.log("Proxy Started"); this.proxyMiddlewareManager = new ProxyMiddlewareManager(this.proxy, proxyConfig, this.rulesHelper, this.loggerService, null); this.proxyMiddlewareManager.init(); + // Replay a toggle that was set before the manager existed. + if (this.allowInsecureCerts !== undefined) { + this.proxyMiddlewareManager.setAllowInsecureCerts?.(this.allowInsecureCerts); + } } } ); @@ -72,8 +80,11 @@ class RQProxy { } // RQ-2425: live-update upstream TLS verification without restarting the proxy. + // Remembers the value so it survives even if set before the middleware manager + // is ready (replayed in initProxy once the manager is created). setAllowInsecureCerts = (value: boolean) => { - this.proxyMiddlewareManager?.setAllowInsecureCerts?.(value); + this.allowInsecureCerts = !!value; + this.proxyMiddlewareManager?.setAllowInsecureCerts?.(this.allowInsecureCerts); } doSomething = () => {