@checkstack/healthcheck-http-backend 0.5.4 → 0.6.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 +20 -0
- package/package.json +2 -2
- package/src/strategy.test.ts +124 -5
- package/src/strategy.ts +130 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @checkstack/healthcheck-http-backend
|
|
2
2
|
|
|
3
|
+
## 0.6.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [e819276]
|
|
8
|
+
- @checkstack/backend-api@0.28.0
|
|
9
|
+
|
|
10
|
+
## 0.6.0
|
|
11
|
+
|
|
12
|
+
### Minor Changes
|
|
13
|
+
|
|
14
|
+
- b4e0832: Add optional authentication to the HTTP health check strategy. A new
|
|
15
|
+
**Authentication** picker (`none` / `basic` / `token`) on the strategy config
|
|
16
|
+
sets the outbound `Authorization` header: `basic` sends
|
|
17
|
+
`Basic <base64(username:password)>`, `token` sends `Bearer <token>`. Passwords
|
|
18
|
+
and tokens are secret fields (encrypted at rest, redacted in the UI). A
|
|
19
|
+
collector that sets its own `Authorization` header still takes precedence.
|
|
20
|
+
Existing configs are unaffected (`authType` defaults to `none`; no
|
|
21
|
+
schema-version bump required).
|
|
22
|
+
|
|
3
23
|
## 0.5.4
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/healthcheck-http-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"checkstack": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"pack": "bunx @checkstack/scripts plugin-pack"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@checkstack/backend-api": "0.
|
|
17
|
+
"@checkstack/backend-api": "0.28.0",
|
|
18
18
|
"@checkstack/healthcheck-common": "1.11.0",
|
|
19
19
|
"jsonpath-plus": "^10.3.0",
|
|
20
20
|
"@checkstack/common": "0.19.0"
|
package/src/strategy.test.ts
CHANGED
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
} from "bun:test";
|
|
8
8
|
import * as http from "node:http";
|
|
9
9
|
import { AddressInfo } from "node:net";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
buildAuthorizationHeader,
|
|
12
|
+
HttpHealthCheckStrategy,
|
|
13
|
+
httpHealthCheckConfigSchema,
|
|
14
|
+
} from "./strategy";
|
|
11
15
|
|
|
12
16
|
describe("HttpHealthCheckStrategy", () => {
|
|
13
17
|
// Inject a deterministic DNS resolver so the in-process SSRF guard does not
|
|
@@ -88,8 +92,9 @@ describe("HttpHealthCheckStrategy", () => {
|
|
|
88
92
|
headers: [{ name: "Accept", value: "application/json" }],
|
|
89
93
|
body: "payload",
|
|
90
94
|
});
|
|
91
|
-
// v1->v2 fabricates the default timeout, v2->v3 strips the moved fields
|
|
92
|
-
|
|
95
|
+
// v1->v2 fabricates the default timeout, v2->v3 strips the moved fields;
|
|
96
|
+
// the final validation fills the defaulted authType.
|
|
97
|
+
expect(migrated).toEqual({ timeout: 30_000, authType: "none" });
|
|
93
98
|
});
|
|
94
99
|
|
|
95
100
|
it("carries a v1 timeout through both migration steps", async () => {
|
|
@@ -98,12 +103,12 @@ describe("HttpHealthCheckStrategy", () => {
|
|
|
98
103
|
method: "POST",
|
|
99
104
|
timeout: 12_345,
|
|
100
105
|
});
|
|
101
|
-
expect(migrated).toEqual({ timeout: 12_345 });
|
|
106
|
+
expect(migrated).toEqual({ timeout: 12_345, authType: "none" });
|
|
102
107
|
});
|
|
103
108
|
|
|
104
109
|
it("is idempotent: an already-current {timeout} blob is unchanged", async () => {
|
|
105
110
|
const migrated = await strategy.config.parseAssumingV1({ timeout: 5000 });
|
|
106
|
-
expect(migrated).toEqual({ timeout: 5000 });
|
|
111
|
+
expect(migrated).toEqual({ timeout: 5000, authType: "none" });
|
|
107
112
|
});
|
|
108
113
|
|
|
109
114
|
it("has a complete v1->version migration chain", () => {
|
|
@@ -301,6 +306,120 @@ describe("HttpHealthCheckStrategy", () => {
|
|
|
301
306
|
});
|
|
302
307
|
});
|
|
303
308
|
|
|
309
|
+
describe("authentication", () => {
|
|
310
|
+
it("sends no Authorization header by default (authType none)", async () => {
|
|
311
|
+
const connectedClient = await localStrategy.createClient({
|
|
312
|
+
timeout: 5000,
|
|
313
|
+
});
|
|
314
|
+
const result = await connectedClient.client.exec({
|
|
315
|
+
url: localUrl("/echo"),
|
|
316
|
+
method: "GET",
|
|
317
|
+
timeout: 5000,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const parsed = JSON.parse(result.body) as { auth: string | null };
|
|
321
|
+
expect(parsed.auth).toBeNull();
|
|
322
|
+
|
|
323
|
+
connectedClient.close();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("sends Authorization: Basic with base64(username:password)", async () => {
|
|
327
|
+
const connectedClient = await localStrategy.createClient({
|
|
328
|
+
timeout: 5000,
|
|
329
|
+
authType: "basic",
|
|
330
|
+
authUsername: "alice",
|
|
331
|
+
authPassword: "s3cret",
|
|
332
|
+
});
|
|
333
|
+
const result = await connectedClient.client.exec({
|
|
334
|
+
url: localUrl("/echo"),
|
|
335
|
+
method: "GET",
|
|
336
|
+
timeout: 5000,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const parsed = JSON.parse(result.body) as { auth: string | null };
|
|
340
|
+
const expected = Buffer.from("alice:s3cret", "utf8").toString("base64");
|
|
341
|
+
expect(parsed.auth).toBe(`Basic ${expected}`);
|
|
342
|
+
|
|
343
|
+
connectedClient.close();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("sends Authorization: Bearer with the configured token", async () => {
|
|
347
|
+
const connectedClient = await localStrategy.createClient({
|
|
348
|
+
timeout: 5000,
|
|
349
|
+
authType: "token",
|
|
350
|
+
authToken: "my-api-token",
|
|
351
|
+
});
|
|
352
|
+
const result = await connectedClient.client.exec({
|
|
353
|
+
url: localUrl("/echo"),
|
|
354
|
+
method: "GET",
|
|
355
|
+
timeout: 5000,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const parsed = JSON.parse(result.body) as { auth: string | null };
|
|
359
|
+
expect(parsed.auth).toBe("Bearer my-api-token");
|
|
360
|
+
|
|
361
|
+
connectedClient.close();
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("lets an explicit request Authorization header win over the config", async () => {
|
|
365
|
+
const connectedClient = await localStrategy.createClient({
|
|
366
|
+
timeout: 5000,
|
|
367
|
+
authType: "token",
|
|
368
|
+
authToken: "config-token",
|
|
369
|
+
});
|
|
370
|
+
const result = await connectedClient.client.exec({
|
|
371
|
+
url: localUrl("/echo"),
|
|
372
|
+
method: "GET",
|
|
373
|
+
// Lower-case on purpose: precedence must be case-insensitive.
|
|
374
|
+
headers: { authorization: "Bearer request-token" },
|
|
375
|
+
timeout: 5000,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const parsed = JSON.parse(result.body) as { auth: string | null };
|
|
379
|
+
expect(parsed.auth).toBe("Bearer request-token");
|
|
380
|
+
|
|
381
|
+
connectedClient.close();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("builds no header for authType none", () => {
|
|
385
|
+
expect(
|
|
386
|
+
buildAuthorizationHeader({ config: { authType: "none" } }),
|
|
387
|
+
).toBeUndefined();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("rejects basic auth without username or password", () => {
|
|
391
|
+
const missingPassword = httpHealthCheckConfigSchema.safeParse({
|
|
392
|
+
timeout: 5000,
|
|
393
|
+
authType: "basic",
|
|
394
|
+
authUsername: "alice",
|
|
395
|
+
});
|
|
396
|
+
expect(missingPassword.success).toBe(false);
|
|
397
|
+
|
|
398
|
+
const missingUsername = httpHealthCheckConfigSchema.safeParse({
|
|
399
|
+
timeout: 5000,
|
|
400
|
+
authType: "basic",
|
|
401
|
+
authPassword: "s3cret",
|
|
402
|
+
});
|
|
403
|
+
expect(missingUsername.success).toBe(false);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("rejects token auth without a token", () => {
|
|
407
|
+
const result = httpHealthCheckConfigSchema.safeParse({
|
|
408
|
+
timeout: 5000,
|
|
409
|
+
authType: "token",
|
|
410
|
+
});
|
|
411
|
+
expect(result.success).toBe(false);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("accepts a config without auth fields (existing stored configs)", () => {
|
|
415
|
+
const result = httpHealthCheckConfigSchema.safeParse({ timeout: 5000 });
|
|
416
|
+
expect(result.success).toBe(true);
|
|
417
|
+
if (result.success) {
|
|
418
|
+
expect(result.data.authType).toBe("none");
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
304
423
|
describe("SSRF guard (in-process egress)", () => {
|
|
305
424
|
it("refuses a request whose host resolves to the cloud-metadata IP", async () => {
|
|
306
425
|
const metadataStrategy = new HttpHealthCheckStrategy(async () => [
|
package/src/strategy.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
aggregatedCounter,
|
|
7
7
|
mergeCounter,
|
|
8
8
|
z,
|
|
9
|
+
configString,
|
|
9
10
|
type InferAggregatedResult,
|
|
10
11
|
type ConnectedClient,
|
|
11
12
|
type TransportTimings,
|
|
@@ -29,6 +30,14 @@ import type {
|
|
|
29
30
|
// SCHEMAS
|
|
30
31
|
// ============================================================================
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Supported authentication schemes for outbound HTTP health-check requests.
|
|
35
|
+
* - none: no Authorization header is set (default)
|
|
36
|
+
* - basic: `Authorization: Basic base64(<username>:<password>)`
|
|
37
|
+
* - token: `Authorization: Bearer <token>`
|
|
38
|
+
*/
|
|
39
|
+
export const HTTP_AUTH_TYPES = ["none", "basic", "token"] as const;
|
|
40
|
+
|
|
32
41
|
/**
|
|
33
42
|
* HTTP health check configuration schema.
|
|
34
43
|
* Global defaults only - action params moved to RequestCollector.
|
|
@@ -40,19 +49,124 @@ import type {
|
|
|
40
49
|
* the metadata/link-local block. Leaving it unset keeps the secure default.
|
|
41
50
|
* RFC1918 / internal probing stays allowed by default (a monitoring tool's job).
|
|
42
51
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
52
|
+
* The auth fields hide behind the `authType` picker via `x-hidden-when`; the
|
|
53
|
+
* `""` entry covers configs stored before the field existed (no value in the
|
|
54
|
+
* form until the default is applied). Their conditional requiredness lives in
|
|
55
|
+
* the superRefine below, mirroring the Jira connection config pattern.
|
|
56
|
+
*
|
|
57
|
+
* Optional + additive: existing stored configs (which lack these fields)
|
|
58
|
+
* remain valid, so no schema-version bump / migration is required.
|
|
45
59
|
*/
|
|
46
|
-
export const httpHealthCheckConfigSchema = baseStrategyConfigSchema
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
60
|
+
export const httpHealthCheckConfigSchema = baseStrategyConfigSchema
|
|
61
|
+
.extend({
|
|
62
|
+
egressDenyCidrs: z
|
|
63
|
+
.array(z.string())
|
|
64
|
+
.optional()
|
|
65
|
+
.describe(
|
|
66
|
+
"Extra CIDR ranges to deny for outbound requests (added on top of the always-on cloud-metadata/link-local block).",
|
|
67
|
+
),
|
|
68
|
+
authType: z
|
|
69
|
+
.enum(HTTP_AUTH_TYPES)
|
|
70
|
+
.default("none")
|
|
71
|
+
.describe("Authentication for outbound requests"),
|
|
72
|
+
authUsername: configString({
|
|
73
|
+
"x-hidden-when": { authType: ["none", "token", ""] },
|
|
74
|
+
})
|
|
75
|
+
.optional()
|
|
76
|
+
.describe("Username for Basic authentication"),
|
|
77
|
+
authPassword: configString({
|
|
78
|
+
"x-secret": true,
|
|
79
|
+
"x-hidden-when": { authType: ["none", "token", ""] },
|
|
80
|
+
})
|
|
81
|
+
.optional()
|
|
82
|
+
.describe("Password for Basic authentication"),
|
|
83
|
+
authToken: configString({
|
|
84
|
+
"x-secret": true,
|
|
85
|
+
"x-hidden-when": { authType: ["none", "basic", ""] },
|
|
86
|
+
})
|
|
87
|
+
.optional()
|
|
88
|
+
.describe("Bearer token for Token authentication"),
|
|
89
|
+
})
|
|
90
|
+
.superRefine((data, ctx) => {
|
|
91
|
+
if (data.authType === "basic") {
|
|
92
|
+
if (!data.authUsername) {
|
|
93
|
+
ctx.addIssue({
|
|
94
|
+
code: z.ZodIssueCode.custom,
|
|
95
|
+
message: "Username is required for Basic authentication",
|
|
96
|
+
path: ["authUsername"],
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (!data.authPassword) {
|
|
100
|
+
ctx.addIssue({
|
|
101
|
+
code: z.ZodIssueCode.custom,
|
|
102
|
+
message: "Password is required for Basic authentication",
|
|
103
|
+
path: ["authPassword"],
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (data.authType === "token" && !data.authToken) {
|
|
108
|
+
ctx.addIssue({
|
|
109
|
+
code: z.ZodIssueCode.custom,
|
|
110
|
+
message: "Token is required for Token authentication",
|
|
111
|
+
path: ["authToken"],
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
});
|
|
54
115
|
|
|
55
116
|
export type HttpHealthCheckConfig = z.infer<typeof httpHealthCheckConfigSchema>;
|
|
117
|
+
export type HttpHealthCheckConfigInput = z.input<
|
|
118
|
+
typeof httpHealthCheckConfigSchema
|
|
119
|
+
>;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Build the `Authorization` header value for the configured auth scheme, or
|
|
123
|
+
* `undefined` when the check runs unauthenticated. Basic credentials are
|
|
124
|
+
* base64-encoded as `<username>:<password>` per RFC 7617.
|
|
125
|
+
*/
|
|
126
|
+
export function buildAuthorizationHeader({
|
|
127
|
+
config,
|
|
128
|
+
}: {
|
|
129
|
+
config: Pick<
|
|
130
|
+
HttpHealthCheckConfig,
|
|
131
|
+
"authType" | "authUsername" | "authPassword" | "authToken"
|
|
132
|
+
>;
|
|
133
|
+
}): string | undefined {
|
|
134
|
+
switch (config.authType) {
|
|
135
|
+
case "basic": {
|
|
136
|
+
const credentials = Buffer.from(
|
|
137
|
+
`${config.authUsername ?? ""}:${config.authPassword ?? ""}`,
|
|
138
|
+
"utf8",
|
|
139
|
+
).toString("base64");
|
|
140
|
+
return `Basic ${credentials}`;
|
|
141
|
+
}
|
|
142
|
+
case "token": {
|
|
143
|
+
return `Bearer ${config.authToken ?? ""}`;
|
|
144
|
+
}
|
|
145
|
+
default: {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Merge the strategy-level auth header into a request's headers. A collector
|
|
153
|
+
* that explicitly sets its own `Authorization` header (any casing) wins over
|
|
154
|
+
* the strategy config - the more specific setting takes precedence.
|
|
155
|
+
*/
|
|
156
|
+
function mergeAuthHeader({
|
|
157
|
+
headers,
|
|
158
|
+
authHeader,
|
|
159
|
+
}: {
|
|
160
|
+
headers: Record<string, string> | undefined;
|
|
161
|
+
authHeader: string | undefined;
|
|
162
|
+
}): Record<string, string> | undefined {
|
|
163
|
+
if (!authHeader) return headers;
|
|
164
|
+
const hasExplicitAuth = Object.keys(headers ?? {}).some(
|
|
165
|
+
(name) => name.toLowerCase() === "authorization",
|
|
166
|
+
);
|
|
167
|
+
if (hasExplicitAuth) return headers;
|
|
168
|
+
return { ...headers, Authorization: authHeader };
|
|
169
|
+
}
|
|
56
170
|
|
|
57
171
|
// The migrate input is `unknown` per the versioning chain, so narrowing is
|
|
58
172
|
// done with `typeof`/`in` guards (no casts).
|
|
@@ -206,10 +320,14 @@ export class HttpHealthCheckStrategy implements HealthCheckStrategy<
|
|
|
206
320
|
* All request parameters come from the collector (RequestCollector).
|
|
207
321
|
*/
|
|
208
322
|
async createClient(
|
|
209
|
-
config:
|
|
323
|
+
config: HttpHealthCheckConfigInput,
|
|
210
324
|
): Promise<ConnectedClient<HttpTransportClient>> {
|
|
211
325
|
const validatedConfig = this.config.validate(config);
|
|
212
326
|
|
|
327
|
+
// Strategy-level auth: computed once from the validated config; merged
|
|
328
|
+
// into every request unless the collector sets its own Authorization.
|
|
329
|
+
const authHeader = buildAuthorizationHeader({ config: validatedConfig });
|
|
330
|
+
|
|
213
331
|
// SSRF secure default: deny cloud-metadata + link-local always, plus any
|
|
214
332
|
// operator-configured extra ranges. This collector runs IN-PROCESS on the
|
|
215
333
|
// trusted core, so without this guard a healthcheck author could point a
|
|
@@ -279,7 +397,7 @@ export class HttpHealthCheckStrategy implements HealthCheckStrategy<
|
|
|
279
397
|
const fetchStart = performance.now();
|
|
280
398
|
const response = await fetch(request.url, {
|
|
281
399
|
method: request.method,
|
|
282
|
-
headers: request.headers,
|
|
400
|
+
headers: mergeAuthHeader({ headers: request.headers, authHeader }),
|
|
283
401
|
body: request.body,
|
|
284
402
|
signal: controller.signal,
|
|
285
403
|
});
|