From 4f604e4e2b6f55fe51a2bb7d3101827847b01fd2 Mon Sep 17 00:00:00 2001 From: Aditya Raj Singh Date: Fri, 21 Aug 2026 00:12:42 +0530 Subject: [PATCH] feat(ntfy): allow more than one custom header The custom header added in #1695 was a single name/value pair, which is enough for a proxy that authenticates with one token but not for Pangolin, which expects both P-Access-Token-Id and P-Access-Token. NTFY_CUSTOMHEADER_NAME and NTFY_CUSTOMHEADER_VALUE are replaced by a single list setting, NTFY_CUSTOM_HEADERS, holding one "Name: Value" entry per header. The list widget is the same one the other list settings use. Only the first colon separates the name from the value, so values may contain colons. An entry is skipped and logged when it is malformed, when the name repeats, or when it collides with a header the plugin already set, so a custom header still cannot clobber the ntfy credentials. Values are never written to the log, since they are usually secrets. That also applies to the invalid-header error, which now names the headers that were applied without quoting any of them. --- server/plugins/_publisher_ntfy/README.md | 38 ++++--- server/plugins/_publisher_ntfy/config.json | 88 +++++++++++------ server/plugins/_publisher_ntfy/ntfy.py | 54 +++++++--- test/plugins/test_ntfy_custom_headers.py | 110 +++++++++++++++++++++ 4 files changed, 229 insertions(+), 61 deletions(-) create mode 100644 test/plugins/test_ntfy_custom_headers.py 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) == {}