mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(notifications): name the never-grantable refusal and redact the grant log
A loopback, link-local or metadata target answered private_origin_not_approved with a message telling the user to have the operator list it, and the parser refuses such an entry forever: a dead end the route test even pinned. Those targets now answer private_origin_not_grantable (catalogued, on all four routes, translated in seven locales) with a message that says no grant can open them and to use the relay's LAN address. Both senders carry the reason into the outcome so the test button forwards the same code. The malformed-entry warning printed the entry verbatim, which is where a pasted token or userinfo sits; the parser now hands callers scheme and host only, plus the reason in words. The Nightscout list throws a named error with that redacted entry and reason, and the connect route answers it as a 422 with the message instead of a bare 500, so a self-host that listed http://localhost:1337 reads what to change. The two settings cards translate a coded save refusal like the test button does; the docs and the changelog name the sub-host and sibling- port narrowing; the dispatcher floor tests assert the policy marker so a real NXDOMAIN cannot pass for a refusal. Refs #947
This commit is contained in:
1 parent
71175290af
commit
9c306f04e7
30 files changed
+436
-85
No files matched your search
+10
-6
@@ -12022,7 +12022,8 @@ paths:
|
||||
"422":
|
||||
description: Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and
|
||||
topic; or the server URL is on a private network the operator has not listed in
|
||||
`NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`). Nothing was stored.
|
||||
`NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`), or it is a loopback,
|
||||
link-local or metadata address that no grant can open (`private_origin_not_grantable`). Nothing was stored.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -12149,7 +12150,8 @@ paths:
|
||||
"422":
|
||||
description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on
|
||||
a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` =
|
||||
`private_origin_not_approved`). Nothing was stored.
|
||||
`private_origin_not_approved`), or it is a loopback, link-local or metadata address that no grant can open
|
||||
(`private_origin_not_grantable`). Nothing was stored.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -12386,7 +12388,8 @@ paths:
|
||||
"401": *a1
|
||||
"422":
|
||||
description: The saved server is on a private network the operator has not approved (`meta.errorCode` =
|
||||
`private_origin_not_approved`). The resolved address is never echoed.
|
||||
`private_origin_not_approved`), or is a loopback, link-local or metadata address that no grant can open
|
||||
(`private_origin_not_grantable`). The resolved address is never echoed.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -12436,7 +12439,8 @@ paths:
|
||||
"401": *a1
|
||||
"422":
|
||||
description: The saved URL is on a private network the operator has not approved (`meta.errorCode` =
|
||||
`private_origin_not_approved`). The resolved address is never echoed.
|
||||
`private_origin_not_approved`), or is a loopback, link-local or metadata address that no grant can open
|
||||
(`private_origin_not_grantable`). The resolved address is never echoed.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -30099,8 +30103,8 @@ components:
|
||||
dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict,
|
||||
invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict,
|
||||
missing_required_columns, modules_conflict, mood_tag_layout_conflict, not_configured,
|
||||
notification_prefs_conflict, oidc_only, private_origin_not_approved, rate_limited_self, too_many_rows,
|
||||
unreadable_json, upstream_invalid_json, vapid_not_configured"
|
||||
notification_prefs_conflict, oidc_only, private_origin_not_approved, private_origin_not_grantable,
|
||||
rate_limited_self, too_many_rows, unreadable_json, upstream_invalid_json, vapid_not_configured"
|
||||
type: string
|
||||
additionalProperties: {}
|
||||
required:
|
||||
|
||||
@@ -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 | 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 |
|
||||
| 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 (`private_origin_not_grantable`) |
|
||||
|
||||
A private target that is not listed is refused when the card is saved
|
||||
(`422`, `meta.errorCode` = `private_origin_not_approved`, the message
|
||||
|
||||
@@ -5991,6 +5991,7 @@
|
||||
"not_configured": "Nicht konfiguriert",
|
||||
"url_not_public": "URL ist kein öffentlicher Endpunkt",
|
||||
"private_origin_not_approved": "Ziel im privaten Netz. Der Betreiber muss diesen Origin in NOTIFICATION_PRIVATE_ORIGINS eintragen",
|
||||
"private_origin_not_grantable": "Diese Adresse ist Loopback, Link-local oder eine Metadaten-Adresse; keine Betreiber-Freigabe kann sie öffnen. Bitte die LAN-Adresse des Relays verwenden",
|
||||
"url_invalid": "URL ist ungültig",
|
||||
"redirected": "Endpunkt leitet weiter — URL prüfen",
|
||||
"endpoint_not_found": "Endpunkt unter der URL nicht gefunden",
|
||||
|
||||
@@ -5991,6 +5991,7 @@
|
||||
"not_configured": "Not configured",
|
||||
"url_not_public": "URL is not a public endpoint",
|
||||
"private_origin_not_approved": "Private network target. The operator has to list this origin in NOTIFICATION_PRIVATE_ORIGINS",
|
||||
"private_origin_not_grantable": "This address is loopback, link-local or a metadata address; no operator grant can open it. Use the relay's LAN address",
|
||||
"url_invalid": "URL is invalid",
|
||||
"redirected": "Endpoint redirects — check the URL",
|
||||
"endpoint_not_found": "Endpoint not found at the URL",
|
||||
|
||||
@@ -5991,6 +5991,7 @@
|
||||
"not_configured": "No configurado",
|
||||
"url_not_public": "La URL no es un endpoint público",
|
||||
"private_origin_not_approved": "Destino en una red privada. El operador debe añadir este origen a NOTIFICATION_PRIVATE_ORIGINS",
|
||||
"private_origin_not_grantable": "Esta dirección es loopback, de enlace local o de metadatos; ninguna autorización del operador puede abrirla. Usa la dirección LAN del relay",
|
||||
"url_invalid": "La URL no es válida",
|
||||
"redirected": "El endpoint redirige — comprueba la URL",
|
||||
"endpoint_not_found": "No se encontró el endpoint en la URL",
|
||||
|
||||
@@ -5991,6 +5991,7 @@
|
||||
"not_configured": "Non configuré",
|
||||
"url_not_public": "L’URL n’est pas un point de terminaison public",
|
||||
"private_origin_not_approved": "Cible sur un réseau privé. L’opérateur doit ajouter cette origine à NOTIFICATION_PRIVATE_ORIGINS",
|
||||
"private_origin_not_grantable": "Cette adresse est une adresse de bouclage, de lien local ou de métadonnées ; aucune autorisation de l’opérateur ne peut l’ouvrir. Utilisez l’adresse LAN du relais",
|
||||
"url_invalid": "L’URL n’est pas valide",
|
||||
"redirected": "Le point de terminaison redirige — vérifiez l’URL",
|
||||
"endpoint_not_found": "Point de terminaison introuvable à l’URL",
|
||||
|
||||
@@ -5991,6 +5991,7 @@
|
||||
"not_configured": "Non configurato",
|
||||
"url_not_public": "L’URL non è un endpoint pubblico",
|
||||
"private_origin_not_approved": "Destinazione su una rete privata. L’operatore deve aggiungere questa origine a NOTIFICATION_PRIVATE_ORIGINS",
|
||||
"private_origin_not_grantable": "Questo indirizzo è di loopback, link-local o dei metadati; nessuna autorizzazione dell’operatore può aprirlo. Usa l’indirizzo LAN del relay",
|
||||
"url_invalid": "L’URL non è valido",
|
||||
"redirected": "L’endpoint reindirizza — controlla l’URL",
|
||||
"endpoint_not_found": "Endpoint non trovato all’URL",
|
||||
|
||||
@@ -5991,6 +5991,7 @@
|
||||
"not_configured": "설정되지 않았어요",
|
||||
"url_not_public": "URL이 공개 엔드포인트가 아니에요",
|
||||
"private_origin_not_approved": "비공개 네트워크 대상이에요. 운영자가 NOTIFICATION_PRIVATE_ORIGINS에 이 출처를 등록해야 해요",
|
||||
"private_origin_not_grantable": "이 주소는 루프백, 링크 로컬 또는 메타데이터 주소예요. 운영자 등록으로도 열 수 없으니 릴레이의 LAN 주소를 사용해 주세요",
|
||||
"url_invalid": "URL이 올바르지 않아요",
|
||||
"redirected": "엔드포인트가 리디렉션돼요 — URL을 확인해 주세요",
|
||||
"endpoint_not_found": "해당 URL에 엔드포인트가 없어요",
|
||||
|
||||
@@ -5991,6 +5991,7 @@
|
||||
"not_configured": "Nieskonfigurowano",
|
||||
"url_not_public": "Adres URL nie jest publicznym punktem końcowym",
|
||||
"private_origin_not_approved": "Cel w sieci prywatnej. Operator musi dodać to źródło do NOTIFICATION_PRIVATE_ORIGINS",
|
||||
"private_origin_not_grantable": "Ten adres to loopback, link-local lub adres metadanych; żadna zgoda operatora nie może go otworzyć. Użyj adresu LAN przekaźnika",
|
||||
"url_invalid": "Adres URL jest nieprawidłowy",
|
||||
"redirected": "Punkt końcowy przekierowuje — sprawdź adres URL",
|
||||
"endpoint_not_found": "Nie znaleziono punktu końcowego pod adresem URL",
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
configuredNightscoutPrivateOrigins,
|
||||
evaluateNightscoutOrigin,
|
||||
nightscoutConnectSchema,
|
||||
NightscoutOriginConfigError,
|
||||
} from "@/lib/validations/nightscout";
|
||||
|
||||
/**
|
||||
@@ -67,10 +68,22 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const { url, token } = result.data;
|
||||
const policy = evaluateNightscoutOrigin(
|
||||
url,
|
||||
configuredNightscoutPrivateOrigins(),
|
||||
);
|
||||
// An operator list that holds a non-grant is an operator problem, not a
|
||||
// user one: say which entry and why, rather than a 500 with no words.
|
||||
let privateOrigins: ReadonlySet<string>;
|
||||
try {
|
||||
privateOrigins = configuredNightscoutPrivateOrigins();
|
||||
} catch (err) {
|
||||
if (err instanceof NightscoutOriginConfigError) {
|
||||
annotate({
|
||||
action: { name: "nightscout.connect" },
|
||||
meta: { refused: "operator_origin_list_invalid" },
|
||||
});
|
||||
return apiError(err.message, 422);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const policy = evaluateNightscoutOrigin(url, privateOrigins);
|
||||
|
||||
// SSRF floor at INPUT time, not merely as a side effect of the live probe
|
||||
// below. `fetchSgvEntries` does route through `safeFetch` with the same
|
||||
|
||||
@@ -179,10 +179,10 @@ describe("PUT /api/settings/webhook — private targets", () => {
|
||||
expect(json.meta).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses a loopback grant at save time: listing it does not open it", async () => {
|
||||
// The parser drops a loopback or localhost grant with a warning, so the
|
||||
// save is refused with the reason and the operator learns that the list
|
||||
// is not the way to reach the container itself.
|
||||
it("answers a loopback target with the not-grantable code, listed or not (M1)", async () => {
|
||||
// This used to pin a dead end: the route said "list it", the parser
|
||||
// dropped the listing, and the next save said "list it" again. A target
|
||||
// no grant can open gets its own code and a message that says so.
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
process.env.NOTIFICATION_PRIVATE_ORIGINS =
|
||||
"http://127.0.0.1:8080, http://localhost:8080";
|
||||
@@ -190,11 +190,16 @@ describe("PUT /api/settings/webhook — private targets", () => {
|
||||
for (const url of [
|
||||
"http://127.0.0.1:8080/message",
|
||||
"http://localhost:8080/message",
|
||||
"http://169.254.169.254/latest",
|
||||
"http://[::1]:8080/message",
|
||||
]) {
|
||||
const response = await put("webhook", { url, enabled: true });
|
||||
expect(response.status).toBe(422);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" });
|
||||
expect(json.meta).toEqual({ errorCode: "private_origin_not_grantable" });
|
||||
expect(json.error).toMatch(/LAN address/);
|
||||
expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS");
|
||||
expect(json.details.issues.length).toBeGreaterThan(0);
|
||||
}
|
||||
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -298,6 +303,29 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself",
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["webhook", sendViaWebhookMock],
|
||||
["ntfy", sendViaNtfyMock],
|
||||
])(
|
||||
"%s: forwards the not-grantable code with a message that does not say list it (M1)",
|
||||
async (path, sender) => {
|
||||
sender.mockResolvedValue({
|
||||
ok: false,
|
||||
hardReject: false,
|
||||
reason: `${path}_private_origin_refused`,
|
||||
errorCode: "private_origin_not_grantable",
|
||||
});
|
||||
|
||||
const response = await post(path);
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
const json = await response.json();
|
||||
expect(json.meta).toEqual({ errorCode: "private_origin_not_grantable" });
|
||||
expect(json.error).toMatch(/LAN address/);
|
||||
expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["webhook", sendViaWebhookMock],
|
||||
["ntfy", sendViaNtfyMock],
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
import {
|
||||
evaluateNotificationTarget,
|
||||
isAllowedNotificationTarget,
|
||||
PRIVATE_ORIGIN_NOT_APPROVED,
|
||||
PRIVATE_ORIGIN_NOT_APPROVED_CODE,
|
||||
PRIVATE_ORIGIN_NOT_GRANTABLE_CODE,
|
||||
} from "@/lib/notifications/egress-policy";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -30,6 +31,8 @@ const ntfySettingsSchema = ntfySettingsSchemaWith(isAllowedNotificationTarget);
|
||||
|
||||
const PRIVATE_ORIGIN_REFUSAL =
|
||||
"This server is on a private network. The operator has to list its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS before HealthLog can send to it.";
|
||||
const NOT_GRANTABLE_REFUSAL =
|
||||
"This server is loopback, link-local or a metadata address, which no operator grant can open. Use the relay's LAN address instead.";
|
||||
|
||||
/**
|
||||
* Refusal for an enable attempt on a channel the operator switched off
|
||||
@@ -145,18 +148,26 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
// adds the code and a message naming the operator's lever (#947).
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
const candidate = (body as { serverUrl?: unknown } | null)?.serverUrl;
|
||||
const reason =
|
||||
typeof candidate === "string"
|
||||
? evaluateNotificationTarget(candidate).reasonCode
|
||||
: null;
|
||||
if (
|
||||
typeof candidate === "string" &&
|
||||
evaluateNotificationTarget(candidate).reasonCode ===
|
||||
PRIVATE_ORIGIN_NOT_APPROVED
|
||||
reason === PRIVATE_ORIGIN_NOT_APPROVED_CODE ||
|
||||
reason === PRIVATE_ORIGIN_NOT_GRANTABLE_CODE
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "settings.ntfy.update" },
|
||||
meta: { refused: PRIVATE_ORIGIN_NOT_APPROVED },
|
||||
});
|
||||
return apiValidationError(PRIVATE_ORIGIN_REFUSAL, issues, 422, {
|
||||
errorCode: PRIVATE_ORIGIN_NOT_APPROVED,
|
||||
meta: { refused: reason },
|
||||
});
|
||||
return apiValidationError(
|
||||
reason === PRIVATE_ORIGIN_NOT_GRANTABLE_CODE
|
||||
? NOT_GRANTABLE_REFUSAL
|
||||
: PRIVATE_ORIGIN_REFUSAL,
|
||||
issues,
|
||||
422,
|
||||
{ errorCode: reason },
|
||||
);
|
||||
}
|
||||
return apiValidationError("Invalid data", issues, 422);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,9 @@ export const POST = apiHandler(async () => {
|
||||
meta: { success: false, refused: result.errorCode },
|
||||
});
|
||||
return apiError(
|
||||
"The server is on a private network the operator has not approved. List its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS on the server.",
|
||||
result.errorCode === "private_origin_not_grantable"
|
||||
? "The server is loopback, link-local or a metadata address, which no operator grant can open. Use the relay's LAN address instead."
|
||||
: "The server is on a private network the operator has not approved. List its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS on the server.",
|
||||
422,
|
||||
{ errorCode: result.errorCode },
|
||||
);
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
import {
|
||||
evaluateNotificationTarget,
|
||||
isAllowedNotificationTarget,
|
||||
PRIVATE_ORIGIN_NOT_APPROVED,
|
||||
PRIVATE_ORIGIN_NOT_APPROVED_CODE,
|
||||
PRIVATE_ORIGIN_NOT_GRANTABLE_CODE,
|
||||
} from "@/lib/notifications/egress-policy";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -32,6 +33,8 @@ const webhookSettingsSchema = webhookSettingsSchemaWith(
|
||||
|
||||
const PRIVATE_ORIGIN_REFUSAL =
|
||||
"This address is on a private network. The operator has to list its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS before HealthLog can send to it.";
|
||||
const NOT_GRANTABLE_REFUSAL =
|
||||
"This address is loopback, link-local or a metadata address, which no operator grant can open. Use the relay's LAN address instead.";
|
||||
|
||||
/**
|
||||
* Generic-webhook channel config (v1.17.1).
|
||||
@@ -139,18 +142,26 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
// adds the code and a message naming the operator's lever (#947).
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
const candidate = (body as { url?: unknown } | null)?.url;
|
||||
const reason =
|
||||
typeof candidate === "string"
|
||||
? evaluateNotificationTarget(candidate).reasonCode
|
||||
: null;
|
||||
if (
|
||||
typeof candidate === "string" &&
|
||||
evaluateNotificationTarget(candidate).reasonCode ===
|
||||
PRIVATE_ORIGIN_NOT_APPROVED
|
||||
reason === PRIVATE_ORIGIN_NOT_APPROVED_CODE ||
|
||||
reason === PRIVATE_ORIGIN_NOT_GRANTABLE_CODE
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "settings.webhook.update" },
|
||||
meta: { refused: PRIVATE_ORIGIN_NOT_APPROVED },
|
||||
});
|
||||
return apiValidationError(PRIVATE_ORIGIN_REFUSAL, issues, 422, {
|
||||
errorCode: PRIVATE_ORIGIN_NOT_APPROVED,
|
||||
meta: { refused: reason },
|
||||
});
|
||||
return apiValidationError(
|
||||
reason === PRIVATE_ORIGIN_NOT_GRANTABLE_CODE
|
||||
? NOT_GRANTABLE_REFUSAL
|
||||
: PRIVATE_ORIGIN_REFUSAL,
|
||||
issues,
|
||||
422,
|
||||
{ errorCode: reason },
|
||||
);
|
||||
}
|
||||
return apiValidationError("Invalid data", issues, 422);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,9 @@ export const POST = apiHandler(async () => {
|
||||
meta: { success: false, refused: result.errorCode },
|
||||
});
|
||||
return apiError(
|
||||
"The target is on a private network the operator has not approved. List its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS on the server.",
|
||||
result.errorCode === "private_origin_not_grantable"
|
||||
? "The target is loopback, link-local or a metadata address, which no operator grant can open. Use the relay's LAN address instead."
|
||||
: "The target is on a private network the operator has not approved. List its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS on the server.",
|
||||
422,
|
||||
{ errorCode: result.errorCode },
|
||||
);
|
||||
|
||||
@@ -72,8 +72,20 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = await res.json();
|
||||
throw new Error(json.error || t("common.error"));
|
||||
const json = (await res.json()) as {
|
||||
error?: string;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
// A coded refusal (the private-origin policy) translates like the
|
||||
// test button's; anything else shows the server sentence.
|
||||
const code = json.meta?.errorCode;
|
||||
const key = code ? `settings.testConnection.errors.${code}` : null;
|
||||
const translated = key ? t(key) : null;
|
||||
throw new Error(
|
||||
translated && translated !== key
|
||||
? translated
|
||||
: json.error || t("common.error"),
|
||||
);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -72,8 +72,20 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = await res.json();
|
||||
throw new Error(json.error || t("common.error"));
|
||||
const json = (await res.json()) as {
|
||||
error?: string;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
// A coded refusal (the private-origin policy) translates like the
|
||||
// test button's; anything else shows the server sentence.
|
||||
const code = json.meta?.errorCode;
|
||||
const key = code ? `settings.testConnection.errors.${code}` : null;
|
||||
const translated = key ? t(key) : null;
|
||||
throw new Error(
|
||||
translated && translated !== key
|
||||
? translated
|
||||
: json.error || t("common.error"),
|
||||
);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -16,9 +16,12 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
canonicalOrigin,
|
||||
describeGrantRejection,
|
||||
evaluateOriginGrant,
|
||||
parsePrivateOrigins,
|
||||
PRIVATE_ORIGIN_NOT_APPROVED,
|
||||
PRIVATE_ORIGIN_NOT_APPROVED_CODE,
|
||||
PRIVATE_ORIGIN_NOT_GRANTABLE_CODE,
|
||||
redactGrantEntry,
|
||||
} from "../private-origin-policy";
|
||||
|
||||
describe("canonicalOrigin", () => {
|
||||
@@ -113,7 +116,7 @@ describe("parsePrivateOrigins", () => {
|
||||
expect(onInvalid).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hands every malformed entry to the caller and keeps the valid ones", () => {
|
||||
it("hands every malformed entry to the caller, with a reason, and keeps the valid ones", () => {
|
||||
const onInvalid = vi.fn();
|
||||
expect(
|
||||
parsePrivateOrigins(
|
||||
@@ -126,6 +129,25 @@ describe("parsePrivateOrigins", () => {
|
||||
"http://127.0.0.1:8080",
|
||||
"not an origin",
|
||||
]);
|
||||
expect(onInvalid.mock.calls[1][1]).toMatch(/loopback.*cannot be granted/);
|
||||
expect(onInvalid.mock.calls[0][1]).toMatch(/one exact http\(s\) origin/);
|
||||
});
|
||||
|
||||
it("never hands a query, fragment or userinfo to the caller (M3)", () => {
|
||||
const onInvalid = vi.fn();
|
||||
parsePrivateOrigins(
|
||||
"http://10.0.0.4/message?token=AbC123, http://user:pw@gotify.lan/, https://ntfy.lan/topic#frag",
|
||||
onInvalid,
|
||||
);
|
||||
const seen = onInvalid.mock.calls.map(([entry]) => entry);
|
||||
expect(seen).toEqual([
|
||||
"http://10.0.0.4/message",
|
||||
"http://gotify.lan/",
|
||||
"https://ntfy.lan/topic",
|
||||
]);
|
||||
expect(JSON.stringify(onInvalid.mock.calls)).not.toMatch(
|
||||
/AbC123|token=|user:pw|#frag/,
|
||||
);
|
||||
});
|
||||
|
||||
it("lets the caller turn a malformed entry into a throw", () => {
|
||||
@@ -160,7 +182,7 @@ describe("evaluateOriginGrant", () => {
|
||||
allowed: false,
|
||||
canonicalOrigin: origin,
|
||||
privateOriginApproved: false,
|
||||
reasonCode: PRIVATE_ORIGIN_NOT_APPROVED,
|
||||
reasonCode: PRIVATE_ORIGIN_NOT_APPROVED_CODE,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,7 +200,49 @@ describe("evaluateOriginGrant", () => {
|
||||
allowed: false,
|
||||
canonicalOrigin: "http://192.168.1.9:8080",
|
||||
privateOriginApproved: false,
|
||||
reasonCode: PRIVATE_ORIGIN_NOT_APPROVED,
|
||||
reasonCode: PRIVATE_ORIGIN_NOT_APPROVED_CODE,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["IPv4 loopback", "http://127.0.0.1:8080"],
|
||||
["localhost", "http://localhost:8080"],
|
||||
["a *.localhost name", "http://gotify.localhost"],
|
||||
["link-local / metadata", "http://169.254.169.254"],
|
||||
["IPv6 loopback", "http://[::1]:8080"],
|
||||
["unspecified", "http://0.0.0.0:8080"],
|
||||
])(
|
||||
"answers %s with the not-grantable code, not a request to list it (M1)",
|
||||
(_label, origin) => {
|
||||
expect(evaluateOriginGrant(origin, grants)).toEqual({
|
||||
allowed: false,
|
||||
canonicalOrigin: origin,
|
||||
privateOriginApproved: false,
|
||||
reasonCode: PRIVATE_ORIGIN_NOT_GRANTABLE_CODE,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("redactGrantEntry / describeGrantRejection", () => {
|
||||
it("keeps scheme and host, drops userinfo, query and fragment", () => {
|
||||
expect(redactGrantEntry("http://user:pw@gotify.lan/?token=x#y")).toBe(
|
||||
"http://gotify.lan/",
|
||||
);
|
||||
expect(redactGrantEntry("https://gotify.example.com")).toBe(
|
||||
"https://gotify.example.com",
|
||||
);
|
||||
expect(redactGrantEntry("not an origin?token=x")).toBe("not an origin");
|
||||
});
|
||||
|
||||
it("names the never-grantable class apart from a grammar error", () => {
|
||||
expect(describeGrantRejection("http://localhost:1337")).toMatch(
|
||||
/loopback.*LAN address/,
|
||||
);
|
||||
expect(describeGrantRejection("http://[::1]:1337")).toMatch(/loopback/);
|
||||
expect(describeGrantRejection("https://*.lan")).toMatch(/exact http/);
|
||||
expect(describeGrantRejection("http://10.0.0.4/path")).toMatch(
|
||||
/exact http/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,21 @@ const EMBEDDED_PRIVATE_DNS_ANSWERS = [
|
||||
["RFC8215 local-use metadata", "64:ff9b:1:a9fe:0:a9fe::"],
|
||||
] as const;
|
||||
|
||||
/** True when the policy marker the dispatcher stamps is anywhere in the chain. */
|
||||
function hasPrivateHostMarker(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
const value = error as {
|
||||
safeFetchKind?: unknown;
|
||||
cause?: unknown;
|
||||
errors?: unknown[];
|
||||
};
|
||||
return (
|
||||
value.safeFetchKind === "private_host" ||
|
||||
hasPrivateHostMarker(value.cause) ||
|
||||
(value.errors ?? []).some(hasPrivateHostMarker)
|
||||
);
|
||||
}
|
||||
|
||||
function errorCodes(error: unknown): string[] {
|
||||
if (!error || typeof error !== "object") return [];
|
||||
const value = error as {
|
||||
@@ -306,6 +321,9 @@ describe("pinnedOperatorApprovedDispatcher", () => {
|
||||
|
||||
expect(caught).toBeDefined();
|
||||
expect(errorCodes(caught)).toContain("ENOTFOUND");
|
||||
// A real NXDOMAIN would also be ENOTFOUND; the policy marker is what
|
||||
// proves the refusal came from the floor and not from the resolver.
|
||||
expect(hasPrivateHostMarker(caught)).toBe(true);
|
||||
},
|
||||
5_000,
|
||||
);
|
||||
|
||||
@@ -75,6 +75,21 @@ describe("configuredNotificationPrivateOrigins", () => {
|
||||
expect(lines[0]).toContain("NOTIFICATION_PRIVATE_ORIGINS");
|
||||
expect(lines[0]).toContain("https://*.lan");
|
||||
expect(lines[1]).toContain("http://127.0.0.1:8080");
|
||||
expect(lines[1]).toMatch(/loopback.*cannot be granted/);
|
||||
});
|
||||
|
||||
it("warns without echoing a query, fragment or userinfo (M3)", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
process.env.NOTIFICATION_PRIVATE_ORIGINS =
|
||||
"http://10.0.0.4/message?token=AbC123, http://user:pw@gotify.lan/, https://ntfy.lan/topic#frag";
|
||||
|
||||
expect(configuredNotificationPrivateOrigins()).toEqual(new Set());
|
||||
|
||||
const lines = warn.mock.calls.map((call) => String(call[0]));
|
||||
expect(lines).toHaveLength(3);
|
||||
expect(lines[0]).toContain('"http://10.0.0.4/message"');
|
||||
expect(lines[1]).toContain('"http://gotify.lan/"');
|
||||
expect(lines.join("\n")).not.toMatch(/AbC123|token=|user:pw|#frag/);
|
||||
});
|
||||
|
||||
it("never widens on a wholly malformed value", () => {
|
||||
@@ -137,12 +152,25 @@ describe("evaluateNotificationTarget", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the raw-string floor: an octal loopback spelling is refused, not parsed", () => {
|
||||
it("keeps the raw-string floor: an octal loopback spelling is refused as never grantable", () => {
|
||||
expect(
|
||||
evaluateNotificationTarget("http://0177.0.0.1/message"),
|
||||
).toMatchObject({
|
||||
allowed: false,
|
||||
reasonCode: "private_origin_not_approved",
|
||||
reasonCode: "private_origin_not_grantable",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["loopback", "http://127.0.0.1:8080/message"],
|
||||
["localhost", "http://localhost:8080/message"],
|
||||
["metadata", "http://169.254.169.254/latest"],
|
||||
["IPv6 loopback", "http://[::1]:8080/message"],
|
||||
])("answers a %s target with the not-grantable code (M1)", (_l, url) => {
|
||||
process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com";
|
||||
expect(evaluateNotificationTarget(url)).toMatchObject({
|
||||
allowed: false,
|
||||
reasonCode: "private_origin_not_grantable",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
INVALID_ORIGIN,
|
||||
originOfUrl,
|
||||
parsePrivateOrigins,
|
||||
PRIVATE_ORIGIN_NOT_APPROVED,
|
||||
PRIVATE_ORIGIN_NOT_APPROVED_CODE,
|
||||
PRIVATE_ORIGIN_NOT_GRANTABLE_CODE,
|
||||
type OriginVerdict,
|
||||
} from "@/lib/private-origin-policy";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
@@ -43,10 +44,11 @@ let cachedOrigins: ReadonlySet<string> = new Set();
|
||||
export function configuredNotificationPrivateOrigins(): ReadonlySet<string> {
|
||||
const raw = process.env.NOTIFICATION_PRIVATE_ORIGINS;
|
||||
if (raw === cachedRaw) return cachedOrigins;
|
||||
cachedOrigins = parsePrivateOrigins(raw, (entry) => {
|
||||
console.warn(
|
||||
`${ENV_NAME}: ignoring entry "${entry}" — a grant is one exact http(s) origin (scheme://host[:port]) on a private network; loopback, link-local and metadata addresses cannot be granted`,
|
||||
);
|
||||
// The entry arrives reduced to scheme and host: this line goes to stdout,
|
||||
// not through the wide-event redactors, and a malformed entry is where a
|
||||
// pasted token or userinfo would sit.
|
||||
cachedOrigins = parsePrivateOrigins(raw, (redactedEntry, reason) => {
|
||||
console.warn(`${ENV_NAME}: ignoring entry "${redactedEntry}": ${reason}`);
|
||||
});
|
||||
cachedRaw = raw;
|
||||
return cachedOrigins;
|
||||
@@ -85,7 +87,7 @@ export function isAllowedNotificationTarget(url: string): boolean {
|
||||
return evaluateNotificationTarget(url).allowed;
|
||||
}
|
||||
|
||||
export { PRIVATE_ORIGIN_NOT_APPROVED };
|
||||
export { PRIVATE_ORIGIN_NOT_APPROVED_CODE, PRIVATE_ORIGIN_NOT_GRANTABLE_CODE };
|
||||
|
||||
/**
|
||||
* Wide-event mark for a send that used the grant: one row per private
|
||||
|
||||
@@ -217,4 +217,19 @@ describe("sendViaNtfy", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("reports the not-grantable code for a loopback target, without dialling (M1)", async () => {
|
||||
const result = await sendViaNtfy(
|
||||
{ serverUrl: "http://localhost:8080", topic: "health" },
|
||||
payload(),
|
||||
);
|
||||
|
||||
expect(safeFetchMock).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
hardReject: false,
|
||||
reason: "ntfy_private_origin_refused",
|
||||
errorCode: "private_origin_not_grantable",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -265,4 +265,19 @@ describe("sendViaWebhook", () => {
|
||||
expect(opts.operatorApprovedPrivateOrigin).toBe("http://10.0.0.9:8080");
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the not-grantable code for a loopback target, without dialling (M1)", async () => {
|
||||
const result = await sendViaWebhook(
|
||||
{ url: "http://127.0.0.1:8080/message" },
|
||||
payload(),
|
||||
);
|
||||
|
||||
expect(safeFetchMock).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
hardReject: false,
|
||||
reason: "webhook_private_origin_refused",
|
||||
errorCode: "private_origin_not_grantable",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,9 @@ import { isUrgentPayload } from "@/lib/notifications/types";
|
||||
import {
|
||||
annotatePrivateOriginEgress,
|
||||
evaluateNotificationTarget,
|
||||
PRIVATE_ORIGIN_NOT_APPROVED,
|
||||
PRIVATE_ORIGIN_NOT_APPROVED_CODE,
|
||||
} from "@/lib/notifications/egress-policy";
|
||||
import type { OriginReason } from "@/lib/private-origin-policy";
|
||||
|
||||
/**
|
||||
* Send notification via ntfy (simple HTTP POST).
|
||||
@@ -31,6 +32,10 @@ export async function sendViaNtfy(
|
||||
): Promise<SendOutcome> {
|
||||
const userId = payload.recipientUserId ?? payload.userId;
|
||||
const start = performance.now();
|
||||
// Set when the policy itself refuses, so the outcome can say which of the
|
||||
// two refusals it was: an origin the operator could list, or one no grant
|
||||
// can open.
|
||||
let policyReason: OriginReason | null = null;
|
||||
try {
|
||||
const url = `${config.serverUrl.replace(/\/$/, "")}/${encodeURIComponent(config.topic)}`;
|
||||
|
||||
@@ -74,6 +79,7 @@ export async function sendViaNtfy(
|
||||
// the settings routes and the webhook sender.
|
||||
const policy = evaluateNotificationTarget(url);
|
||||
if (!policy.allowed || !policy.canonicalOrigin) {
|
||||
policyReason = policy.reasonCode;
|
||||
throw new SafeFetchError(
|
||||
"ntfy server refused by the notification origin policy",
|
||||
"private_host",
|
||||
@@ -156,7 +162,9 @@ export async function sendViaNtfy(
|
||||
hardReject: false,
|
||||
reason,
|
||||
message,
|
||||
...(policyRefused ? { errorCode: PRIVATE_ORIGIN_NOT_APPROVED } : {}),
|
||||
...(policyRefused
|
||||
? { errorCode: policyReason ?? PRIVATE_ORIGIN_NOT_APPROVED_CODE }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,9 @@ import { stripHtml } from "@/lib/notifications/strip-html";
|
||||
import {
|
||||
annotatePrivateOriginEgress,
|
||||
evaluateNotificationTarget,
|
||||
PRIVATE_ORIGIN_NOT_APPROVED,
|
||||
PRIVATE_ORIGIN_NOT_APPROVED_CODE,
|
||||
} from "@/lib/notifications/egress-policy";
|
||||
import type { OriginReason } from "@/lib/private-origin-policy";
|
||||
|
||||
/**
|
||||
* Send a notification via a generic outbound webhook (v1.17.1).
|
||||
@@ -45,6 +46,10 @@ export async function sendViaWebhook(
|
||||
): Promise<SendOutcome> {
|
||||
const userId = payload.recipientUserId ?? payload.userId;
|
||||
const start = performance.now();
|
||||
// Set when the policy itself refuses, so the outcome can say which of the
|
||||
// two refusals it was: an origin the operator could list, or one no grant
|
||||
// can open.
|
||||
let policyReason: OriginReason | null = null;
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
@@ -75,6 +80,7 @@ export async function sendViaWebhook(
|
||||
|
||||
const policy = evaluateNotificationTarget(config.url);
|
||||
if (!policy.allowed || !policy.canonicalOrigin) {
|
||||
policyReason = policy.reasonCode;
|
||||
throw new SafeFetchError(
|
||||
"webhook target refused by the notification origin policy",
|
||||
"private_host",
|
||||
@@ -159,7 +165,9 @@ export async function sendViaWebhook(
|
||||
hardReject: false,
|
||||
reason,
|
||||
message,
|
||||
...(policyRefused ? { errorCode: PRIVATE_ORIGIN_NOT_APPROVED } : {}),
|
||||
...(policyRefused
|
||||
? { errorCode: policyReason ?? PRIVATE_ORIGIN_NOT_APPROVED_CODE }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -334,6 +334,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"notification_prefs_conflict",
|
||||
"oidc_only",
|
||||
"private_origin_not_approved",
|
||||
"private_origin_not_grantable",
|
||||
"rate_limited_self",
|
||||
"too_many_rows",
|
||||
"unreadable_json",
|
||||
|
||||
@@ -400,7 +400,7 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...stdResponses,
|
||||
"422": {
|
||||
description:
|
||||
"Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and topic; or the server URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`). Nothing was stored.",
|
||||
"Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and topic; or the server URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`), or it is a loopback, link-local or metadata address that no grant can open (`private_origin_not_grantable`). Nothing was stored.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
@@ -516,7 +516,7 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...stdResponses,
|
||||
"422": {
|
||||
description:
|
||||
"Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`). Nothing was stored.",
|
||||
"Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`), or it is a loopback, link-local or metadata address that no grant can open (`private_origin_not_grantable`). Nothing was stored.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
@@ -719,7 +719,7 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...stdResponses,
|
||||
"422": {
|
||||
description:
|
||||
"The saved server is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`). The resolved address is never echoed.",
|
||||
"The saved server is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a loopback, link-local or metadata address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
@@ -765,7 +765,7 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...stdResponses,
|
||||
"422": {
|
||||
description:
|
||||
"The saved URL is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`). The resolved address is never echoed.",
|
||||
"The saved URL is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a loopback, link-local or metadata address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -17,12 +17,64 @@ import {
|
||||
* several origins.
|
||||
*/
|
||||
|
||||
export const PRIVATE_ORIGIN_NOT_APPROVED =
|
||||
export const PRIVATE_ORIGIN_NOT_APPROVED_CODE =
|
||||
"private_origin_not_approved" as const;
|
||||
/**
|
||||
* The target is loopback, unspecified, link-local or the metadata range: no
|
||||
* grant can ever open it, so telling the user to ask the operator would send
|
||||
* them in a circle. On host networking the LAN address is the answer.
|
||||
*/
|
||||
export const PRIVATE_ORIGIN_NOT_GRANTABLE_CODE =
|
||||
"private_origin_not_grantable" as const;
|
||||
export const INVALID_ORIGIN = "invalid_origin" as const;
|
||||
|
||||
export type OriginReason =
|
||||
typeof PRIVATE_ORIGIN_NOT_APPROVED | typeof INVALID_ORIGIN;
|
||||
| typeof PRIVATE_ORIGIN_NOT_APPROVED_CODE
|
||||
| typeof PRIVATE_ORIGIN_NOT_GRANTABLE_CODE
|
||||
| typeof INVALID_ORIGIN;
|
||||
|
||||
/**
|
||||
* A hostname no grant may name: `localhost` / `*.localhost` (loopback by
|
||||
* definition, RFC 6761) or a literal address outside the grantable ranges.
|
||||
* DNS names are not judged here — a name that resolves into those ranges is
|
||||
* dropped at dial time by the pinned dispatcher.
|
||||
*/
|
||||
export function isNeverGrantableHost(hostname: string): boolean {
|
||||
const lower = hostname.toLowerCase();
|
||||
if (lower === "localhost" || lower.endsWith(".localhost")) return true;
|
||||
const literal = lower.replace(/^\[|\]$/g, "");
|
||||
return isIP(literal) !== 0 && !isOperatorGrantableIp(literal);
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry as it may be logged or put in an error message: scheme and host
|
||||
* only. A malformed entry is malformed precisely when it carries the shapes
|
||||
* that hold a secret — a query with a token, userinfo, a capability URL
|
||||
* pasted whole — and this text goes to stdout, not through the wide-event
|
||||
* redactors.
|
||||
*/
|
||||
export function redactGrantEntry(entry: string): string {
|
||||
return entry
|
||||
.split(/[?#]/, 1)[0]
|
||||
.replace(/\/\/[^/@]*@/, "//")
|
||||
.slice(0, 200);
|
||||
}
|
||||
|
||||
/** Why an entry is not a grant, in operator-readable words. */
|
||||
export function describeGrantRejection(entry: string): string {
|
||||
try {
|
||||
const url = new URL(entry);
|
||||
if (
|
||||
(url.protocol === "http:" || url.protocol === "https:") &&
|
||||
isNeverGrantableHost(url.hostname)
|
||||
) {
|
||||
return "loopback, link-local and metadata addresses cannot be granted; on host networking list the LAN address of the relay instead";
|
||||
}
|
||||
} catch {
|
||||
// fall through to the grammar sentence
|
||||
}
|
||||
return "a grant is one exact http(s) origin (scheme://host[:port]) with no path, query, credentials or wildcard";
|
||||
}
|
||||
|
||||
export interface OriginVerdict {
|
||||
allowed: boolean;
|
||||
@@ -48,17 +100,12 @@ export function canonicalOrigin(value: string): string | null {
|
||||
if (url.username || url.password) return null;
|
||||
if (url.pathname !== "/" || url.search || url.hash) return null;
|
||||
if (!url.hostname || url.hostname.includes("*")) return null;
|
||||
// `localhost` and `*.localhost` are loopback by definition (RFC 6761);
|
||||
// refuse them at parse time like a literal loopback address. Other
|
||||
// Loopback, unspecified, link-local and metadata literals, and
|
||||
// `localhost` / `*.localhost`, are refused at parse time. Other
|
||||
// reserved-looking names (`.local` mDNS, `.internal`, `.lan`) stay
|
||||
// grantable: an explicitly listed origin is what the list is for, and
|
||||
// the dial-time floor still drops a loopback or metadata answer.
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
if (hostname === "localhost" || hostname.endsWith(".localhost")) {
|
||||
return null;
|
||||
}
|
||||
const literal = hostname.replace(/^\[|\]$/g, "");
|
||||
if (isIP(literal) !== 0 && !isOperatorGrantableIp(literal)) return null;
|
||||
if (isNeverGrantableHost(url.hostname)) return null;
|
||||
return url.origin;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -102,15 +149,16 @@ function sharesApprovedPrivateHostname(
|
||||
/**
|
||||
* Parse a server-only comma-separated exact-origin allowlist.
|
||||
*
|
||||
* Every malformed non-empty entry is handed to `onInvalid`, which decides the
|
||||
* failure mode: the Nightscout list throws (a connection form can afford to
|
||||
* Every malformed non-empty entry is handed to `onInvalid` with the entry
|
||||
* already reduced to scheme and host (`redactGrantEntry`) and the reason in
|
||||
* words (`describeGrantRejection`); the callback decides the failure mode: the Nightscout list throws (a connection form can afford to
|
||||
* fail loudly), the notification list logs once and skips the entry (a typo
|
||||
* must not take every public webhook down with it). Either way a malformed
|
||||
* entry grants nothing — the set only ever shrinks.
|
||||
*/
|
||||
export function parsePrivateOrigins(
|
||||
raw: string | undefined,
|
||||
onInvalid: (entry: string) => void,
|
||||
onInvalid: (redactedEntry: string, reason: string) => void,
|
||||
): ReadonlySet<string> {
|
||||
const origins = new Set<string>();
|
||||
for (const entry of (raw ?? "").split(",")) {
|
||||
@@ -118,7 +166,7 @@ export function parsePrivateOrigins(
|
||||
if (!trimmed) continue;
|
||||
const origin = canonicalOrigin(trimmed);
|
||||
if (!origin) {
|
||||
onInvalid(trimmed);
|
||||
onInvalid(redactGrantEntry(trimmed), describeGrantRejection(trimmed));
|
||||
continue;
|
||||
}
|
||||
origins.add(origin);
|
||||
@@ -158,7 +206,7 @@ export function evaluateOriginGrant(
|
||||
allowed: false,
|
||||
canonicalOrigin: origin,
|
||||
privateOriginApproved: false,
|
||||
reasonCode: PRIVATE_ORIGIN_NOT_APPROVED,
|
||||
reasonCode: PRIVATE_ORIGIN_NOT_APPROVED_CODE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -171,10 +219,14 @@ export function evaluateOriginGrant(
|
||||
};
|
||||
}
|
||||
|
||||
// Two refusals that read differently to the person who gets them: a
|
||||
// private address the operator could list, and one no grant can open.
|
||||
return {
|
||||
allowed: false,
|
||||
canonicalOrigin: origin,
|
||||
privateOriginApproved: false,
|
||||
reasonCode: PRIVATE_ORIGIN_NOT_APPROVED,
|
||||
reasonCode: isNeverGrantableHost(new URL(origin).hostname)
|
||||
? PRIVATE_ORIGIN_NOT_GRANTABLE_CODE
|
||||
: PRIVATE_ORIGIN_NOT_APPROVED_CODE,
|
||||
};
|
||||
}
|
||||
@@ -120,4 +120,24 @@ describe("Nightscout exact-origin policy", () => {
|
||||
reasonCode: "invalid_origin",
|
||||
});
|
||||
});
|
||||
|
||||
it("names the entry and the reason when a loopback grant is listed, without its token (M2)", () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
parseNightscoutPrivateOrigins(
|
||||
"https://cgm.lan, http://localhost:1337/?token=SECRET123",
|
||||
);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
const message = (caught as Error).message;
|
||||
expect((caught as Error).name).toBe("NightscoutOriginConfigError");
|
||||
expect(message).toContain(
|
||||
'NIGHTSCOUT_PRIVATE_ORIGINS entry "http://localhost:1337/"',
|
||||
);
|
||||
expect(message).toMatch(/loopback.*LAN address/);
|
||||
expect(message).not.toContain("SECRET123");
|
||||
expect(message).not.toContain("token=");
|
||||
});
|
||||
});
|
||||
@@ -4,14 +4,32 @@ import {
|
||||
evaluateOriginGrant,
|
||||
INVALID_ORIGIN,
|
||||
parsePrivateOrigins,
|
||||
PRIVATE_ORIGIN_NOT_APPROVED,
|
||||
PRIVATE_ORIGIN_NOT_APPROVED_CODE,
|
||||
type OriginReason,
|
||||
type OriginVerdict,
|
||||
} from "@/lib/private-origin-policy";
|
||||
|
||||
export const NIGHTSCOUT_PRIVATE_ORIGIN_REASON = PRIVATE_ORIGIN_NOT_APPROVED;
|
||||
export const NIGHTSCOUT_PRIVATE_ORIGIN_REASON =
|
||||
PRIVATE_ORIGIN_NOT_APPROVED_CODE;
|
||||
export const NIGHTSCOUT_INVALID_ORIGIN_REASON = INVALID_ORIGIN;
|
||||
|
||||
/**
|
||||
* Thrown when `NIGHTSCOUT_PRIVATE_ORIGINS` holds an entry that is not a
|
||||
* grant. The message names the entry (scheme and host only) and the reason,
|
||||
* so the sync ledger and the connect form show the operator what to edit
|
||||
* instead of a bare "invalid entry". Loopback and `localhost` entries
|
||||
* became invalid with the shared address floor (#947); a host-networking
|
||||
* deployment lists the LAN address instead.
|
||||
*/
|
||||
export class NightscoutOriginConfigError extends Error {
|
||||
constructor(redactedEntry: string, reason: string) {
|
||||
super(
|
||||
`Invalid NIGHTSCOUT_PRIVATE_ORIGINS entry "${redactedEntry}": ${reason}`,
|
||||
);
|
||||
this.name = "NightscoutOriginConfigError";
|
||||
}
|
||||
}
|
||||
|
||||
export type NightscoutOriginReason = OriginReason;
|
||||
|
||||
export type NightscoutOriginVerdict = OriginVerdict;
|
||||
@@ -28,8 +46,8 @@ export type NightscoutOriginVerdict = OriginVerdict;
|
||||
export function parseNightscoutPrivateOrigins(
|
||||
raw: string | undefined,
|
||||
): ReadonlySet<string> {
|
||||
return parsePrivateOrigins(raw, () => {
|
||||
throw new Error("Invalid NIGHTSCOUT_PRIVATE_ORIGINS entry");
|
||||
return parsePrivateOrigins(raw, (redactedEntry, reason) => {
|
||||
throw new NightscoutOriginConfigError(redactedEntry, reason);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user