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/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); }); // ----------------------------------------------------------- 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() diff --git a/server/plugins/_publisher_ntfy/README.md b/server/plugins/_publisher_ntfy/README.md index d86fa4d8f..46826208b 100755 --- a/server/plugins/_publisher_ntfy/README.md +++ b/server/plugins/_publisher_ntfy/README.md @@ -12,28 +12,41 @@ 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`: + +```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 +``` 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...` | + +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. -Both settings must be filled in — setting only one of them does nothing. +An entry is skipped, with a warning in the log, when: -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. +- 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 -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. +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 @@ -45,4 +58,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..7529de588 100755 --- a/server/plugins/_publisher_ntfy/ntfy.py +++ b/server/plugins/_publisher_ntfy/ntfy.py @@ -84,6 +84,52 @@ 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. + + 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.']) + 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 + + return custom_headers + + # ------------------------------------------------------------------------------- def send(html, text): @@ -95,8 +141,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 +163,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: @@ -149,17 +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_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.') - 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 new file mode 100644 index 000000000..d0e542ea6 --- /dev/null +++ b/test/plugins/test_ntfy_custom_headers.py @@ -0,0 +1,176 @@ +""" +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, patch + +# --------------------------------------------------------------------------- +# 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")) + +import ntfy # noqa: E402 +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) == {} + + +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"