diff --git a/server/package-lock.json b/server/package-lock.json index 0b4a308f9..47868e645 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -26,6 +26,7 @@ "got": "14.4.7", "handlebars": "^4.7.8", "helmet": "^8.0.0", + "http-proxy-agent": "^9.1.0", "https-proxy-agent": "^9.1.0", "isomorphic-dompurify": "^2.26.0", "jmespath": "^0.16.0", @@ -7904,16 +7905,26 @@ } }, "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", "license": "MIT", "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" }, "engines": { - "node": ">= 14" + "node": ">= 20" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" } }, "node_modules/http2-wrapper": { @@ -8289,6 +8300,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/isomorphic-dompurify/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/isomorphic-dompurify/node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -9240,6 +9264,19 @@ } } }, + "node_modules/jsdom/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/jsdom/node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", diff --git a/server/package.json b/server/package.json index a8a2f210a..af1031375 100755 --- a/server/package.json +++ b/server/package.json @@ -49,6 +49,7 @@ "got": "14.4.7", "handlebars": "^4.7.8", "helmet": "^8.0.0", + "http-proxy-agent": "^9.1.0", "https-proxy-agent": "^9.1.0", "isomorphic-dompurify": "^2.26.0", "jmespath": "^0.16.0", diff --git a/server/src/service/network/HttpProvider.ts b/server/src/service/network/HttpProvider.ts index 96b6ec4cb..ff436bd94 100644 --- a/server/src/service/network/HttpProvider.ts +++ b/server/src/service/network/HttpProvider.ts @@ -9,7 +9,7 @@ import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { isStatusUp } from "@/service/network/utils.js"; import { NETWORK_ERROR } from "@/types/network.js"; import CacheableLookup from "cacheable-lookup"; -import { ProxyAgentCache, parseProxyMonitorTypes, resolveProxyForCheck } from "@/service/network/proxyPolicy.js"; +import { ProxyAgentCache, parseProxyMonitorTypes, resolveProxyForCheck } from "@/service/network/ProxyPolicy.js"; export class HttpProvider implements IStatusProvider { readonly type = "http"; @@ -179,17 +179,31 @@ export class HttpProvider implements IStatusProvider { const options: Record = { headers: monitor.secret ? { Authorization: `Bearer ${secret}` } : undefined, }; + let cleanupProxyListener: (() => void) | undefined; // Route through an outbound proxy when the environment configures one and // this monitor type is eligible; otherwise use the shared direct agents. const { proxyUrl } = resolveProxyForCheck(monitor.type, url, this.proxyableTypes); + // Set when the proxy itself refuses the tunnel, so a proxy ACL denial is not + // reported as the target's own status code. + let tunnelFailure: { statusCode: number } | null = null; if (proxyUrl) { - const proxyAgent = this.proxyAgents.get(proxyUrl, !ignoreTlsErrors); + const proxyAgent = this.proxyAgents.get(proxyUrl, url); options.agent = { http: proxyAgent, https: proxyAgent }; // The agent's rejectUnauthorized governs the socket to the proxy, not the // tunnelled handshake with the target, so ignoreTlsErrors has to be stated // per request or it would be silently dropped for proxied monitors. options.https = { rejectUnauthorized: !ignoreTlsErrors }; + + const emitter = proxyAgent as unknown as { + once?: (event: string, cb: (connect: { statusCode: number }) => void) => void; + off?: (event: string, cb: (connect: { statusCode: number }) => void) => void; + }; + const onProxyConnect = (connect: { statusCode: number }) => { + if (connect.statusCode >= 400) tunnelFailure = { statusCode: connect.statusCode }; + }; + emitter.once?.("proxyConnect", onProxyConnect); + cleanupProxyListener = () => emitter.off?.("proxyConnect", onProxyConnect); } else { options.agent = { http: this.httpAgent, @@ -213,7 +227,24 @@ export class HttpProvider implements IStatusProvider { timings: response.timings, }); } catch (error: unknown) { + if (tunnelFailure) { + // The proxy rejected the tunnel, so the target was never reached and + // its status is unknown. Reporting NETWORK_ERROR keeps customUpCodes + // from marking the monitor up on the proxy's 403/407. + return { + monitorId: monitor.id, + teamId: monitor.teamId, + type: monitor.type, + status: false, + code: NETWORK_ERROR, + message: `Proxy refused the connection (HTTP ${(tunnelFailure as { statusCode: number }).statusCode})`, + responseTime: 0, + payload: null as T, + }; + } return this.handleHttpError(error, monitor); + } finally { + cleanupProxyListener?.(); } } } diff --git a/server/src/service/network/proxyPolicy.ts b/server/src/service/network/ProxyPolicy.ts similarity index 71% rename from server/src/service/network/proxyPolicy.ts rename to server/src/service/network/ProxyPolicy.ts index cf9843248..2f3f61288 100644 --- a/server/src/service/network/proxyPolicy.ts +++ b/server/src/service/network/ProxyPolicy.ts @@ -1,3 +1,4 @@ +import { HttpProxyAgent } from "http-proxy-agent"; import { HttpsProxyAgent } from "https-proxy-agent"; import { getProxyForUrl } from "proxy-from-env"; import type { Agent as HttpAgent } from "http"; @@ -75,13 +76,17 @@ export const parseProxyMonitorTypes = (raw: string | undefined): { types: Set(PROXYABLE_MONITOR_TYPES), - warning: `PROXY_MONITOR_TYPES ("${raw}") matched no proxyable monitor type — ${problems.join("; ")}. Falling back to: ${PROXYABLE_MONITOR_TYPES.join(", ")}. Use "none" to disable proxying.`, + warning: `PROXY_MONITOR_TYPES matched no proxyable monitor type — ${problems.join("; ")}. Falling back to: ${PROXYABLE_MONITOR_TYPES.join(", ")}. Use "none" to disable proxying.`, }; } @@ -114,25 +119,28 @@ export const resolveProxyForCheck = (monitorType: MonitorType, url: string, prox /** * Builds proxy agents on demand and reuses them. * - * Keyed by proxy URL *and* TLS mode: a monitor with ignoreTlsErrors must not - * share an agent with a strict one, or the relaxed setting would leak across - * monitors. Caching also preserves connection pooling — without it every check - * would open a fresh tunnel to the proxy. + * The agent must match the *target* scheme, not the proxy's. HttpsProxyAgent + * always issues CONNECT, which hardened proxies (Squid's stock ACL among them) + * permit only to 443 — so a plain-http target needs HttpProxyAgent, which sends + * an absolute-URI request instead. + * + * Caching preserves connection pooling: without it every check would open a + * fresh connection to the proxy. Node keys its own socket pools on the TLS + * options it receives per request, so agents are shared across monitors + * regardless of ignoreTlsErrors — that setting is applied per request in + * HttpProvider, not baked into the agent. */ export class ProxyAgentCache { private readonly agents = new Map(); - get(proxyUrl: string, rejectUnauthorized: boolean): HttpAgent | HttpsAgent { - const key = `${proxyUrl}::${rejectUnauthorized ? "strict" : "insecure"}`; + get(proxyUrl: string, targetUrl: string): HttpAgent | HttpsAgent { + const targetIsSecure = safeProtocol(targetUrl) !== "http:"; + const key = `${proxyUrl}::${targetIsSecure ? "https" : "http"}`; const existing = this.agents.get(key); if (existing) return existing; - const agent = new HttpsProxyAgent(proxyUrl, { - keepAlive: true, - maxSockets: 256, - maxFreeSockets: 256, - rejectUnauthorized, - }) as unknown as HttpsAgent; + const options = { keepAlive: true, maxSockets: 256, maxFreeSockets: 256 }; + const agent = (targetIsSecure ? new HttpsProxyAgent(proxyUrl, options) : new HttpProxyAgent(proxyUrl, options)) as unknown as HttpsAgent; this.agents.set(key, agent); return agent; @@ -143,3 +151,12 @@ export class ProxyAgentCache { return this.agents.size; } } + +/** Target URLs are validated upstream; fall back to https on anything odd. */ +const safeProtocol = (url: string): string => { + try { + return new URL(url).protocol; + } catch { + return "https:"; + } +}; diff --git a/server/test/unit/providers/network/httpProvider.test.ts b/server/test/unit/providers/network/httpProvider.test.ts index c6b7e8cb7..50c70ba5c 100644 --- a/server/test/unit/providers/network/httpProvider.test.ts +++ b/server/test/unit/providers/network/httpProvider.test.ts @@ -581,6 +581,30 @@ describe("HttpProvider", () => { expect(isProxyAgent(agentOf())).toBe(false); }); + it("reports a refused proxy tunnel as a network error, not the target's status", async () => { + clearProxyEnv(); + process.env.HTTPS_PROXY = "http://corp:3128"; + const { provider } = createProvider(); + // The agent replays a refused CONNECT onto the socket, so the proxy's own + // status would otherwise be parsed as the target's. + const agent: any = (provider as any).proxyAgents.get("http://corp:3128", "https://example.com"); + const err = new HTTPError("Response code 403 (Forbidden)"); + (err as any).response = { statusCode: 403, body: "", headers: {} }; + (err as any).timings = { phases: { total: 5 } }; + mockGot.mockImplementation(() => { + agent.emit("proxyConnect", { statusCode: 403 }); + return Promise.reject(err); + }); + + const result = await provider.handle(makeMonitor({ url: "https://example.com", customUpCodes: [403] })); + + // customUpCodes must not mark the monitor up on the proxy's status. + expect(result.status).toBe(false); + expect(result.code).toBe(NETWORK_ERROR); + expect(result.message).toContain("Proxy refused"); + clearProxyEnv(); + }); + it("honours NO_PROXY", async () => { clearProxyEnv(); process.env.HTTPS_PROXY = "http://corp:3128"; diff --git a/server/test/unit/providers/network/proxyPolicy.test.ts b/server/test/unit/providers/network/proxyPolicy.test.ts index cd495bf6c..c39aef55c 100644 --- a/server/test/unit/providers/network/proxyPolicy.test.ts +++ b/server/test/unit/providers/network/proxyPolicy.test.ts @@ -4,7 +4,7 @@ import { ProxyAgentCache, parseProxyMonitorTypes, resolveProxyForCheck, -} from "../../../../src/service/network/proxyPolicy.ts"; +} from "../../../../src/service/network/ProxyPolicy.ts"; // proxy-from-env reads process.env directly, so these tests set and restore it. const PROXY_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"]; @@ -59,6 +59,15 @@ describe("parseProxyMonitorTypes", () => { expect(parseProxyMonitorTypes("none,http").types.size).toBe(0); }); + it("leaves an all-unproxyable list empty rather than proxying types nobody named", () => { + // "ping,port" is a misunderstanding, but a deliberate one — silently + // proxying http instead would route traffic the operator never nominated. + const { types, warning } = parseProxyMonitorTypes("ping,port"); + + expect(types.size).toBe(0); + expect(warning).toContain("cannot be proxied"); + }); + it("falls back to defaults when every entry is invalid, rather than disabling proxying", () => { // Silently proxying nothing would take every HTTP monitor down in a // restricted-egress deployment, which is worse than ignoring the typo. @@ -158,30 +167,52 @@ describe("ProxyAgentCache", () => { it("reuses one agent per proxy so connections stay pooled", () => { const cache = new ProxyAgentCache(); - const first = cache.get("http://corp:3128", true); - const second = cache.get("http://corp:3128", true); + const first = cache.get("http://corp:3128", "https://example.com"); + const second = cache.get("http://corp:3128", "https://other.example.com"); expect(second).toBe(first); expect(cache.size).toBe(1); }); - it("keeps strict and TLS-ignoring agents separate", () => { + it("uses a CONNECT-based agent for https targets", () => { const cache = new ProxyAgentCache(); - const strict = cache.get("http://corp:3128", true); - const insecure = cache.get("http://corp:3128", false); + const agent = cache.get("http://corp:3128", "https://example.com"); + + expect(agent.constructor.name).toBe("HttpsProxyAgent"); + }); + + it("uses an absolute-URI agent for plain http targets", () => { + const cache = new ProxyAgentCache(); + + // HttpsProxyAgent would issue CONNECT, which hardened proxies allow only + // to 443, breaking every http:// monitor. + const agent = cache.get("http://corp:3128", "http://example.com"); + + expect(agent.constructor.name).toBe("HttpProxyAgent"); + }); + + it("keeps http and https target agents separate", () => { + const cache = new ProxyAgentCache(); + + cache.get("http://corp:3128", "https://example.com"); + cache.get("http://corp:3128", "http://example.com"); - // Sharing one agent would leak ignoreTlsErrors across monitors. - expect(insecure).not.toBe(strict); expect(cache.size).toBe(2); }); it("keeps a separate agent per proxy URL", () => { const cache = new ProxyAgentCache(); - cache.get("http://a:3128", true); - cache.get("http://b:3128", true); + cache.get("http://a:3128", "https://example.com"); + cache.get("http://b:3128", "https://example.com"); expect(cache.size).toBe(2); }); + + it("falls back to a CONNECT agent for an unparseable target", () => { + const cache = new ProxyAgentCache(); + + expect(cache.get("http://corp:3128", "not-a-url").constructor.name).toBe("HttpsProxyAgent"); + }); });