@checkstack/notification-backstage-backend 0.1.67 → 0.2.0
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 +26 -0
- package/package.json +3 -3
- package/src/index.test.ts +79 -1
- package/src/index.ts +19 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# @checkstack/notification-backstage-backend
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 43e4484: Harden the Discord, Slack, Gotify, and Backstage channels against SSRF. Each
|
|
8
|
+
POSTs to a configured arbitrary host (Discord/Slack incoming webhook, Gotify
|
|
9
|
+
server URL, Backstage base URL); these now run the shared `validateWebhookUrl`
|
|
10
|
+
pre-flight and send with `redirect: "error"` so a receiver cannot
|
|
11
|
+
`302`-redirect the request at a blocked host past the pre-flight. The pre-flight
|
|
12
|
+
blocks only the classic exfiltration / pivot targets (loopback, `0.0.0.0/8`,
|
|
13
|
+
cloud-metadata, link-local, IPv6 ULA) and ALLOWS internal RFC1918 hosts, so
|
|
14
|
+
self-hosted internal receivers keep working. Same defense-in-depth already
|
|
15
|
+
applied to the Webhook channel. (Pushover, Telegram, Teams, and Webex POST to
|
|
16
|
+
hard-coded vendor hosts and are unaffected.)
|
|
17
|
+
|
|
18
|
+
### Patch Changes
|
|
19
|
+
|
|
20
|
+
- Updated dependencies [43e4484]
|
|
21
|
+
- Updated dependencies [43e4484]
|
|
22
|
+
- Updated dependencies [43e4484]
|
|
23
|
+
- Updated dependencies [43e4484]
|
|
24
|
+
- Updated dependencies [43e4484]
|
|
25
|
+
- Updated dependencies [43e4484]
|
|
26
|
+
- @checkstack/backend-api@0.31.1
|
|
27
|
+
- @checkstack/notification-backend@1.7.0
|
|
28
|
+
|
|
3
29
|
## 0.1.67
|
|
4
30
|
|
|
5
31
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/notification-backstage-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"checkstack": {
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"pack": "bunx @checkstack/scripts plugin-pack"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@checkstack/backend-api": "0.31.
|
|
19
|
-
"@checkstack/notification-backend": "1.
|
|
18
|
+
"@checkstack/backend-api": "0.31.1",
|
|
19
|
+
"@checkstack/notification-backend": "1.7.0",
|
|
20
20
|
"@checkstack/common": "0.22.0",
|
|
21
21
|
"zod": "^4.2.1"
|
|
22
22
|
},
|
package/src/index.test.ts
CHANGED
|
@@ -1,10 +1,44 @@
|
|
|
1
|
-
import { describe, it, expect } from "bun:test";
|
|
1
|
+
import { describe, it, expect, spyOn, mock } from "bun:test";
|
|
2
|
+
import type { Logger } from "@checkstack/backend-api";
|
|
2
3
|
import {
|
|
3
4
|
backstageConfigSchemaV1,
|
|
4
5
|
userConfigSchemaV1,
|
|
5
6
|
mapImportanceToSeverity,
|
|
7
|
+
backstageStrategy,
|
|
6
8
|
} from "./index";
|
|
7
9
|
|
|
10
|
+
function makeLogger(): Logger {
|
|
11
|
+
return {
|
|
12
|
+
info: mock(() => {}),
|
|
13
|
+
error: mock(() => {}),
|
|
14
|
+
warn: mock(() => {}),
|
|
15
|
+
debug: mock(() => {}),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type BackstageSendContext = Parameters<typeof backstageStrategy.send>[0];
|
|
20
|
+
|
|
21
|
+
function makeContext(baseUrl: string): BackstageSendContext {
|
|
22
|
+
return {
|
|
23
|
+
user: { userId: "u1", email: "u1@example.com" },
|
|
24
|
+
contact: "user:default/u1",
|
|
25
|
+
notification: {
|
|
26
|
+
title: "Alert",
|
|
27
|
+
body: "body",
|
|
28
|
+
importance: "info",
|
|
29
|
+
type: "test",
|
|
30
|
+
},
|
|
31
|
+
strategyConfig: {
|
|
32
|
+
baseUrl,
|
|
33
|
+
token: "tok",
|
|
34
|
+
defaultEntityPrefix: "user:default/",
|
|
35
|
+
},
|
|
36
|
+
userConfig: { entityRef: "user:default/u1" },
|
|
37
|
+
layoutConfig: undefined,
|
|
38
|
+
logger: makeLogger(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
8
42
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
9
43
|
// Config Schema Tests
|
|
10
44
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
@@ -145,3 +179,47 @@ describe("backstageStrategy.send", () => {
|
|
|
145
179
|
expect(normalizedUrl).toBe("https://backstage.example.com");
|
|
146
180
|
});
|
|
147
181
|
});
|
|
182
|
+
|
|
183
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
184
|
+
// SSRF hardening (configured base URL)
|
|
185
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
186
|
+
|
|
187
|
+
describe("Backstage SSRF hardening", () => {
|
|
188
|
+
it("rejects a baseUrl that resolves to a blocked host before dispatch", async () => {
|
|
189
|
+
const fetchSpy = spyOn(globalThis, "fetch");
|
|
190
|
+
try {
|
|
191
|
+
const result = await backstageStrategy.send(
|
|
192
|
+
makeContext("http://169.254.169.254"),
|
|
193
|
+
);
|
|
194
|
+
expect(result.success).toBe(false);
|
|
195
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
196
|
+
} finally {
|
|
197
|
+
fetchSpy.mockRestore();
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("refuses redirects so a 302 to a blocked host is not followed", async () => {
|
|
202
|
+
let targetHit = false;
|
|
203
|
+
const fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (
|
|
204
|
+
_url: RequestInfo | URL,
|
|
205
|
+
init?: RequestInit,
|
|
206
|
+
) => {
|
|
207
|
+
if (init?.redirect === "error") {
|
|
208
|
+
throw new TypeError("unexpected redirect");
|
|
209
|
+
}
|
|
210
|
+
targetHit = true;
|
|
211
|
+
return new Response(null, { status: 200 });
|
|
212
|
+
}) as unknown as typeof fetch);
|
|
213
|
+
try {
|
|
214
|
+
const result = await backstageStrategy.send(
|
|
215
|
+
makeContext("https://93.184.216.34"),
|
|
216
|
+
);
|
|
217
|
+
expect(result.success).toBe(false);
|
|
218
|
+
expect(targetHit).toBe(false);
|
|
219
|
+
const init = fetchSpy.mock.calls[0]?.[1] as RequestInit | undefined;
|
|
220
|
+
expect(init?.redirect).toBe("error");
|
|
221
|
+
} finally {
|
|
222
|
+
fetchSpy.mockRestore();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import {
|
|
9
9
|
notificationStrategyExtensionPoint,
|
|
10
10
|
renderSubjectsAsMarkdown,
|
|
11
|
+
validateWebhookUrl,
|
|
11
12
|
} from "@checkstack/notification-backend";
|
|
12
13
|
import { z } from "zod";
|
|
13
14
|
import { pluginMetadata } from "./plugin-metadata";
|
|
@@ -225,6 +226,17 @@ const backstageStrategy: NotificationStrategy<
|
|
|
225
226
|
"",
|
|
226
227
|
)}/api/notifications/notifications`;
|
|
227
228
|
|
|
229
|
+
// SSRF guard: `baseUrl` is a configured arbitrary host. Reject it up front if
|
|
230
|
+
// it resolves to an internal/reserved address, and refuse redirects below so
|
|
231
|
+
// the server cannot 3xx us at an internal host past this pre-flight.
|
|
232
|
+
const validation = await validateWebhookUrl({ url });
|
|
233
|
+
if (!validation.ok) {
|
|
234
|
+
logger?.warn?.(
|
|
235
|
+
`Blocked Backstage delivery to ${url}: ${validation.error}`,
|
|
236
|
+
);
|
|
237
|
+
return { success: false, error: validation.error };
|
|
238
|
+
}
|
|
239
|
+
|
|
228
240
|
try {
|
|
229
241
|
logger?.debug?.("Sending notification to Backstage", {
|
|
230
242
|
url,
|
|
@@ -239,6 +251,7 @@ const backstageStrategy: NotificationStrategy<
|
|
|
239
251
|
Authorization: `Bearer ${strategyConfig.token}`,
|
|
240
252
|
},
|
|
241
253
|
body: JSON.stringify(payload),
|
|
254
|
+
redirect: "error",
|
|
242
255
|
});
|
|
243
256
|
|
|
244
257
|
if (!response.ok) {
|
|
@@ -306,6 +319,11 @@ export default createBackendPlugin({
|
|
|
306
319
|
* public API surface.
|
|
307
320
|
* @internal
|
|
308
321
|
*/
|
|
309
|
-
export {
|
|
322
|
+
export {
|
|
323
|
+
backstageConfigSchemaV1,
|
|
324
|
+
userConfigSchemaV1,
|
|
325
|
+
mapImportanceToSeverity,
|
|
326
|
+
backstageStrategy,
|
|
327
|
+
};
|
|
310
328
|
/** @internal */
|
|
311
329
|
export type { BackstageConfig, BackstageUserConfig };
|