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"