Skip to content
Merged

sync #1760

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,7 @@ README.md
CONTRIBUTING
FUNDING.yml
config/.gitignore
db/.gitignore
db/.gitignore
**/__pycache__
**/*.pyc
**/*.pyo
8 changes: 7 additions & 1 deletion front/js/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -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("']","");
Expand All @@ -1402,6 +1404,10 @@ $(document).ready(function() {
} else {
setCookie("UI_theme", "Light");
}
}

$(document).ready(function() {
callAfterAppInitialized(applyTheme);
});

// -----------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion server/models/notification_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down
43 changes: 28 additions & 15 deletions server/plugins/_publisher_ntfy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
88 changes: 58 additions & 30 deletions server/plugins/_publisher_ntfy/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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."
}
]
}
Expand Down
81 changes: 58 additions & 23 deletions server/plugins/_publisher_ntfy/ntfy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand All @@ -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('?')
Expand All @@ -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:
Expand All @@ -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])

Expand Down
Loading
Loading