@checkstack/healthcheck-http-backend 0.4.11 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,145 @@
1
1
  # @checkstack/healthcheck-http-backend
2
2
 
3
+ ## 0.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [2e20792]
8
+ - Updated dependencies [2e20792]
9
+ - @checkstack/backend-api@0.26.0
10
+ - @checkstack/healthcheck-common@1.8.1
11
+ - @checkstack/common@0.17.0
12
+
13
+ ## 0.5.0
14
+
15
+ ### Minor Changes
16
+
17
+ - 8cad340: Fix collectors hard-failing on successful-but-non-OK application results.
18
+
19
+ A health-check collector must fail only when the TRANSPORT fails (the probe
20
+ could not complete: DNS/connect/TLS failure, timeout, aborted, unspawnable
21
+ process). A successfully-received result that is simply "not what you hoped" is
22
+ an assertable metric, not a collector failure - the user's assertions (or the
23
+ no-assertion default) decide health.
24
+
25
+ BREAKING CHANGE: checks that previously relied on a collector auto-failing on a
26
+ non-OK result will now report healthy unless an explicit assertion is added.
27
+ Affected collectors:
28
+
29
+ - HTTP request collector: a received response (including 4xx/5xx) is now a
30
+ successful collection. `statusCode` / `statusText` / `success` are exposed as
31
+ metrics; the collector no longer sets `error` on a non-2xx. Add a
32
+ `statusCode equals 200` assertion to fail on non-200 (or `statusCode equals
33
+ 404` for a check that wants a 404). Only a real transport failure fails the
34
+ collector.
35
+ - gRPC health collector: a completed health RPC returning `NOT_SERVING` /
36
+ `SERVICE_UNKNOWN` / `UNKNOWN` is now a successful collection. `serving` /
37
+ `status` are assertable metrics; only a real RPC transport error fails the
38
+ collector.
39
+ - Jenkins node-health collector: offline nodes are now an assertable metric
40
+ (`offlineNodes`); a successful all-nodes API call no longer fails the
41
+ collector when some nodes are offline.
42
+ - Script (shell) execute collector: a non-zero exit code is now an assertable
43
+ metric (`exitCode` / `success`); the collector no longer hard-fails on a
44
+ non-zero exit. A timeout or a script that could not be spawned still fails the
45
+ collector (those are transport failures). Add a `success is true` (or
46
+ `exitCode equals 0`) assertion to fail on a non-zero exit.
47
+
48
+ Other strategies (DNS, TCP, TLS, ping, ssh, mysql, postgres, redis, rcon,
49
+ hardware, and the inline-script collector) were audited and already failed only
50
+ on genuine transport failures.
51
+
52
+ - 8cad340: feat(healthcheck-http): SSRF egress guard for the in-process HTTP collector
53
+
54
+ The HTTP healthcheck strategy runs in-process on the trusted core (whenever a
55
+ check is local or not satellite-only), so it now applies a secure-by-default
56
+ egress guard before connecting:
57
+
58
+ - Denies the cloud-metadata + link-local ranges by default (the same
59
+ `ALWAYS_BLOCKED_CIDRS` the script sandbox enforces), so a check can no longer
60
+ be pointed at `http://169.254.169.254/...` to read instance credentials.
61
+ - Keeps RFC1918 / internal probing ALLOWED by default (a monitoring tool's job).
62
+ - Resolves the target host to IP(s) and checks the CONNECTED IP, pinning the
63
+ request to the validated IP to resist DNS-rebind.
64
+ - Operator-extensible: the new optional `egressDenyCidrs` field on the HTTP
65
+ strategy config adds further CIDRs on top of the always-on block.
66
+
67
+ `@checkstack/backend-api` exports a reusable `resolveAndValidateHost` /
68
+ `pinUrlToIp` SSRF guard plus `DEFAULT_EGRESS_DENY_CIDRS`.
69
+
70
+ - 8cad340: Add a finer per-run transport timing breakdown to health checks.
71
+
72
+ Each run now records an optional structured `metadata.timings` (DNS, connect,
73
+ TLS, wait/time-to-first-byte, transfer, and a `processing` catch-all for
74
+ non-HTTP operation time). The run-detail view renders the phases it has, in
75
+ transport order, and falls back to the previous Connection + Processing split
76
+ for older runs that lack the finer data.
77
+
78
+ For HTTP the request is issued verbatim through `fetch` (original URL, headers,
79
+ and body), so request behavior is identical to a plain `fetch`. The timing is
80
+ measured around it: `fetch` resolves at the response headers, so wait
81
+ (time-to-first-byte) and transfer (body) are measured exactly on the request,
82
+ DNS is timed at the resolve step, and connect/TLS come from a short-lived,
83
+ best-effort raw `net`/`tls` probe to the same already-validated IP (the request
84
+ socket exposes no connect/handshake events on the Bun runtime). The probe is
85
+ timing-only and never fails the check. The probe validates the TLS certificate
86
+ (against the original hostname via SNI) like the real request does - it does not
87
+ disable certificate validation; an unverifiable cert simply yields no TLS-phase
88
+ timing rather than aborting. Other transports surface the connect and operation
89
+ times they already measure.
90
+
91
+ The SSRF guard now validates the resolved host (rejecting cloud-metadata /
92
+ link-local and operator-denied ranges) as a pre-flight check and no longer pins
93
+ the request to the resolved IP. Pinning rewrote the URL to the IP literal and
94
+ moved the host to the `Host` header, which breaks HTTP/2 origins (their
95
+ authority comes from the URL's `:authority`, not `Host`) - that is why real
96
+ hosts such as `google.com` started answering 404/429 instead of 200. The
97
+ pre-flight validation keeps blocking static metadata/link-local targets and
98
+ direct denied IP literals; the only thing dropped is DNS-rebind TOCTOU
99
+ protection (a narrow window that pinning closed at the cost of breaking
100
+ legitimate HTTP/2 requests).
101
+
102
+ The run-detail "slowest" badge no longer collides with the timing bar, and a
103
+ genuinely sub-millisecond phase reads as "<1 ms" instead of a bare "0 ms".
104
+
105
+ ### Patch Changes
106
+
107
+ - 8cad340: Retune anomaly-detection defaults across every health-check strategy and the
108
+ hardware collector for a low-noise, problem-focused out-of-the-box experience.
109
+
110
+ The detection engine already learns a per-metric baseline, debounces with a
111
+ confirmation window, and applies practical-significance floors. This pass tunes
112
+ the per-metric **defaults** so a fresh install alerts only on genuine,
113
+ statistically-significant, problem-mapping deviations instead of flooding on
114
+ every metric that wiggles. 264 metrics were reviewed:
115
+
116
+ - **Default-disabled** the high-noise and un-baselineable classes that were
117
+ alerting for no good reason: raw identifiers and counts (status codes, error
118
+ and row counts, build counts, player and executor counts), config echoes and
119
+ near-constants (probe packet counts, CPU core count, total/swap memory),
120
+ payload-size and other run-to-run-volatile values, and deterministic values
121
+ like certificate days-remaining (governed by the check's own static-threshold
122
+ health logic, not statistics). These stay chartable and can be re-enabled per
123
+ field.
124
+ - **Hardened** the signals that should alert - latency/response/execution time
125
+ and availability/success/saturation percentages - with confirmation windows
126
+ and absolute + relative floors so brief spikes and sub-threshold jitter no
127
+ longer flap, and prefer percentage metrics over their absolute twins.
128
+
129
+ No detection-engine or schema changes; only per-metric `x-anomaly-*` defaults.
130
+ Users who had opted into any now-disabled metric keep their explicit override.
131
+
132
+ - Updated dependencies [8cad340]
133
+ - Updated dependencies [8cad340]
134
+ - Updated dependencies [8cad340]
135
+ - Updated dependencies [8cad340]
136
+ - Updated dependencies [8cad340]
137
+ - Updated dependencies [8cad340]
138
+ - Updated dependencies [8cad340]
139
+ - @checkstack/backend-api@0.25.0
140
+ - @checkstack/healthcheck-common@1.8.0
141
+ - @checkstack/common@0.17.0
142
+
3
143
  ## 0.4.11
4
144
 
5
145
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-http-backend",
3
- "version": "0.4.11",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "checkstack": {
@@ -14,16 +14,16 @@
14
14
  "pack": "bunx @checkstack/scripts plugin-pack"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/backend-api": "0.24.1",
18
- "@checkstack/healthcheck-common": "1.7.1",
17
+ "@checkstack/backend-api": "0.26.0",
18
+ "@checkstack/healthcheck-common": "1.8.1",
19
19
  "jsonpath-plus": "^10.3.0",
20
- "@checkstack/common": "0.16.0"
20
+ "@checkstack/common": "0.17.0"
21
21
  },
22
22
  "devDependencies": {
23
23
  "@types/bun": "^1.0.0",
24
24
  "typescript": "^5.0.0",
25
25
  "@checkstack/tsconfig": "0.0.7",
26
- "@checkstack/scripts": "0.6.2"
26
+ "@checkstack/scripts": "0.6.4"
27
27
  },
28
28
  "description": "Checkstack healthcheck-http-backend plugin",
29
29
  "author": {
@@ -0,0 +1,43 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
2
+ import net from "node:net";
3
+ import type { AddressInfo } from "node:net";
4
+ import { probeConnectTiming } from "./connect-probe";
5
+
6
+ describe("probeConnectTiming", () => {
7
+ let server: net.Server;
8
+ let port = 0;
9
+
10
+ beforeAll(async () => {
11
+ server = net.createServer((socket) => socket.end());
12
+ await new Promise<void>((resolve) =>
13
+ server.listen(0, "127.0.0.1", resolve),
14
+ );
15
+ port = (server.address() as AddressInfo).port;
16
+ });
17
+
18
+ afterAll(() => server.close());
19
+
20
+ it("measures TCP connect time to a listening port", async () => {
21
+ const result = await probeConnectTiming({
22
+ ip: "127.0.0.1",
23
+ port,
24
+ tls: false,
25
+ timeoutMs: 2000,
26
+ });
27
+ expect(result.connectMs).toBeGreaterThanOrEqual(0);
28
+ expect(result.tlsMs).toBeUndefined();
29
+ });
30
+
31
+ it("never throws on a refused connection (timing-only, best-effort)", async () => {
32
+ // Port the listening server is NOT on; connection is refused.
33
+ const result = await probeConnectTiming({
34
+ ip: "127.0.0.1",
35
+ port: port + 1,
36
+ tls: false,
37
+ timeoutMs: 1000,
38
+ });
39
+ // Resolves to an empty result rather than rejecting - it must never fail
40
+ // the health check.
41
+ expect(result.connectMs).toBeUndefined();
42
+ });
43
+ });
@@ -0,0 +1,93 @@
1
+ import net from "node:net";
2
+ import tls from "node:tls";
3
+
4
+ /**
5
+ * Best-effort TCP/TLS connect timing for the HTTP timing breakdown.
6
+ *
7
+ * Why a separate probe: the actual request goes through `fetch`, whose internal
8
+ * socket does NOT expose connect/handshake events on the Bun runtime (Bun never
9
+ * emits `connect`/`secureConnect` on `node:http` sockets). Raw `node:net` /
10
+ * `node:tls` sockets DO emit them on Bun, so we open one short-lived connection
11
+ * to the SAME already-validated IP purely to measure how long TCP connect and
12
+ * the TLS handshake take. It is representative (same host/IP/network, run
13
+ * alongside the request), not the request's own socket.
14
+ *
15
+ * This is timing-only and MUST NEVER fail the check: any error / timeout
16
+ * resolves to whatever was measured so far (possibly nothing). The socket is
17
+ * destroyed as soon as the handshake completes - no application bytes are sent.
18
+ */
19
+ export interface ConnectProbeResult {
20
+ /** TCP connect duration in ms (from probe start to the `connect` event). */
21
+ connectMs?: number;
22
+ /** TLS handshake duration in ms (`connect` -> `secureConnect`); https only. */
23
+ tlsMs?: number;
24
+ }
25
+
26
+ export interface ConnectProbeOptions {
27
+ /** The pre-validated IP literal to connect to (no DNS, no SSRF re-resolve). */
28
+ ip: string;
29
+ port: number;
30
+ /** Whether to perform a TLS handshake (https targets). */
31
+ tls: boolean;
32
+ /** SNI server name for the TLS handshake (the original hostname). */
33
+ servername?: string;
34
+ /** Abort the probe after this many ms. */
35
+ timeoutMs: number;
36
+ /** Monotonic clock; injectable for tests. Defaults to `performance.now`. */
37
+ now?: () => number;
38
+ }
39
+
40
+ export function probeConnectTiming({
41
+ ip,
42
+ port,
43
+ tls: useTls,
44
+ servername,
45
+ timeoutMs,
46
+ now = () => performance.now(),
47
+ }: ConnectProbeOptions): Promise<ConnectProbeResult> {
48
+ return new Promise<ConnectProbeResult>((resolve) => {
49
+ const result: ConnectProbeResult = {};
50
+ const start = now();
51
+ let settled = false;
52
+
53
+ const finish = (socket: net.Socket): void => {
54
+ if (settled) return;
55
+ settled = true;
56
+ socket.destroy();
57
+ resolve(result);
58
+ };
59
+
60
+ if (useTls) {
61
+ // Validate the certificate (the default): it is checked against
62
+ // `servername` (the original hostname), not the IP we dialed, so a
63
+ // legitimately valid cert still verifies. We deliberately do NOT disable
64
+ // validation - the real `fetch` validates strictly too, so a bad cert
65
+ // already fails the check; matching that here is consistent and avoids the
66
+ // insecure `rejectUnauthorized: false`. If the handshake can't complete
67
+ // (invalid/self-signed cert), the `error` handler below resolves with just
68
+ // the TCP `connectMs` and no `tlsMs` - timing is best-effort, never fatal.
69
+ const socket = tls.connect({
70
+ host: ip,
71
+ port,
72
+ ...(servername === undefined ? {} : { servername }),
73
+ });
74
+ socket.once("connect", () => {
75
+ result.connectMs = Math.max(0, now() - start);
76
+ });
77
+ socket.once("secureConnect", () => {
78
+ const handshakeDone = now() - start;
79
+ result.tlsMs = Math.max(0, handshakeDone - (result.connectMs ?? 0));
80
+ finish(socket);
81
+ });
82
+ socket.once("error", () => finish(socket));
83
+ socket.setTimeout(timeoutMs, () => finish(socket));
84
+ } else {
85
+ const socket = net.connect({ host: ip, port }, () => {
86
+ result.connectMs = Math.max(0, now() - start);
87
+ finish(socket);
88
+ });
89
+ socket.once("error", () => finish(socket));
90
+ socket.setTimeout(timeoutMs, () => finish(socket));
91
+ }
92
+ });
93
+ }
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it, mock } from "bun:test";
2
+ import { evaluateAssertions } from "@checkstack/backend-api";
2
3
  import { RequestCollector, type RequestConfig } from "./request-collector";
3
4
  import type { HttpTransportClient } from "./transport-client";
4
5
 
@@ -43,7 +44,13 @@ describe("RequestCollector", () => {
43
44
  expect(result.error).toBeUndefined();
44
45
  });
45
46
 
46
- it("should return error for failed requests", async () => {
47
+ // Regression: a received-but-non-2xx response (e.g. 500) is a SUCCESSFUL
48
+ // collection - the server was reached and answered. The collector must NOT
49
+ // set the `error` field (which the executor treats as a transport failure
50
+ // and uses to hard-fail the run). It records the status code as a metric
51
+ // and lets assertions decide health. Previously this asserted the wrong
52
+ // behavior (`error` contains "500"); updated to the corrected semantics.
53
+ it("does not hard-fail the collector on a 5xx response", async () => {
47
54
  const collector = new RequestCollector();
48
55
  const client = createMockClient({
49
56
  statusCode: 500,
@@ -61,8 +68,51 @@ describe("RequestCollector", () => {
61
68
  });
62
69
 
63
70
  expect(result.result.statusCode).toBe(500);
71
+ // `success` stays a metric (2xx/3xx => true); 500 => false.
64
72
  expect(result.result.success).toBe(false);
65
- expect(result.error).toContain("500");
73
+ // The collector reports transport success: no error field is set.
74
+ expect(result.error).toBeUndefined();
75
+ });
76
+
77
+ // Regression: a 404 is a fully successful HTTP transaction. The collector
78
+ // must report transport success (no `error`) and expose the status code as
79
+ // a metric so a "statusCode equals 404" assertion can pass (GREEN), and so
80
+ // a check with no assertions is not auto-failed by the collector.
81
+ it("treats a 404 as a successful collection with the status as a metric", async () => {
82
+ const collector = new RequestCollector();
83
+ const client = createMockClient({
84
+ statusCode: 404,
85
+ statusText: "Not Found",
86
+ });
87
+
88
+ const result = await collector.execute({
89
+ config: {
90
+ url: "https://example.com/missing",
91
+ method: "GET",
92
+ timeout: 5000,
93
+ },
94
+ client,
95
+ pluginId: "test",
96
+ });
97
+
98
+ expect(result.result.statusCode).toBe(404);
99
+ expect(result.error).toBeUndefined();
100
+
101
+ // A "status code equals 404" assertion passes against the result, so a
102
+ // check that WANTS a 404 can be green.
103
+ const failed = evaluateAssertions(
104
+ [{ field: "statusCode", operator: "equals", value: 404 }],
105
+ result.result as Record<string, unknown>,
106
+ );
107
+ expect(failed).toBeNull();
108
+
109
+ // A "status code equals 200" assertion fails - the user, not the
110
+ // collector, decides this is unhealthy.
111
+ const failed200 = evaluateAssertions(
112
+ [{ field: "statusCode", operator: "equals", value: 200 }],
113
+ result.result as Record<string, unknown>,
114
+ );
115
+ expect(failed200).not.toBeNull();
66
116
  });
67
117
 
68
118
  it("should convert headers array to record", async () => {
@@ -81,8 +81,12 @@ const requestResultSchema = healthResultSchema({
81
81
  statusCode: healthResultNumber({
82
82
  "x-chart-type": "counter",
83
83
  "x-chart-label": "Status Code",
84
- "x-anomaly-enabled": true,
85
- "x-anomaly-direction": "dominance",
84
+ // Off by default: the raw status code is an identifier, not a quantity
85
+ // with a meaningful baseline. Legitimate shifts (200 -> 301/302 redirects,
86
+ // content negotiation) are not problems, while real availability loss is
87
+ // already captured by the `success` boolean. Charting stays available for
88
+ // opt-in.
89
+ "x-anomaly-enabled": false,
86
90
  }),
87
91
  statusText: healthResultString({
88
92
  "x-chart-type": "text",
@@ -125,6 +129,13 @@ const requestAggregatedFields = {
125
129
  "x-chart-unit": "ms",
126
130
  "x-anomaly-enabled": true,
127
131
  "x-anomaly-direction": "lower-is-better",
132
+ // Latency: bias toward fewer false positives. Require several consecutive
133
+ // anomalous buckets, ignore small absolute jitter (tens of ms) so fast
134
+ // endpoints stay quiet, and require a meaningful relative jump.
135
+ "x-anomaly-sensitivity": 2,
136
+ "x-anomaly-confirmation-window": 3,
137
+ "x-anomaly-min-absolute-delta": 50,
138
+ "x-anomaly-min-relative-delta": 0.5,
128
139
  }),
129
140
  successRate: aggregatedRate({
130
141
  "x-chart-type": "gauge",
@@ -132,6 +143,11 @@ const requestAggregatedFields = {
132
143
  "x-chart-unit": "%",
133
144
  "x-anomaly-enabled": true,
134
145
  "x-anomaly-direction": "higher-is-better",
146
+ // Availability percent: the canonical saturation/failure signal. Debounce
147
+ // transient single-bucket dips and ignore sub-percent noise so only a real,
148
+ // sustained drop alerts.
149
+ "x-anomaly-confirmation-window": 3,
150
+ "x-anomaly-min-absolute-delta": 2,
135
151
  }),
136
152
  };
137
153
 
@@ -214,6 +230,14 @@ export class RequestCollector implements CollectorStrategy<
214
230
  });
215
231
 
216
232
  const responseTimeMs = Date.now() - startTime;
233
+ // `success` is an ASSERTABLE METRIC (2xx/3xx), not a collector-failure
234
+ // signal. Receiving ANY response - including a 4xx/5xx - is a successful
235
+ // collection: the server was reached and answered. A real transport failure
236
+ // (DNS, connect, TLS, timeout, aborted) throws out of `client.exec` above
237
+ // and the executor records it as a collector failure. We must NOT set the
238
+ // `error` field here for a received-but-non-2xx response, otherwise the
239
+ // executor hard-fails the run and assertions (e.g. "statusCode equals 404")
240
+ // never get a chance to decide health.
217
241
  const success = response.statusCode >= 200 && response.statusCode < 400;
218
242
 
219
243
  return {
@@ -225,9 +249,6 @@ export class RequestCollector implements CollectorStrategy<
225
249
  bodyLength: response.body?.length ?? 0,
226
250
  success,
227
251
  },
228
- error: success
229
- ? undefined
230
- : `HTTP ${response.statusCode}: ${response.statusText}`,
231
252
  };
232
253
  }
233
254
 
@@ -1,11 +1,83 @@
1
- import { describe, expect, it, spyOn, afterEach } from "bun:test";
1
+ import {
2
+ afterAll,
3
+ beforeAll,
4
+ describe,
5
+ expect,
6
+ it,
7
+ } from "bun:test";
8
+ import * as http from "node:http";
9
+ import { AddressInfo } from "node:net";
2
10
  import { HttpHealthCheckStrategy } from "./strategy";
3
11
 
4
12
  describe("HttpHealthCheckStrategy", () => {
5
- const strategy = new HttpHealthCheckStrategy();
13
+ // Inject a deterministic DNS resolver so the in-process SSRF guard does not
14
+ // depend on real network DNS in unit tests. By default every host resolves
15
+ // to a public IP (allowed); specific tests override with their own resolver.
16
+ const publicLookup = async () => [
17
+ { address: "93.184.216.34", family: 4 },
18
+ ];
19
+ const strategy = new HttpHealthCheckStrategy(publicLookup);
20
+
21
+ // A real local server backs the `client.exec` behaviour tests. The request is
22
+ // issued verbatim (no IP pinning), so the URL targets loopback directly; the
23
+ // SSRF guard validates the `127.0.0.1` literal (an allowed range) before the
24
+ // request goes out.
25
+ let server: http.Server;
26
+ let serverPort = 0;
27
+ const loopbackLookup = async () => [{ address: "127.0.0.1", family: 4 }];
28
+ const localStrategy = new HttpHealthCheckStrategy(loopbackLookup);
29
+ const localUrl = (path: string) => `http://127.0.0.1:${serverPort}${path}`;
30
+
31
+ beforeAll(async () => {
32
+ server = http.createServer((req, res) => {
33
+ const url = req.url ?? "/";
34
+ if (url === "/notfound") {
35
+ res.writeHead(404, { "content-type": "text/plain" });
36
+ res.end("missing");
37
+ return;
38
+ }
39
+ if (url === "/echo") {
40
+ const chunks: Buffer[] = [];
41
+ req.on("data", (c: Buffer) => chunks.push(c));
42
+ req.on("end", () => {
43
+ res.writeHead(200, { "content-type": "application/json" });
44
+ res.end(
45
+ JSON.stringify({
46
+ method: req.method,
47
+ body: Buffer.concat(chunks).toString("utf8"),
48
+ auth: req.headers["authorization"] ?? null,
49
+ custom: req.headers["x-custom-header"] ?? null,
50
+ host: req.headers["host"] ?? null,
51
+ }),
52
+ );
53
+ });
54
+ return;
55
+ }
56
+ if (url === "/text") {
57
+ res.writeHead(200, { "content-type": "text/plain" });
58
+ res.end("Hello World");
59
+ return;
60
+ }
61
+ if (url === "/slow") {
62
+ // Delay the response so the server's processing time must land in the
63
+ // `waitMs` (time-to-first-byte) phase, not vanish.
64
+ setTimeout(() => {
65
+ res.writeHead(200, { "content-type": "text/plain" });
66
+ res.end("slow");
67
+ }, 300);
68
+ return;
69
+ }
70
+ res.writeHead(200, { "content-type": "application/json" });
71
+ res.end(JSON.stringify({ status: "ok" }));
72
+ });
73
+ await new Promise<void>((resolve) =>
74
+ server.listen(0, "127.0.0.1", resolve),
75
+ );
76
+ serverPort = (server.address() as AddressInfo).port;
77
+ });
6
78
 
7
- afterEach(() => {
8
- spyOn(globalThis, "fetch").mockRestore();
79
+ afterAll(() => {
80
+ server.close();
9
81
  });
10
82
 
11
83
  describe("config migration (assume-v1-on-read)", () => {
@@ -57,17 +129,11 @@ describe("HttpHealthCheckStrategy", () => {
57
129
 
58
130
  describe("client.exec", () => {
59
131
  it("should return successful response for valid request", async () => {
60
- spyOn(globalThis, "fetch").mockResolvedValue(
61
- new Response(JSON.stringify({ status: "ok" }), {
62
- status: 200,
63
- statusText: "OK",
64
- headers: { "Content-Type": "application/json" },
65
- }),
66
- );
67
-
68
- const connectedClient = await strategy.createClient({ timeout: 5000 });
132
+ const connectedClient = await localStrategy.createClient({
133
+ timeout: 5000,
134
+ });
69
135
  const result = await connectedClient.client.exec({
70
- url: "https://example.com/api",
136
+ url: localUrl("/api"),
71
137
  method: "GET",
72
138
  timeout: 5000,
73
139
  });
@@ -75,40 +141,67 @@ describe("HttpHealthCheckStrategy", () => {
75
141
  expect(result.statusCode).toBe(200);
76
142
  expect(result.statusText).toBe("OK");
77
143
  expect(result.contentType).toContain("application/json");
144
+ // The connected client surfaces the request's transport phase timings on
145
+ // its holder. wait + transfer come from the fetch (always present); dns
146
+ // is measured at the resolve step; connect/tls are best-effort.
147
+ const timings = connectedClient.timings;
148
+ if (!timings) throw new Error("expected the HTTP client to surface timings");
149
+ expect(timings.waitMs).toBeGreaterThanOrEqual(0);
150
+ expect(timings.transferMs).toBeGreaterThanOrEqual(0);
151
+ expect(timings.dnsMs).toBeGreaterThanOrEqual(0);
78
152
 
79
153
  connectedClient.close();
80
154
  });
81
155
 
82
- it("should return 404 status for not found", async () => {
83
- spyOn(globalThis, "fetch").mockResolvedValue(
84
- new Response(null, { status: 404, statusText: "Not Found" }),
85
- );
156
+ it("attributes a slow server response to the wait phase (not lost)", async () => {
157
+ const connectedClient = await localStrategy.createClient({
158
+ timeout: 5000,
159
+ });
160
+ await connectedClient.client.exec({
161
+ url: localUrl("/slow"),
162
+ method: "GET",
163
+ timeout: 5000,
164
+ });
86
165
 
87
- const connectedClient = await strategy.createClient({ timeout: 5000 });
166
+ const timings = connectedClient.timings;
167
+ if (!timings) throw new Error("expected the HTTP client to surface timings");
168
+ // The 300ms server delay must surface as wait time (the bug was the
169
+ // dominant phase vanishing, leaving only a sub-ms transfer).
170
+ expect(timings.waitMs).toBeGreaterThanOrEqual(250);
171
+ // The breakdown must roughly account for the whole request, not <1ms.
172
+ const total =
173
+ (timings.dnsMs ?? 0) +
174
+ (timings.connectMs ?? 0) +
175
+ (timings.tlsMs ?? 0) +
176
+ (timings.waitMs ?? 0) +
177
+ (timings.transferMs ?? 0);
178
+ expect(total).toBeGreaterThanOrEqual(250);
179
+
180
+ connectedClient.close();
181
+ });
182
+
183
+ it("should return 404 status for not found (received = success)", async () => {
184
+ const connectedClient = await localStrategy.createClient({
185
+ timeout: 5000,
186
+ });
88
187
  const result = await connectedClient.client.exec({
89
- url: "https://example.com/notfound",
188
+ url: localUrl("/notfound"),
90
189
  method: "GET",
91
190
  timeout: 5000,
92
191
  });
93
192
 
94
193
  expect(result.statusCode).toBe(404);
194
+ expect(result.body).toBe("missing");
95
195
 
96
196
  connectedClient.close();
97
197
  });
98
198
 
99
199
  it("should send custom headers with request", async () => {
100
- let capturedHeaders: Record<string, string> | undefined;
101
- spyOn(globalThis, "fetch").mockImplementation((async (
102
- _url: RequestInfo | URL,
103
- options?: RequestInit,
104
- ) => {
105
- capturedHeaders = options?.headers as Record<string, string>;
106
- return new Response(null, { status: 200 });
107
- }) as unknown as typeof fetch);
108
-
109
- const connectedClient = await strategy.createClient({ timeout: 5000 });
110
- await connectedClient.client.exec({
111
- url: "https://example.com/api",
200
+ const connectedClient = await localStrategy.createClient({
201
+ timeout: 5000,
202
+ });
203
+ const result = await connectedClient.client.exec({
204
+ url: localUrl("/echo"),
112
205
  method: "GET",
113
206
  headers: {
114
207
  Authorization: "Bearer my-token",
@@ -117,45 +210,37 @@ describe("HttpHealthCheckStrategy", () => {
117
210
  timeout: 5000,
118
211
  });
119
212
 
120
- expect(capturedHeaders).toBeDefined();
121
- expect(capturedHeaders?.["Authorization"]).toBe("Bearer my-token");
122
- expect(capturedHeaders?.["X-Custom-Header"]).toBe("custom-value");
213
+ const parsed = JSON.parse(result.body) as {
214
+ auth: string | null;
215
+ custom: string | null;
216
+ };
217
+ expect(parsed.auth).toBe("Bearer my-token");
218
+ expect(parsed.custom).toBe("custom-value");
123
219
 
124
220
  connectedClient.close();
125
221
  });
126
222
 
127
223
  it("should return JSON body as string", async () => {
128
- const responseBody = { foo: "bar", count: 42 };
129
- spyOn(globalThis, "fetch").mockResolvedValue(
130
- new Response(JSON.stringify(responseBody), {
131
- status: 200,
132
- headers: { "Content-Type": "application/json" },
133
- }),
134
- );
135
-
136
- const connectedClient = await strategy.createClient({ timeout: 5000 });
224
+ const connectedClient = await localStrategy.createClient({
225
+ timeout: 5000,
226
+ });
137
227
  const result = await connectedClient.client.exec({
138
- url: "https://example.com/api",
228
+ url: localUrl("/api"),
139
229
  method: "GET",
140
230
  timeout: 5000,
141
231
  });
142
232
 
143
- expect(result.body).toBe(JSON.stringify(responseBody));
233
+ expect(result.body).toBe(JSON.stringify({ status: "ok" }));
144
234
 
145
235
  connectedClient.close();
146
236
  });
147
237
 
148
238
  it("should handle text body", async () => {
149
- spyOn(globalThis, "fetch").mockResolvedValue(
150
- new Response("Hello World", {
151
- status: 200,
152
- headers: { "Content-Type": "text/plain" },
153
- }),
154
- );
155
-
156
- const connectedClient = await strategy.createClient({ timeout: 5000 });
239
+ const connectedClient = await localStrategy.createClient({
240
+ timeout: 5000,
241
+ });
157
242
  const result = await connectedClient.client.exec({
158
- url: "https://example.com/api",
243
+ url: localUrl("/text"),
159
244
  method: "GET",
160
245
  timeout: 5000,
161
246
  });
@@ -166,47 +251,123 @@ describe("HttpHealthCheckStrategy", () => {
166
251
  });
167
252
 
168
253
  it("should send POST body", async () => {
169
- let capturedBody: string | undefined;
170
- spyOn(globalThis, "fetch").mockImplementation((async (
171
- _url: RequestInfo | URL,
172
- options?: RequestInit,
173
- ) => {
174
- capturedBody = options?.body as string;
175
- return new Response(null, { status: 201 });
176
- }) as unknown as typeof fetch);
177
-
178
- const connectedClient = await strategy.createClient({ timeout: 5000 });
179
- await connectedClient.client.exec({
180
- url: "https://example.com/api",
254
+ const connectedClient = await localStrategy.createClient({
255
+ timeout: 5000,
256
+ });
257
+ const result = await connectedClient.client.exec({
258
+ url: localUrl("/echo"),
181
259
  method: "POST",
182
260
  body: JSON.stringify({ name: "test" }),
183
261
  timeout: 5000,
184
262
  });
185
263
 
186
- expect(capturedBody).toBe('{"name":"test"}');
264
+ const parsed = JSON.parse(result.body) as { body: string };
265
+ expect(parsed.body).toBe('{"name":"test"}');
187
266
 
188
267
  connectedClient.close();
189
268
  });
190
269
 
191
270
  it("should use correct HTTP method", async () => {
192
- let capturedMethod: string | undefined;
193
- spyOn(globalThis, "fetch").mockImplementation((async (
194
- _url: RequestInfo | URL,
195
- options?: RequestInit,
196
- ) => {
197
- capturedMethod = options?.method;
198
- return new Response(null, { status: 200 });
199
- }) as unknown as typeof fetch);
271
+ const connectedClient = await localStrategy.createClient({
272
+ timeout: 5000,
273
+ });
274
+ const result = await connectedClient.client.exec({
275
+ url: localUrl("/echo"),
276
+ method: "DELETE",
277
+ timeout: 5000,
278
+ });
279
+
280
+ const parsed = JSON.parse(result.body) as { method: string };
281
+ expect(parsed.method).toBe("DELETE");
282
+
283
+ connectedClient.close();
284
+ });
200
285
 
286
+ it("sends the request's Host header to the server", async () => {
287
+ const connectedClient = await localStrategy.createClient({
288
+ timeout: 5000,
289
+ });
290
+ const result = await connectedClient.client.exec({
291
+ url: localUrl("/echo"),
292
+ method: "GET",
293
+ timeout: 5000,
294
+ });
295
+
296
+ const parsed = JSON.parse(result.body) as { host: string | null };
297
+ // The request is issued verbatim, so the server sees the URL's authority.
298
+ expect(parsed.host).toBe(`127.0.0.1:${serverPort}`);
299
+
300
+ connectedClient.close();
301
+ });
302
+ });
303
+
304
+ describe("SSRF guard (in-process egress)", () => {
305
+ it("refuses a request whose host resolves to the cloud-metadata IP", async () => {
306
+ const metadataStrategy = new HttpHealthCheckStrategy(async () => [
307
+ { address: "169.254.169.254", family: 4 },
308
+ ]);
309
+
310
+ const connectedClient = await metadataStrategy.createClient({
311
+ timeout: 5000,
312
+ });
313
+
314
+ // The guard rejects in resolveTarget, BEFORE any socket is opened.
315
+ await expect(
316
+ connectedClient.client.exec({
317
+ url: "http://metadata.internal/latest/meta-data/",
318
+ method: "GET",
319
+ timeout: 5000,
320
+ }),
321
+ ).rejects.toThrow(/denied egress range/);
322
+
323
+ connectedClient.close();
324
+ });
325
+
326
+ it("refuses a direct cloud-metadata IP literal", async () => {
201
327
  const connectedClient = await strategy.createClient({ timeout: 5000 });
202
- await connectedClient.client.exec({
203
- url: "https://example.com/api",
204
- method: "DELETE",
328
+
329
+ await expect(
330
+ connectedClient.client.exec({
331
+ url: "http://169.254.169.254/latest/meta-data/",
332
+ method: "GET",
333
+ timeout: 5000,
334
+ }),
335
+ ).rejects.toThrow(/denied egress range/);
336
+ connectedClient.close();
337
+ });
338
+
339
+ it("allows an internal RFC1918 host by default (monitoring stays allowed)", async () => {
340
+ // Resolve to loopback so the (allowed) request reaches the local server.
341
+ const internalStrategy = new HttpHealthCheckStrategy(loopbackLookup);
342
+ const connectedClient = await internalStrategy.createClient({
205
343
  timeout: 5000,
206
344
  });
207
345
 
208
- expect(capturedMethod).toBe("DELETE");
346
+ const result = await connectedClient.client.exec({
347
+ url: localUrl("/healthz"),
348
+ method: "GET",
349
+ timeout: 5000,
350
+ });
351
+ expect(result.statusCode).toBe(200);
352
+ connectedClient.close();
353
+ });
209
354
 
355
+ it("honors an operator-extended denylist for an internal range", async () => {
356
+ const internalStrategy = new HttpHealthCheckStrategy(async () => [
357
+ { address: "10.5.5.5", family: 4 },
358
+ ]);
359
+ const connectedClient = await internalStrategy.createClient({
360
+ timeout: 5000,
361
+ egressDenyCidrs: ["10.0.0.0/8"],
362
+ });
363
+
364
+ await expect(
365
+ connectedClient.client.exec({
366
+ url: "http://internal.service.local/healthz",
367
+ method: "GET",
368
+ timeout: 5000,
369
+ }),
370
+ ).rejects.toThrow(/denied egress range/);
210
371
  connectedClient.close();
211
372
  });
212
373
  });
package/src/strategy.ts CHANGED
@@ -8,13 +8,17 @@ import {
8
8
  z,
9
9
  type InferAggregatedResult,
10
10
  type ConnectedClient,
11
+ type TransportTimings,
11
12
  baseStrategyConfigSchema,
13
+ DEFAULT_EGRESS_DENY_CIDRS,
14
+ resolveAndValidateHost,
12
15
  } from "@checkstack/backend-api";
13
16
  import {
14
17
  healthResultString,
15
18
  healthResultSchema,
16
19
  StrategyCategory,
17
20
  } from "@checkstack/healthcheck-common";
21
+ import { probeConnectTiming } from "./connect-probe";
18
22
  import type {
19
23
  HttpTransportClient,
20
24
  HttpRequest,
@@ -28,8 +32,25 @@ import type {
28
32
  /**
29
33
  * HTTP health check configuration schema.
30
34
  * Global defaults only - action params moved to RequestCollector.
35
+ *
36
+ * `egressDenyCidrs` extends the SSRF guard's default denylist. The collector
37
+ * runs in-process on the trusted core, so it always refuses to connect to
38
+ * cloud-metadata + link-local ranges (the secure default); operators can add
39
+ * further CIDRs here (e.g. to block a sensitive internal range) WITHOUT losing
40
+ * the metadata/link-local block. Leaving it unset keeps the secure default.
41
+ * RFC1918 / internal probing stays allowed by default (a monitoring tool's job).
42
+ *
43
+ * Optional + additive: existing stored configs (which lack this field) remain
44
+ * valid, so no schema-version bump / migration is required.
31
45
  */
32
- export const httpHealthCheckConfigSchema = baseStrategyConfigSchema.extend({});
46
+ export const httpHealthCheckConfigSchema = baseStrategyConfigSchema.extend({
47
+ egressDenyCidrs: z
48
+ .array(z.string())
49
+ .optional()
50
+ .describe(
51
+ "Extra CIDR ranges to deny for outbound requests (added on top of the always-on cloud-metadata/link-local block).",
52
+ ),
53
+ });
33
54
 
34
55
  export type HttpHealthCheckConfig = z.infer<typeof httpHealthCheckConfigSchema>;
35
56
 
@@ -64,8 +85,12 @@ const httpAggregatedFields = {
64
85
  errorCount: aggregatedCounter({
65
86
  "x-chart-type": "counter",
66
87
  "x-chart-label": "Errors",
67
- "x-anomaly-enabled": true,
68
- "x-anomaly-direction": "lower-is-better",
88
+ // Off by default: a raw per-bucket error COUNT scales with how many runs
89
+ // land in the bucket, so it has no stable baseline and drifts with traffic
90
+ // volume. The percent form of the same signal (`successRate`) is the one
91
+ // that alerts; this absolute twin stays chart-only to avoid duplicate,
92
+ // noisy alerting.
93
+ "x-anomaly-enabled": false,
69
94
  }),
70
95
  };
71
96
 
@@ -86,6 +111,17 @@ export class HttpHealthCheckStrategy implements HealthCheckStrategy<
86
111
  description = "HTTP endpoint health monitoring";
87
112
  category = StrategyCategory.NETWORKING;
88
113
 
114
+ /**
115
+ * DNS resolver used by the SSRF guard. Defaults to the system resolver
116
+ * (`resolveAndValidateHost`'s built-in). Injectable so tests can drive the
117
+ * guard deterministically without real network DNS.
118
+ */
119
+ constructor(
120
+ private readonly lookupFn?: (
121
+ hostname: string,
122
+ ) => Promise<Array<{ address: string; family: number }>>,
123
+ ) {}
124
+
89
125
  config: Versioned<HttpHealthCheckConfig> = new Versioned({
90
126
  version: 3, // v3 for createClient pattern with action params moved to RequestCollector
91
127
  schema: httpHealthCheckConfigSchema,
@@ -174,33 +210,118 @@ export class HttpHealthCheckStrategy implements HealthCheckStrategy<
174
210
  ): Promise<ConnectedClient<HttpTransportClient>> {
175
211
  const validatedConfig = this.config.validate(config);
176
212
 
213
+ // SSRF secure default: deny cloud-metadata + link-local always, plus any
214
+ // operator-configured extra ranges. This collector runs IN-PROCESS on the
215
+ // trusted core, so without this guard a healthcheck author could point a
216
+ // check at http://169.254.169.254/... and read the response body.
217
+ const denyCidrs = [
218
+ ...DEFAULT_EGRESS_DENY_CIDRS,
219
+ ...(validatedConfig.egressDenyCidrs ?? []),
220
+ ];
221
+ const lookupFn = this.lookupFn;
222
+
223
+ // Mutable per-run timings holder. `exec` writes the request's transport
224
+ // phases here so the executor can lift them into `metadata.timings`.
225
+ const timings: TransportTimings = {};
226
+
177
227
  const client: HttpTransportClient = {
178
228
  async exec(request: HttpRequest): Promise<HttpResponse> {
179
229
  const controller = new AbortController();
180
- const timeoutId = setTimeout(
181
- () => controller.abort(),
182
- request.timeout ?? validatedConfig.timeout,
183
- );
230
+ const timeoutMs = request.timeout ?? validatedConfig.timeout;
231
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
184
232
 
185
233
  try {
234
+ // SSRF guard: resolve the host and reject any denied range (cloud
235
+ // metadata / link-local, plus operator extras) BEFORE issuing the
236
+ // request. We then `fetch` the ORIGINAL url verbatim - we do NOT pin
237
+ // the connection to the resolved IP. Pinning (rewriting the URL host
238
+ // to the IP + overriding the Host header + TLS SNI) breaks HTTP/2
239
+ // origins, whose authority comes from the URL's `:authority`
240
+ // pseudo-header, not the `Host` header - which is exactly what made
241
+ // real hosts like google.com answer 404/429 instead of 200. Issuing
242
+ // the original request keeps it byte-identical to a plain fetch.
243
+ //
244
+ // Trade-off: because `fetch` re-resolves DNS itself, this pre-flight
245
+ // validation has a DNS-rebind TOCTOU window (a host could resolve to
246
+ // an allowed IP here and a denied one at connect time). It still
247
+ // blocks the common cases - a host that statically resolves to a
248
+ // denied range, and direct denied IP literals. The resolved IP is
249
+ // reused only for the best-effort timing probe below.
250
+ const requestUrl = new URL(request.url);
251
+ const dnsStart = performance.now();
252
+ const target = await resolveAndValidateHost({
253
+ host: requestUrl.hostname,
254
+ denyCidrs,
255
+ ...(lookupFn === undefined ? {} : { lookupFn }),
256
+ });
257
+ const dnsMs = Math.max(0, performance.now() - dnsStart);
258
+ const isHttps = requestUrl.protocol === "https:";
259
+
260
+ // Connect/TLS timing comes from a short-lived raw probe to the SAME
261
+ // validated IP: Bun's `fetch` socket exposes no connect/handshake
262
+ // events, but raw net/tls sockets do. Best-effort, runs alongside the
263
+ // request, and never fails the check.
264
+ const probePromise = probeConnectTiming({
265
+ ip: target.ip,
266
+ port:
267
+ requestUrl.port === ""
268
+ ? isHttps
269
+ ? 443
270
+ : 80
271
+ : Number(requestUrl.port),
272
+ tls: isHttps,
273
+ servername: requestUrl.hostname,
274
+ timeoutMs,
275
+ });
276
+
277
+ // `fetch` resolves at the response HEADERS (time-to-first-byte);
278
+ // consuming the body is the transfer phase.
279
+ const fetchStart = performance.now();
186
280
  const response = await fetch(request.url, {
187
281
  method: request.method,
188
282
  headers: request.headers,
189
283
  body: request.body,
190
284
  signal: controller.signal,
191
285
  });
286
+ const ttfbMs = Math.max(0, performance.now() - fetchStart);
192
287
 
193
- // Read body BEFORE clearing timeout - body streaming can also hang
288
+ // Read body BEFORE clearing the timeout - body streaming can also hang.
289
+ const transferStart = performance.now();
194
290
  const body = await response.text();
291
+ const transferMs = Math.max(0, performance.now() - transferStart);
195
292
 
196
293
  clearTimeout(timeoutId);
197
- const headers: Record<string, string> = {};
198
294
 
199
- // eslint-disable-next-line unicorn/no-array-for-each
200
- response.headers.forEach((value, key) => {
295
+ const probe = await probePromise;
296
+ // Server wait = time-to-first-byte minus the connection setup we could
297
+ // measure. Clamped: the probe is a parallel connection, so on a warm
298
+ // path its setup can exceed the request's ttfb.
299
+ const waitMs = Math.max(
300
+ 0,
301
+ ttfbMs - ((probe.connectMs ?? 0) + (probe.tlsMs ?? 0)),
302
+ );
303
+
304
+ // Last-request-wins: reset then write this request's phases.
305
+ delete timings.dnsMs;
306
+ delete timings.connectMs;
307
+ delete timings.tlsMs;
308
+ delete timings.waitMs;
309
+ delete timings.transferMs;
310
+ delete timings.processingMs;
311
+ timings.dnsMs = dnsMs;
312
+ if (probe.connectMs !== undefined) timings.connectMs = probe.connectMs;
313
+ if (probe.tlsMs !== undefined) timings.tlsMs = probe.tlsMs;
314
+ timings.waitMs = waitMs;
315
+ timings.transferMs = transferMs;
316
+
317
+ const headers: Record<string, string> = {};
318
+ for (const [key, value] of response.headers) {
201
319
  headers[key.toLowerCase()] = value;
202
- });
320
+ }
203
321
 
322
+ // A received response - INCLUDING a 4xx/5xx - returns normally and is
323
+ // an assertable metric (never an error). Only a genuine transport
324
+ // failure (DNS, connect, TLS, timeout, abort) throws.
204
325
  return {
205
326
  statusCode: response.status,
206
327
  statusText: response.statusText,
@@ -217,6 +338,7 @@ export class HttpHealthCheckStrategy implements HealthCheckStrategy<
217
338
 
218
339
  return {
219
340
  client,
341
+ timings,
220
342
  close: () => {
221
343
  // HTTP is stateless, nothing to close
222
344
  },
@@ -1,4 +1,5 @@
1
1
  import type { TransportClient } from "@checkstack/common";
2
+ import type { TransportTimings } from "@checkstack/backend-api";
2
3
 
3
4
  /**
4
5
  * HTTP request configuration.
@@ -20,6 +21,12 @@ export interface HttpResponse {
20
21
  headers: Record<string, string>;
21
22
  body: string;
22
23
  contentType?: string;
24
+ /**
25
+ * Structured transport timing breakdown for this request (DNS / connect /
26
+ * TLS / wait / transfer), populated by the instrumented node-http client.
27
+ * Absent if the request never produced socket events.
28
+ */
29
+ timings?: TransportTimings;
23
30
  }
24
31
 
25
32
  /**