fix(api): name the private-origin refusal on webhook and ntfy routes

The save routes evaluate the same policy the senders do. A target on a
private network the operator has not listed is a 422 with
meta.errorCode = private_origin_not_approved and a message that names
NOTIFICATION_PRIVATE_ORIGINS, instead of the generic "Invalid data"; a
listed private address saves although the plain public floor would
have refused it. The settings schemas take the target predicate as a
parameter so the validator module never reads the environment; the
exported constants keep the public floor for the OpenAPI contract.

The test routes forward the sender's refusal as the same 422 and code
instead of a bare 500, so the card can say why the test failed rather
than sending the operator to the wide-event log. A delivery fault is
still a 500. Both test routes and the dispatcher reach the same sender
function, and a structural test holds them there.

The OpenAPI descriptions name the code on all four routes; the test
button's error map gains the code in every locale.

Refs #947
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 16:46:13 +02:00
1 parent 6c75c4f39f
commit 984a75d036
17 files changed
+614 -56

No files matched your search

+32 -11
View File
@@ -11969,7 +11969,8 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422":
description: Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and
topic.
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.
content:
application/json:
schema:
@@ -12094,7 +12095,9 @@ paths:
"413": *a16
"415": *a17
"422":
description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored.
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.
content:
application/json:
schema:
@@ -12311,7 +12314,9 @@ paths:
summary: Send a test message to the saved ntfy topic
description: Publishes one message to the account's configured ntfy server and topic. Takes no body. Rate-limited 5 per
5 minutes per user. Refuses with 400 when no ntfy channel is saved or when the saved config is missing a server
URL or topic; a publish failure is a 500.
URL or topic; a publish failure is a 500. A server on a private network is refused with 422 and `meta.errorCode`
= `private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`;
the test and the scheduled delivery take the same decision.
responses:
"200":
description: The provider accepted the message. Not proof it was delivered.
@@ -12320,7 +12325,13 @@ paths:
schema:
$ref: "#/components/schemas/NtfyTestEnvelope"
"401": *a1
"422": *a4
"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.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429": *a5
/api/settings/telegram/test:
post:
@@ -12353,7 +12364,9 @@ paths:
description: Posts one payload to the account's configured webhook URL. Takes no body. Rate-limited 5 per 5 minutes per
user. Refuses with 400 when no webhook channel is saved or the saved config has no URL; a delivery failure is a
500. The URL was checked against the SSRF floor when it was saved, so a test cannot be used to reach an internal
host.
host. A URL on a private network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless
the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take
the same decision.
responses:
"200":
description: The provider accepted the message. Not proof it was delivered.
@@ -12362,7 +12375,13 @@ paths:
schema:
$ref: "#/components/schemas/WebhookTestEnvelope"
"401": *a1
"422": *a4
"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.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429": *a5
/api/settings/data:
delete:
@@ -25600,9 +25619,10 @@ components:
required:
- enabled
description: "ntfy channel config. `serverUrl` must pass the SSRF floor — a URL resolving into a private range is
refused at input time. An EMPTY OR OMITTED `authToken` preserves the stored one rather than clearing it: the GET
never returns the secret, so a client round-tripping the form would otherwise wipe it on every unrelated save.
Sending a non-empty value replaces it. There is no way to clear a stored auth token through this endpoint."
refused at input time unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR
OMITTED `authToken` preserves the stored one rather than clearing it: the GET never returns the secret, so a
client round-tripping the form would otherwise wipe it on every unrelated save. Sending a non-empty value
replaces it. There is no way to clear a stored auth token through this endpoint."
TelegramSettingsPutRequest:
anyOf:
- $ref: "#/components/schemas/NotificationChannelEnabledRequest"
@@ -25660,8 +25680,9 @@ components:
- enabled
description: "Generic-webhook channel config — one channel covering Gotify, Discord, Slack, a Matrix bridge, Home
Assistant, or any relay accepting an inbound JSON POST. `url` must pass the SSRF floor at input time and is
re-checked at dispatch time. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason as
ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this
re-checked at dispatch time; a private origin passes both only when the operator listed it in
`NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason
as ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this
endpoint."
DeleteAccountRequest:
type: object