From 4f604e4e2b6f55fe51a2bb7d3101827847b01fd2 Mon Sep 17 00:00:00 2001 From: Aditya Raj Singh Date: Fri, 21 Aug 2026 00:12:42 +0530 Subject: [PATCH 1/6] 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 d86fa4d8f..8a99a9e79 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 7b40ed858..66ffc4f97 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", - "elements": [ - { "elementType": "input", "elementOptions": [], "transformers": [] } - ] - }, - "default_value": "", - "options": [], - "localized": ["name", "description"], - "name": [ - { - "language_code": "en_us", - "string": "Custom header name" - } - ], - "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", + "dataType": "array", "elements": [ - { "elementType": "input", "elementOptions": [{ "type": "password" }], "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 value" + "string": "Custom headers" } ], "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 f9a9b720a..612532ec1 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 000000000..cdfab9ac4 --- /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) == {} From baed8d458fc764748c6c67a80bc093520a78951f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:39:19 +0000 Subject: [PATCH 2/6] Initial plan From 05bdf1985e872ce0514f8c66cc91efa39405570a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:09:27 +0000 Subject: [PATCH 3/6] fix: lazy-import write_notification to prevent partial module load in tests Co-authored-by: jokob-sk <96159884+jokob-sk@users.noreply.github.com> --- .dockerignore | 5 ++++- server/models/notification_instance.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.dockerignore b/.dockerignore index 8b4efda47..dc89bfcd8 100755 --- a/.dockerignore +++ b/.dockerignore @@ -12,4 +12,7 @@ README.md CONTRIBUTING FUNDING.yml config/.gitignore -db/.gitignore \ No newline at end of file +db/.gitignore +**/__pycache__ +**/*.pyc +**/*.pyo \ No newline at end of file diff --git a/server/models/notification_instance.py b/server/models/notification_instance.py index 45327659f..0c9fbf5e8 100755 --- a/server/models/notification_instance.py +++ b/server/models/notification_instance.py @@ -18,7 +18,6 @@ get_setting_value, getBuildTimeStampAndVersion, ) -from messaging.in_app import write_notification from messaging.notification_sections import SECTION_ORDER from utils.datetime_utils import timeNowUTC, timeNowTZ, get_timezone_offset @@ -162,6 +161,7 @@ def create(self, JSON, Extra=""): self.HTML = final_html # Notify frontend + from messaging.in_app import write_notification # lazy import to avoid Flask dep at module level write_notification(f"Report:{self.GUID}", "alert", self.DateTimeCreated) self.upsert() From 9fedd49e4a24e2d16689c9751e1102d0d39b2975 Mon Sep 17 00:00:00 2001 From: Valiante <13119903+Valiante@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:44:21 +0100 Subject: [PATCH 4/6] Fix: theme reverts to Light after header reload (#943) Theme was applied on raw $(document).ready, which fires before the async cacheSettings() call has necessarily populated the settings cache. clearCache() (the header reload button) wipes localStorage immediately before reloading, so that reload could read an empty cache and hard-override the theme to Light via setCookie, even with Dark or System configured. Gated the theme application on callAfterAppInitialized(), which is already used elsewhere for this class of race. Fixes #943 --- front/js/common.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/front/js/common.js b/front/js/common.js index 9538a04bb..13cc8340a 100755 --- a/front/js/common.js +++ b/front/js/common.js @@ -1385,7 +1385,9 @@ function getDevicesList() // ----------------------------------------------------------------------------- // apply theme -$(document).ready(function() { +// Gated on callAfterAppInitialized() rather than raw $(document).ready, so this +// can't race clearCache()'s reload and force-override the theme to Light. See #943. +function applyTheme() { let theme = getSetting("UI_theme"); if (theme) { theme = theme.replace("['","").replace("']",""); @@ -1402,6 +1404,10 @@ $(document).ready(function() { } else { setCookie("UI_theme", "Light"); } +} + +$(document).ready(function() { + callAfterAppInitialized(applyTheme); }); // ----------------------------------------------------------- From 2d4e2d5f05541c230d6a743ac9afd5ca19f001a3 Mon Sep 17 00:00:00 2001 From: Aditya Raj Singh Date: Fri, 21 Aug 2026 23:17:28 +0530 Subject: [PATCH 5/6] fix(ntfy): skip custom headers that cannot be sent A custom header carrying a non-ASCII character raised UnicodeEncodeError from inside http.client. That is a ValueError, not a RequestException, so it escaped both handlers in send() and took the whole publisher down - every notification lost because of one typo in one header. build_custom_headers now rejects newlines and non-ASCII the same way it already rejects malformed and colliding entries: warn, skip that entry, keep the rest. UnicodeEncodeError is still caught at the request, as a backstop for the plugin's own headers, since REPORT_DASHBOARD_URL feeds one of them. Verified against real requests: before, send() raised UnicodeEncodeError; after, the bad header is dropped and the notification is still posted. --- server/plugins/_publisher_ntfy/ntfy.py | 37 ++++++++----- test/plugins/test_ntfy_custom_headers.py | 68 +++++++++++++++++++++++- 2 files changed, 92 insertions(+), 13 deletions(-) diff --git a/server/plugins/_publisher_ntfy/ntfy.py b/server/plugins/_publisher_ntfy/ntfy.py index 612532ec1..7529de588 100755 --- a/server/plugins/_publisher_ntfy/ntfy.py +++ b/server/plugins/_publisher_ntfy/ntfy.py @@ -84,6 +84,23 @@ def check_config(): return True +# ------------------------------------------------------------------------------- +def header_is_sendable(name, value): + """Whether requests can put this header on the wire without raising. + + A newline raises InvalidHeader, and a non-ASCII character raises UnicodeEncodeError + from deep inside http.client, which is not a RequestException and so escapes the + handling in send(). + """ + + try: + f'{name}{value}'.encode('ascii') + except UnicodeEncodeError: + return False + + return '\r' not in f'{name}{value}' and '\n' not in f'{name}{value}' + + # ------------------------------------------------------------------------------- def build_custom_headers(entries, reserved_headers): """Turn "Name: Value" setting entries into a header dict. @@ -104,6 +121,8 @@ def build_custom_headers(entries, reserved_headers): 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.']) + elif not header_is_sendable(name, value): + mylog('none', [f'[{pluginName}] ⚠ Custom header "{name}" contains a newline or a non-ASCII character, which is not valid in an HTTP header; skipping it.']) else: taken.add(name.lower()) custom_headers[name] = value @@ -170,18 +189,12 @@ def send(html, text): else: response_text = json.dumps(response.text) - except requests.exceptions.InvalidHeader: - # 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_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.') + except (requests.exceptions.InvalidHeader, UnicodeEncodeError): + # requests echoes the offending header value in InvalidHeader's message, so that + # message is never logged - it would leak a configured secret. Custom headers are + # already filtered by build_custom_headers, so this is one of the plugin's own. + error_text = ('A request header contains characters that are not allowed in an HTTP header. Check ' + 'REPORT_DASHBOARD_URL and the NTFY_* settings for stray newlines or non-ASCII characters.') mylog('none', [f'[{pluginName}] ⚠ ERROR: ', error_text]) diff --git a/test/plugins/test_ntfy_custom_headers.py b/test/plugins/test_ntfy_custom_headers.py index cdfab9ac4..d0e542ea6 100644 --- a/test/plugins/test_ntfy_custom_headers.py +++ b/test/plugins/test_ntfy_custom_headers.py @@ -12,7 +12,7 @@ import sys import tempfile import types -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch # --------------------------------------------------------------------------- # Stub NetAlertX-specific modules so tests can run outside the container. @@ -54,6 +54,7 @@ def _stub(name: str, **attrs): sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "server", "plugins", "_publisher_ntfy")) +import ntfy # noqa: E402 from ntfy import build_custom_headers # noqa: E402 BUILT_IN = {"Title": "NetAlertX Notification", "Authorization": "Bearer secret"} @@ -108,3 +109,68 @@ def test_a_bad_entry_does_not_discard_the_good_ones(): def test_no_entries_produces_no_headers(): assert build_custom_headers([], BUILT_IN) == {} + + +def test_skips_values_containing_a_newline(): + assert build_custom_headers(["X-Token: abc\ndef"], {}) == {} + + +def test_skips_values_containing_a_carriage_return(): + assert build_custom_headers(["X-Token: abc\r\nInjected: 1"], {}) == {} + + +def test_skips_non_ascii_names_and_values(): + assert build_custom_headers(["X-Token: caf\u00e9", "X-T\u00e9st: abc"], {}) == {} + + +def test_an_unsendable_entry_does_not_discard_the_good_ones(): + entries = ["X-Bad: abc\ndef", "P-Access-Token: token456"] + + assert build_custom_headers(entries, {}) == {"P-Access-Token": "token456"} + + +SEND_SETTINGS = { + "NTFY_HOST": "https://ntfy.example.com", + "NTFY_TOPIC": "netalertx", + "NTFY_TOKEN": "tk_secret", + "NTFY_USER": "", + "NTFY_PASSWORD": "", + "NTFY_VERIFY_SSL": True, + "NTFY_URL_QUERY_STRING": "", + "NTFY_PRIORITY": "default", + "NTFY_RUN_TIMEOUT": 10, + "REPORT_DASHBOARD_URL": "http://localhost:20211", +} + + +def send_with(custom_headers): + settings = dict(SEND_SETTINGS, NTFY_CUSTOM_HEADERS=custom_headers) + response = MagicMock(status_code=200, text="ok") + + with patch.object(ntfy, "get_setting_value", lambda key: settings[key]), \ + patch.object(ntfy.requests, "post", return_value=response) as post: + ntfy.send("html", "text") + + return post.call_args.kwargs["headers"] + + +def test_send_passes_accepted_custom_headers_to_requests(): + headers = send_with(["P-Access-Token-Id: id123", "P-Access-Token: token456"]) + + assert headers["P-Access-Token-Id"] == "id123" + assert headers["P-Access-Token"] == "token456" + + +def test_send_keeps_plugin_managed_headers_intact(): + headers = send_with(["Authorization: Bearer mine", "P-Access-Token: token456"]) + + assert headers["Authorization"] == "Bearer tk_secret" + assert headers["Title"] == "NetAlertX Notification" + assert headers["P-Access-Token"] == "token456" + + +def test_send_drops_an_unsendable_custom_header_but_still_posts(): + headers = send_with(["X-Bad: abc\ndef", "P-Access-Token: token456"]) + + assert "X-Bad" not in headers + assert headers["P-Access-Token"] == "token456" From 63b20d060e5d47b83869da9652b230bda508d134 Mon Sep 17 00:00:00 2001 From: Aditya Raj Singh Date: Fri, 21 Aug 2026 23:18:09 +0530 Subject: [PATCH 6/6] docs(ntfy): document per-entry header skipping The whitespace note contradicted itself: it said surrounding whitespace is trimmed and then that leading or trailing whitespace is invalid. The parser strips, so trimming is what actually happens. Also lists the new non-ASCII/newline skip reason and states that skipping is per entry, and adds the language identifier markdownlint wants on the two fenced blocks (MD040). --- server/plugins/_publisher_ntfy/README.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/server/plugins/_publisher_ntfy/README.md b/server/plugins/_publisher_ntfy/README.md index 8a99a9e79..46826208b 100755 --- a/server/plugins/_publisher_ntfy/README.md +++ b/server/plugins/_publisher_ntfy/README.md @@ -18,13 +18,13 @@ Sends extra HTTP headers with the request. Prefer this over the query string for `NTFY_CUSTOM_HEADERS` is a list. Add one entry per header, in the format `Name: Value`: -``` +```text X-Proxy-Token: p_abc123.def456ghi789 ``` Proxies that need more than one header work the same way — add a second entry. Pangolin, for example: -``` +```text P-Access-Token-Id: abc123 P-Access-Token: def456ghi789 ``` @@ -37,11 +37,16 @@ Other common examples: | Cloudflare Access | `CF-Access-Client-Id: abc123.access` | | Generic bearer gateway | `X-Auth-Token: eyJhbGciOi...` | -The first `:` separates the name from the value, so a value may itself contain colons. Surrounding whitespace is trimmed. +The first `:` separates the name from the value, so a value may itself contain colons. Whitespace around the name and around the value is trimmed, so a stray space or a trailing newline pasted in from a text file is harmless. + +An entry is skipped, with a warning in the log, when: -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. +- it is not in `Name: Value` form, or either side is empty +- the same name is listed twice +- 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) +- after trimming, the name or the value still contains a newline or a non-ASCII character, neither of which can be sent in an HTTP header -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. +Skipping applies to that entry only — the remaining headers are still sent and the notification still goes out. The collision 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