Merge pull request #1695 from justadityaraj/feat/ntfy-custom-query-string

Add custom header and URL query string options to the ntfy publisher
This commit is contained in:
Jokob @NetAlertX authored and GitHub committed 2026-08-17 07:25:01 +10:00
commit 548d698b02
3 files changed
+158 -3

No files matched your search

+40
View File
@@ -6,3 +6,43 @@ A plugin to publish a notification via the NTFY gateway. Enable sending notifica
- Go to settings and fill in relevant details.
## Reverse proxy / tunnel authentication
If your ntfy instance sits behind a reverse proxy or tunnel that authenticates requests itself (Pangolin, Tailscale, Cloudflare Access, ...), the proxy usually expects its own credential *in addition to* any ntfy token. Two optional settings cover this.
Both are independent of `NTFY_TOKEN` / `NTFY_USER` / `NTFY_PASSWORD` — those still control authentication against ntfy itself and are unaffected.
### Custom header
Sends an extra HTTP header 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` |
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...` |
Both settings must be filled in — setting only one of them does nothing.
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.
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.
### URL query string
Appends a query string to the ntfy request URL, for proxies that authenticate via a query parameter instead of a header.
| Setting | Sample value |
|---|---|
| `NTFY_URL_QUERY_STRING` | `p_token=tokenId.tokenValue` |
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.
@@ -555,6 +555,78 @@
"string": "Enable TLS support. Disable if you are using a self-signed certificate."
}
]
},
{
"function": "URL_QUERY_STRING",
"type": {
"dataType": "string",
"elements": [
{ "elementType": "input", "elementOptions": [{ "type": "password" }], "transformers": [] }
]
},
"default_value": "",
"options": [],
"localized": ["name", "description"],
"name": [
{
"language_code": "en_us",
"string": "URL query string"
}
],
"description": [
{
"language_code": "en_us",
"string": "Optional query string appended to the ntfy request URL (e.g. <code>p_token=tokenId.tokenValue</code>). Useful when ntfy is behind a reverse proxy or tunnel (Pangolin, Tailscale, ...) that authenticates via a query parameter. Note: values in the URL may be recorded in proxy/server access logs, so for secrets prefer the custom header below. Leave empty to disable."
}
]
},
{
"function": "CUSTOMHEADER_NAME",
"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",
"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."
}
]
}
]
}
+46 -3
View File
@@ -2,6 +2,7 @@
import json
import os
import re
import sys
import requests
from base64 import b64encode
@@ -94,6 +95,11 @@ 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')
# 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('?')
# prepare request headers
headers = {
@@ -112,6 +118,17 @@ 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
# call NTFY service
try:
response = requests.post("{}/{}".format(
@@ -119,6 +136,7 @@ def send(html, text):
get_setting_value('NTFY_TOPIC')),
data = text,
headers = headers,
params = url_query_string if url_query_string != '' else None,
verify = verify_ssl,
timeout = get_setting_value('NTFY_RUN_TIMEOUT')
)
@@ -131,10 +149,35 @@ def send(html, text):
else:
response_text = json.dumps(response.text)
except requests.exceptions.RequestException as e:
mylog('none', [f'[{pluginName}] ⚠ ERROR: ', e])
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.')
response_text = e
mylog('none', [f'[{pluginName}] ⚠ ERROR: ', error_text])
response_text = error_text
return response_text, response_status_code
except requests.exceptions.RequestException as e:
# The exception message embeds the request URL, which may include a secret
# query string (e.g. a proxy token). Redact the query part before it is
# logged and persisted to the plugin result file / shown in the UI.
error_text = str(e)
if url_query_string != '':
error_text = re.sub(r'(\?)\S+', r'\1<redacted>', error_text)
mylog('none', [f'[{pluginName}] ⚠ ERROR: ', error_text])
response_text = error_text
return response_text, response_status_code