mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(notifications): keep the egress mark from clobbering a set action
The mark named its action unconditionally, so a job that had already named its own (the low-stock underflow branch) or a settings route whose test then failed at the relay lost that name on the wide event. Set the action only when the event has none, and always land the channel and origin meta pair, through a small hasAction() on the builder. Note the parse-time floor on the Nightscout page, which shares the grammar. Refs #947
This commit is contained in:
1 parent
9630b59973
commit
761a5a54c1
7 files changed
+93
-15
No files matched your search
@@ -28,9 +28,16 @@ Each entry is an exact normalized `scheme://host:port` trust unit:
|
||||
rejected;
|
||||
- approving one hostname does not approve subdomains, sibling hosts, or a
|
||||
different port; and
|
||||
- loopback (`127.0.0.0/8`, `::1`, `localhost`), the unspecified address,
|
||||
link-local and the cloud-metadata range cannot be listed, and a listed
|
||||
name that resolves there is refused at connect time — the grant opens
|
||||
your network, never the container or the host it runs on; and
|
||||
- any malformed non-empty entry fails evaluation instead of silently
|
||||
weakening the configured policy.
|
||||
|
||||
The grammar is shared with `NOTIFICATION_PRIVATE_ORIGINS`, the same grant
|
||||
for webhook and ntfy targets (`docs/self-hosting/notifications.md`).
|
||||
|
||||
Do not put a Nightscout API token, capability URL, health data, or a URL
|
||||
containing credentials in the allowlist. Restart the application after
|
||||
changing the environment.
|
||||
|
||||
@@ -254,14 +254,14 @@ lists it. Restart the app after changing it.
|
||||
|
||||
What is refused where, so you know which lever to pull:
|
||||
|
||||
| Target | Without the grant | With the exact origin listed |
|
||||
| ----------------------------------------------------------- | ----------------- | ---------------------------- |
|
||||
| Public name resolving to a public address | works | works, public pin |
|
||||
| Public name with a private A record (LAN reverse proxy) | refused at send | works |
|
||||
| `gotify.lan`, `gotify.home.arpa`, a Docker service name | refused at send | works |
|
||||
| `gotify.local` (mDNS) | refused at save | works |
|
||||
| Literal `192.168.x.x`, `10.x.x.x`, `100.64.x.x`, `fd00::/8` | refused at save | works |
|
||||
| `localhost`, `127.0.0.1`, `169.254.169.254`, `::1` | refused at save | still refused |
|
||||
| Target | Without the grant | With the exact origin listed |
|
||||
| ----------------------------------------------------------- | ----------------- | --------------------------------------- |
|
||||
| Public name resolving to a public address | works | works, public pin |
|
||||
| Public name with a private A record (LAN reverse proxy) | refused at send | works |
|
||||
| `gotify.lan`, `gotify.home.arpa`, a Docker service name | refused at send | works |
|
||||
| `gotify.local` (mDNS) | refused at save | allowed, if the container resolves mDNS |
|
||||
| Literal `192.168.x.x`, `10.x.x.x`, `100.64.x.x`, `fd00::/8` | refused at save | works |
|
||||
| `localhost`, `127.0.0.1`, `169.254.169.254`, `::1` | refused at save | still refused |
|
||||
|
||||
A private target that is not listed is refused when the card is saved
|
||||
(`422`, `meta.errorCode` = `private_origin_not_approved`, the message
|
||||
|
||||
@@ -150,6 +150,16 @@ export class WideEventBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a caller has already named this event's action. A leaf that
|
||||
* wants to mark the event (the private-origin egress mark in the
|
||||
* notification senders) checks this first so it never overwrites the
|
||||
* route's or job's own action name, which dashboards pin on.
|
||||
*/
|
||||
hasAction(): boolean {
|
||||
return this.event.action !== undefined;
|
||||
}
|
||||
|
||||
setBackground(bg: WideEvent["background"]): this {
|
||||
this.event.background = bg;
|
||||
return this;
|
||||
|
||||
@@ -12,7 +12,11 @@
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { eventStorage } from "@/lib/logging/context";
|
||||
import { WideEventBuilder } from "@/lib/logging/event-builder";
|
||||
|
||||
import {
|
||||
annotatePrivateOriginEgress,
|
||||
configuredNotificationPrivateOrigins,
|
||||
evaluateNotificationTarget,
|
||||
isAllowedNotificationTarget,
|
||||
@@ -166,3 +170,42 @@ describe("evaluateNotificationTarget", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("annotatePrivateOriginEgress", () => {
|
||||
it("names the action when the event has none and pins the meta pair", () => {
|
||||
const event = new WideEventBuilder("background");
|
||||
eventStorage.run(event, () => {
|
||||
annotatePrivateOriginEgress("webhook", "https://gotify.example.com");
|
||||
});
|
||||
|
||||
const json = event.toJSON();
|
||||
expect(json.action).toEqual({ name: "notification.egress.private_origin" });
|
||||
expect(json.meta).toEqual({
|
||||
channel: "webhook",
|
||||
origin: "https://gotify.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an action the route or job already set, and still lands the meta", () => {
|
||||
// A settings route or a job names its own action; dashboards pin on it.
|
||||
// The egress mark must not overwrite that name.
|
||||
const event = new WideEventBuilder("http");
|
||||
event.setAction({ name: "settings.webhook.test" });
|
||||
eventStorage.run(event, () => {
|
||||
annotatePrivateOriginEgress("ntfy", "http://ntfy.lan:8080");
|
||||
});
|
||||
|
||||
const json = event.toJSON();
|
||||
expect(json.action).toEqual({ name: "settings.webhook.test" });
|
||||
expect(json.meta).toEqual({
|
||||
channel: "ntfy",
|
||||
origin: "http://ntfy.lan:8080",
|
||||
});
|
||||
});
|
||||
|
||||
it("is a no-op without an active event", () => {
|
||||
expect(() =>
|
||||
annotatePrivateOriginEgress("webhook", "https://gotify.example.com"),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
PRIVATE_ORIGIN_NOT_APPROVED,
|
||||
type OriginVerdict,
|
||||
} from "@/lib/private-origin-policy";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* `NOTIFICATION_PRIVATE_ORIGINS` (#947) — the operator grant that lets the
|
||||
@@ -93,13 +93,19 @@ export { PRIVATE_ORIGIN_NOT_APPROVED };
|
||||
* an operator can see which grant is actually in use. The origin is the
|
||||
* canonical grant string, never the full target URL, which may carry a
|
||||
* token in its path or query.
|
||||
*
|
||||
* The action name is set only when the event has none yet: a settings route
|
||||
* or a job that already named its action keeps it, and the meta pair still
|
||||
* lands on the same event, so the mark is never lost and never clobbers.
|
||||
*/
|
||||
export function annotatePrivateOriginEgress(
|
||||
channel: "webhook" | "ntfy",
|
||||
origin: string,
|
||||
): void {
|
||||
annotate({
|
||||
action: { name: "notification.egress.private_origin" },
|
||||
meta: { channel, origin },
|
||||
});
|
||||
const event = getEvent();
|
||||
if (!event) return;
|
||||
if (!event.hasAction()) {
|
||||
annotate({ action: { name: "notification.egress.private_origin" } });
|
||||
}
|
||||
annotate({ meta: { channel, origin } });
|
||||
}
|
||||
@@ -25,7 +25,11 @@ vi.mock("@/lib/safe-fetch", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: () => ({ addWarning: vi.fn(), addExternalCall: vi.fn() }),
|
||||
getEvent: () => ({
|
||||
addWarning: vi.fn(),
|
||||
addExternalCall: vi.fn(),
|
||||
hasAction: () => false,
|
||||
}),
|
||||
annotate: (...args: unknown[]) => annotateMock(...args),
|
||||
}));
|
||||
|
||||
@@ -165,6 +169,8 @@ describe("sendViaNtfy", () => {
|
||||
expect(opts.operatorApprovedPrivateOrigin).toBe("http://ntfy.lan:8080");
|
||||
expect(annotateMock).toHaveBeenCalledWith({
|
||||
action: { name: "notification.egress.private_origin" },
|
||||
});
|
||||
expect(annotateMock).toHaveBeenCalledWith({
|
||||
meta: { channel: "ntfy", origin: "http://ntfy.lan:8080" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,11 @@ vi.mock("@/lib/safe-fetch", () => ({
|
||||
|
||||
const annotateMock = vi.fn();
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: () => ({ addWarning: vi.fn(), addExternalCall: vi.fn() }),
|
||||
getEvent: () => ({
|
||||
addWarning: vi.fn(),
|
||||
addExternalCall: vi.fn(),
|
||||
hasAction: () => false,
|
||||
}),
|
||||
annotate: (...args: unknown[]) => annotateMock(...args),
|
||||
}));
|
||||
|
||||
@@ -198,6 +202,8 @@ describe("sendViaWebhook", () => {
|
||||
);
|
||||
expect(annotateMock).toHaveBeenCalledWith({
|
||||
action: { name: "notification.egress.private_origin" },
|
||||
});
|
||||
expect(annotateMock).toHaveBeenCalledWith({
|
||||
meta: { channel: "webhook", origin: "https://gotify.example.com" },
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user