@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 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.1.67",
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.0",
19
- "@checkstack/notification-backend": "1.6.7",
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 { backstageConfigSchemaV1, userConfigSchemaV1, mapImportanceToSeverity };
322
+ export {
323
+ backstageConfigSchemaV1,
324
+ userConfigSchemaV1,
325
+ mapImportanceToSeverity,
326
+ backstageStrategy,
327
+ };
310
328
  /** @internal */
311
329
  export type { BackstageConfig, BackstageUserConfig };