diff --git a/server/plugins/_publisher_ntfy/README.md b/server/plugins/_publisher_ntfy/README.md
index d86fa4d8..8a99a9e7 100755
--- a/server/plugins/_publisher_ntfy/README.md
+++ b/server/plugins/_publisher_ntfy/README.md
@@ -12,28 +12,36 @@ If your ntfy instance sits behind a reverse proxy or tunnel that authenticates r
Both are independent of `NTFY_TOKEN` / `NTFY_USER` / `NTFY_PASSWORD` — those still control authentication against ntfy itself and are unaffected.
-### Custom header
+### Custom headers
-Sends an extra HTTP header with the request. Prefer this over the query string for anything secret.
+Sends extra HTTP headers with the request. Prefer this over the query string for anything secret.
-| Setting | Sample value |
-|---|---|
-| `NTFY_CUSTOMHEADER_NAME` | `X-Proxy-Token` |
-| `NTFY_CUSTOMHEADER_VALUE` | `p_abc123.def456ghi789` |
+`NTFY_CUSTOM_HEADERS` is a list. Add one entry per header, in the format `Name: Value`:
+
+```
+X-Proxy-Token: p_abc123.def456ghi789
+```
+
+Proxies that need more than one header work the same way — add a second entry. Pangolin, for example:
+
+```
+P-Access-Token-Id: abc123
+P-Access-Token: def456ghi789
+```
Other common examples:
-| Proxy | Header name | Header value |
-|---|---|---|
-| Pangolin | `P-Token` | `tokenId.tokenValue` |
-| Cloudflare Access | `CF-Access-Client-Id` | `abc123.access` |
-| Generic bearer gateway | `X-Auth-Token` | `eyJhbGciOi...` |
+| Proxy | Entry |
+|---|---|
+| Pangolin (single token) | `P-Token: tokenId.tokenValue` |
+| Cloudflare Access | `CF-Access-Client-Id: abc123.access` |
+| Generic bearer gateway | `X-Auth-Token: eyJhbGciOi...` |
-Both settings must be filled in — setting only one of them does nothing.
+The first `:` separates the name from the value, so a value may itself contain colons. Surrounding whitespace is trimmed.
-The header value must be a valid HTTP header value: plain ASCII, no newlines, and no leading or trailing whitespace. A trailing newline pasted in from a text file is the most common mistake and the plugin will report it as an invalid custom header.
+Each header value must be a valid HTTP header value: plain ASCII, no newlines, and no leading or trailing whitespace. A trailing newline pasted in from a text file is the most common mistake and the plugin will report it as an invalid custom header.
-If the header name collides with one the plugin has already set for this request (`Title`, `Actions`, `Priority`, `Tags`, plus `Authorization` when an ntfy token or username/password is configured), the custom header is skipped and a warning is logged, so it can never clobber your ntfy credentials. With no ntfy credentials configured there is no `Authorization` header to clash with, so you are free to use that name for the proxy.
+An entry is skipped, with a warning in the log, when it is not in `Name: Value` form, when the same name is listed twice, or when the name collides with a header the plugin has already set for this request (`Title`, `Actions`, `Priority`, `Tags`, plus `Authorization` when an ntfy token or username/password is configured). That last rule means a custom header can never clobber your ntfy credentials. With no ntfy credentials configured there is no `Authorization` header to clash with, so you are free to use that name for the proxy.
### URL query string
@@ -45,4 +53,4 @@ Appends a query string to the ntfy request URL, for proxies that authenticate vi
A leading `?` is optional — both `p_token=...` and `?p_token=...` work. Multiple parameters are supported: `p_token=abc&source=netalertx`.
-Note that query strings are commonly recorded in proxy and web-server access logs, so for secrets the custom header above is the safer option. The plugin redacts the query string from any error message it logs.
+Note that query strings are commonly recorded in proxy and web-server access logs, so for secrets the custom headers above are the safer option. The plugin redacts the query string from any error message it logs.
diff --git a/server/plugins/_publisher_ntfy/config.json b/server/plugins/_publisher_ntfy/config.json
index 7b40ed85..66ffc4f9 100755
--- a/server/plugins/_publisher_ntfy/config.json
+++ b/server/plugins/_publisher_ntfy/config.json
@@ -581,50 +581,78 @@
]
},
{
- "function": "CUSTOMHEADER_NAME",
+ "function": "CUSTOM_HEADERS",
"type": {
- "dataType": "string",
+ "dataType": "array",
"elements": [
- { "elementType": "input", "elementOptions": [], "transformers": [] }
+ {
+ "elementType": "input",
+ "elementOptions": [
+ { "placeholder": "Enter value" },
+ { "suffix": "_in" },
+ { "cssClasses": "col-sm-10" },
+ { "prefillValue": "null" }
+ ],
+ "transformers": []
+ },
+ {
+ "elementType": "button",
+ "elementOptions": [
+ { "sourceSuffixes": ["_in"] },
+ { "separator": "" },
+ { "cssClasses": "col-xs-12" },
+ { "onClick": "addList(this, false)" },
+ { "getStringKey": "Gen_Add" }
+ ],
+ "transformers": []
+ },
+ {
+ "elementType": "select",
+ "elementHasInputValue": 1,
+ "elementOptions": [
+ { "multiple": "true" },
+ { "readonly": "true" },
+ { "editable": "true" }
+ ],
+ "transformers": []
+ },
+ {
+ "elementType": "button",
+ "elementOptions": [
+ { "sourceSuffixes": [] },
+ { "separator": "" },
+ { "cssClasses": "col-xs-6" },
+ { "onClick": "removeAllOptions(this)" },
+ { "getStringKey": "Gen_Remove_All" }
+ ],
+ "transformers": []
+ },
+ {
+ "elementType": "button",
+ "elementOptions": [
+ { "sourceSuffixes": [] },
+ { "separator": "" },
+ { "cssClasses": "col-xs-6" },
+ { "onClick": "removeFromList(this)" },
+ { "getStringKey": "Gen_Remove_Last" }
+ ],
+ "transformers": []
+ }
]
},
- "default_value": "",
+ "default_value": [],
"options": [],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
- "string": "Custom header name"
+ "string": "Custom headers"
}
],
"description": [
{
"language_code": "en_us",
- "string": "Optional custom HTTP header name sent with the ntfy request, e.g. to authenticate through a reverse proxy or tunnel. Requires the custom header value to also be set. Leave empty to disable."
- }
- ]
- },
- {
- "function": "CUSTOMHEADER_VALUE",
- "type": {
- "dataType": "string",
- "elements": [
- { "elementType": "input", "elementOptions": [{ "type": "password" }], "transformers": [] }
- ]
- },
- "default_value": "",
- "options": [],
- "localized": ["name", "description"],
- "name": [
- {
- "language_code": "en_us",
- "string": "Custom header value"
- }
- ],
- "description": [
- {
- "language_code": "en_us",
- "string": "Value for the custom HTTP header defined above. Requires the custom header name to also be set. Leave empty to disable."
+ "string": "Optional custom HTTP headers sent with the ntfy request, one entry per header in the format Name: Value, for example P-Access-Token-Id: abc123. Useful to authenticate through a reverse proxy or tunnel (Pangolin, Tailscale, ...) in front of ntfy, where more than one header may be required. Entries that are malformed, or whose name collides with a header the plugin already sets (such as Authorization), are skipped and logged. Leave empty to disable."
}
]
}
diff --git a/server/plugins/_publisher_ntfy/ntfy.py b/server/plugins/_publisher_ntfy/ntfy.py
index f9a9b720..612532ec 100755
--- a/server/plugins/_publisher_ntfy/ntfy.py
+++ b/server/plugins/_publisher_ntfy/ntfy.py
@@ -84,6 +84,33 @@ def check_config():
return True
+# -------------------------------------------------------------------------------
+def build_custom_headers(entries, reserved_headers):
+ """Turn "Name: Value" setting entries into a header dict.
+
+ Entries are skipped when malformed, when the name would clobber a header the
+ plugin already set (so ntfy auth stays intact), and when a name repeats.
+ Values are never logged, they are usually secrets.
+ """
+
+ taken = {name.lower() for name in reserved_headers}
+ custom_headers = {}
+
+ for position, entry in enumerate(entries, start=1):
+ name, separator, value = entry.partition(':')
+ name, value = name.strip(), value.strip()
+
+ if separator == '' or name == '' or value == '':
+ mylog('none', [f'[{pluginName}] ⚠ Ignoring custom header #{position}, expected the format "Name: Value".'])
+ elif name.lower() in taken:
+ mylog('none', [f'[{pluginName}] ⚠ Custom header "{name}" collides with a header that is already set; skipping it.'])
+ else:
+ taken.add(name.lower())
+ custom_headers[name] = value
+
+ return custom_headers
+
+
# -------------------------------------------------------------------------------
def send(html, text):
@@ -95,8 +122,7 @@ def send(html, text):
user = get_setting_value('NTFY_USER')
pwd = get_setting_value('NTFY_PASSWORD')
verify_ssl = get_setting_value('NTFY_VERIFY_SSL')
- custom_header_name = get_setting_value('NTFY_CUSTOMHEADER_NAME')
- custom_header_value = get_setting_value('NTFY_CUSTOMHEADER_VALUE')
+ custom_header_entries = get_setting_value('NTFY_CUSTOM_HEADERS') or []
# Strip a leading '?' so both "p_token=..." and "?p_token=..." work; requests
# adds the '?' itself, and a leading one would produce a broken "??" in the URL.
url_query_string = get_setting_value('NTFY_URL_QUERY_STRING').lstrip('?')
@@ -118,16 +144,11 @@ def send(html, text):
# add authorization header with hash
headers["Authorization"] = "Basic {}".format(basichash)
- # Optional custom header, e.g. to authenticate through a reverse proxy / tunnel
- # (Pangolin, Tailscale, ...) sitting in front of the ntfy instance. Skip it if it
- # would clobber a built-in header (e.g. Authorization) so ntfy auth stays intact.
- custom_header_applied = False
- if custom_header_name != '' and custom_header_value != '':
- if custom_header_name.lower() in {k.lower() for k in headers}:
- mylog('none', [f'[{pluginName}] ⚠ Custom header "{custom_header_name}" collides with a built-in header; skipping it.'])
- else:
- headers[custom_header_name] = custom_header_value
- custom_header_applied = True
+ # Optional custom headers, e.g. to authenticate through a reverse proxy / tunnel
+ # sitting in front of the ntfy instance. Pangolin needs two of them, which is why
+ # this is a list rather than a single name/value pair.
+ custom_headers = build_custom_headers(custom_header_entries, headers)
+ headers.update(custom_headers)
# call NTFY service
try:
@@ -153,10 +174,11 @@ def send(html, text):
# requests echoes the offending header value in this exception's message,
# so the message itself is never logged - it would leak the configured
# custom header value. Report the problem without quoting the value.
- if custom_header_applied:
- error_text = (f'Invalid custom header "{custom_header_name}" - the header name or value contains '
- f'characters that are not allowed in an HTTP header (e.g. a newline, a leading space, '
- f'or a non-ASCII character). Check for trailing whitespace on the value.')
+ if custom_headers:
+ names = ', '.join(f'"{name}"' for name in custom_headers)
+ error_text = (f'Invalid custom header - one of {names} has a name or value containing characters '
+ f'that are not allowed in an HTTP header (e.g. a newline, a leading space, or a '
+ f'non-ASCII character). Check for trailing whitespace on the value.')
else:
error_text = ('A request header contains characters that are not allowed in an HTTP header. Check the '
'NTFY_* settings for stray newlines or non-ASCII characters.')
diff --git a/test/plugins/test_ntfy_custom_headers.py b/test/plugins/test_ntfy_custom_headers.py
new file mode 100644
index 00000000..cdfab9ac
--- /dev/null
+++ b/test/plugins/test_ntfy_custom_headers.py
@@ -0,0 +1,110 @@
+"""
+Tests for _publisher_ntfy/ntfy.py custom header parsing.
+
+Run from inside the NetAlertX container (where the full environment is available),
+or locally — in that case the NetAlertX-specific modules are stubbed out
+automatically before the script is imported.
+
+ pytest test/plugins/test_ntfy_custom_headers.py -v
+"""
+
+import os
+import sys
+import tempfile
+import types
+from unittest.mock import MagicMock
+
+# ---------------------------------------------------------------------------
+# Stub NetAlertX-specific modules so tests can run outside the container.
+# sys.modules.setdefault() is a no-op when the real module is already loaded,
+# so this is safe to run inside the container too.
+# ---------------------------------------------------------------------------
+_tmp_log = tempfile.mkdtemp()
+
+
+def _stub(name: str, **attrs):
+ if name not in sys.modules:
+ mod = types.ModuleType(name)
+ for k, v in attrs.items():
+ setattr(mod, k, v)
+ sys.modules[name] = mod
+
+
+_stub("pytz", timezone=lambda tz: tz)
+_stub("conf", tz=None)
+_stub("const", confFileName="app.conf", logPath=_tmp_log)
+_stub("plugin_helper", Plugin_Objects=MagicMock, handleEmpty=lambda v: v)
+_stub("utils")
+_stub("utils.datetime_utils", timeNowUTC=lambda: "2026-01-01 00:00:00")
+_stub("logger", mylog=lambda *a: None, Logger=MagicMock)
+_stub("helper", get_setting_value=lambda k, default="": "")
+_stub("models")
+_stub("models.notification_instance", NotificationInstance=MagicMock)
+_stub("database", DB=MagicMock)
+
+if "requests" not in sys.modules:
+ _req = types.ModuleType("requests")
+ _req.post = MagicMock
+ _req_exc = types.ModuleType("requests.exceptions")
+ _req_exc.InvalidHeader = type("InvalidHeader", (Exception,), {})
+ _req_exc.RequestException = type("RequestException", (Exception,), {})
+ _req.exceptions = _req_exc
+ sys.modules["requests"] = _req
+ sys.modules["requests.exceptions"] = _req_exc
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "server", "plugins", "_publisher_ntfy"))
+
+from ntfy import build_custom_headers # noqa: E402
+
+BUILT_IN = {"Title": "NetAlertX Notification", "Authorization": "Bearer secret"}
+
+
+def test_parses_a_single_header():
+ assert build_custom_headers(["X-Token: abc123"], {}) == {"X-Token": "abc123"}
+
+
+def test_parses_multiple_headers():
+ entries = ["P-Access-Token-Id: id123", "P-Access-Token: token456"]
+
+ assert build_custom_headers(entries, {}) == {
+ "P-Access-Token-Id": "id123",
+ "P-Access-Token": "token456",
+ }
+
+
+def test_trims_surrounding_whitespace():
+ assert build_custom_headers([" X-Token : abc123 "], {}) == {"X-Token": "abc123"}
+
+
+def test_keeps_colons_inside_the_value():
+ assert build_custom_headers(["X-Token: id:secret"], {}) == {"X-Token": "id:secret"}
+
+
+def test_skips_entries_without_a_separator():
+ assert build_custom_headers(["X-Token abc123"], {}) == {}
+
+
+def test_skips_entries_missing_a_name_or_value():
+ assert build_custom_headers([": abc123", "X-Token:", "", " "], {}) == {}
+
+
+def test_skips_names_that_collide_with_a_built_in_header():
+ assert build_custom_headers(["Authorization: Bearer mine"], BUILT_IN) == {}
+
+
+def test_collision_check_ignores_case():
+ assert build_custom_headers(["authorization: Bearer mine"], BUILT_IN) == {}
+
+
+def test_keeps_the_first_of_a_repeated_name():
+ assert build_custom_headers(["X-Token: first", "X-Token: second"], {}) == {"X-Token": "first"}
+
+
+def test_a_bad_entry_does_not_discard_the_good_ones():
+ entries = ["Authorization: Bearer mine", "malformed", "P-Access-Token: token456"]
+
+ assert build_custom_headers(entries, BUILT_IN) == {"P-Access-Token": "token456"}
+
+
+def test_no_entries_produces_no_headers():
+ assert build_custom_headers([], BUILT_IN) == {}