mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-09-12 22:25:49 -04:00
Merge pull request #1756 from justadityaraj/feat/ntfy-multiple-custom-headers
feat(ntfy): allow more than one custom header
This commit is contained in:
4 files changed
+320
-68
No files matched your search
@@ -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...` |
|
||||
|
||||
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. 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.
|
||||
|
||||
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.
|
||||
An entry is skipped, with a warning in the log, when:
|
||||
|
||||
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.
|
||||
- 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
|
||||
|
||||
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.
|
||||
@@ -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 <code>Name: Value</code>, for example <code>P-Access-Token-Id: abc123</code>. 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 <code>Authorization</code>), are skipped and logged. Leave empty to disable."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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("<b>html</b>", "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"
|
||||
Reference in new issue
Block a user