1 Commits
7 changed files with 273 additions and 61 deletions

No files matched your search

+39
View File
@@ -7,6 +7,45 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
--- ---
## [0.9.2] — 2026-10-28
Fixes from a code and security review. None of them change how containers are
monitored or recovered.
### Fixed
- **A bad value saved from Settings can no longer stop Inspector from starting.**
The forms now reject invalid times (maintenance start/end), unknown log levels and
out-of-range numbers (for example `interval_seconds: 0`, which made the loop spin).
If `config.yml` already has such a value, Inspector starts with the default and
logs a warning instead of crashing in a restart loop.
- **The dashboard password can no longer be cleared.** Saving an empty password (or
username) is refused, and an empty password never signs anyone in. Before, clearing
it left the running dashboard open to anyone.
- **Passwords with non-ASCII characters (é, ü, £ …) now work.** Before, every login
attempt with one crashed.
- The Events page no longer errors for a moment after a container is removed.
- The HTTP check hint no longer suggests `302` as an expected status; redirects are
followed, so the final page's code is what counts.
### Security
- **Sign-in throttling:** after 5 failed sign-ins (form or Basic Auth) from one address
within 5 minutes, that address is refused for the rest of the window.
- **Changing the dashboard username or password signs out existing sessions.**
Expired sessions are cleaned up.
- Request bodies are limited to 1 MB; an invalid `Content-Length` gets a 400.
- The Gotify token is sent in the `X-Gotify-Key` header instead of the URL, so it no
longer appears in Gotify or reverse-proxy access logs.
- Portainer token environment variables must be named `PORTAINER_…` (a name already in
your `config.yml` keeps working), so Test connection can't be used to send other
container secrets to an arbitrary URL.
- Single quotes are escaped in HTML, and the delete confirmations build their text safely.
- Two settings saves at the same moment can no longer overwrite each other.
- `requests` updated to 2.32.5 (CVE-2024-47081).
---
## [0.9.1] — 2026-10-28 ## [0.9.1] — 2026-10-28
### Fixed ### Fixed
+2 -1
View File
@@ -91,7 +91,8 @@ and that socket is effectively root on your server. So:
- Keep the dashboard on your home network, or put it behind a VPN or a reverse - Keep the dashboard on your home network, or put it behind a VPN or a reverse
proxy with authentication. Don't expose port 8080 to the internet. proxy with authentication. Don't expose port 8080 to the internet.
- Use a real dashboard password. - Use a real dashboard password. Repeated failed sign-ins from one address are
blocked for a few minutes.
- Inspector itself runs as an unprivileged user; it starts as root only to detect - Inspector itself runs as an unprivileged user; it starts as root only to detect
the socket's permissions and fix ownership of its own folders. the socket's permissions and fix ownership of its own folders.
+1 -1
View File
@@ -1,5 +1,5 @@
docker==7.1.0 docker==7.1.0
PyYAML==6.0.2 PyYAML==6.0.2
requests==2.32.3 requests==2.32.5
colorlog==6.9.0 colorlog==6.9.0
ruamel.yaml==0.18.6 ruamel.yaml==0.18.6
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.9.1" __version__ = "0.9.2"
+34 -4
View File
@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
import io import io
import logging
import os import os
import shutil import shutil
import threading import threading
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import time
from pathlib import Path from pathlib import Path
import yaml import yaml
@@ -23,6 +25,30 @@ VALID_STATUSES = {
"UNREACHABLE", "UNREACHABLE",
} }
log = logging.getLogger("inspector.config")
# Names logging.setLevel() accepts; anything else would crash at startup.
VALID_LOG_LEVELS = {"CRITICAL", "FATAL", "ERROR", "WARN", "WARNING", "INFO", "DEBUG", "NOTSET"}
def valid_hhmm(value: str) -> bool:
"""True if `value` parses the way the maintenance tracker parses it ("HH:MM")."""
try:
hour, minute = str(value).split(":")
time(int(hour), int(minute))
return True
except (TypeError, ValueError):
return False
def _startup_safe(value, default, ok: bool, key: str):
"""Return `value`, or `default` with a warning when it would crash Inspector."""
if ok:
return value
log.warning("config: invalid %s %r; using %r instead", key, value, default)
return default
VALID_RECOVERY_ACTIONS = { VALID_RECOVERY_ACTIONS = {
"restart_container", "restart_container",
"start_container", "start_container",
@@ -190,9 +216,11 @@ def build_config_from_raw(raw: dict) -> AppConfig:
to validate an edited raw dict before writing it back to disk. to validate an edited raw dict before writing it back to disk.
""" """
insp_raw = raw.get("inspector", {}) or {} insp_raw = raw.get("inspector", {}) or {}
interval = int(insp_raw.get("interval_seconds", 60))
log_level = str(insp_raw.get("log_level", "INFO")).upper()
inspector = InspectorConfig( inspector = InspectorConfig(
interval_seconds=int(insp_raw.get("interval_seconds", 60)), interval_seconds=_startup_safe(interval, 1, interval >= 1, "inspector.interval_seconds"),
log_level=str(insp_raw.get("log_level", "INFO")).upper(), log_level=_startup_safe(log_level, "INFO", log_level in VALID_LOG_LEVELS, "inspector.log_level"),
dry_run=bool(insp_raw.get("dry_run", False)), dry_run=bool(insp_raw.get("dry_run", False)),
log_dir=str(insp_raw.get("log_dir", "/app/logs")), log_dir=str(insp_raw.get("log_dir", "/app/logs")),
log_filename=str(insp_raw.get("log_filename", "inspector.log")), log_filename=str(insp_raw.get("log_filename", "inspector.log")),
@@ -237,10 +265,12 @@ def build_config_from_raw(raw: dict) -> AppConfig:
) )
maint_raw = raw.get("maintenance") or {} maint_raw = raw.get("maintenance") or {}
maint_start = str(maint_raw.get("start", "00:00"))
maint_end = str(maint_raw.get("end", "06:00"))
maintenance = MaintenanceConfig( maintenance = MaintenanceConfig(
enabled=bool(maint_raw.get("enabled", False)), enabled=bool(maint_raw.get("enabled", False)),
start=str(maint_raw.get("start", "00:00")), start=_startup_safe(maint_start, "00:00", valid_hhmm(maint_start), "maintenance.start"),
end=str(maint_raw.get("end", "06:00")), end=_startup_safe(maint_end, "06:00", valid_hhmm(maint_end), "maintenance.end"),
dir=str(maint_raw.get("dir", "/app/data/maintenance")), dir=str(maint_raw.get("dir", "/app/data/maintenance")),
max_hours=int(maint_raw.get("max_hours", 8)), max_hours=int(maint_raw.get("max_hours", 8)),
) )
+194 -53
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import functools
import hashlib
import json import json
import logging import logging
import os import os
@@ -21,9 +23,11 @@ from .config import (
AppConfig, AppConfig,
ConfigRef, ConfigRef,
ContainerConfig, ContainerConfig,
VALID_LOG_LEVELS,
HttpCheckConfig, HttpCheckConfig,
UptimeKumaMonitorConfig, UptimeKumaMonitorConfig,
build_config_from_raw, build_config_from_raw,
valid_hhmm,
write_config_raw, write_config_raw,
) )
from .formatting import PACIFIC, format_timestamp from .formatting import PACIFIC, format_timestamp
@@ -36,6 +40,9 @@ from .state import ContainerState, utcnow
log = logging.getLogger("inspector.dashboard") log = logging.getLogger("inspector.dashboard")
_SESSION_MAX_AGE = 86400 # seconds a login cookie stays valid _SESSION_MAX_AGE = 86400 # seconds a login cookie stays valid
_MAX_BODY_BYTES = 1024 * 1024 # largest request body accepted
_LOGIN_MAX_FAILURES = 5 # failed logins per client before it is throttled
_LOGIN_FAILURE_WINDOW = 300 # seconds a failed login counts toward the limit
_UNCHANGED_SECRET_PLACEHOLDER = "__UNCHANGED__" # UI sentinel: keep existing secret value _UNCHANGED_SECRET_PLACEHOLDER = "__UNCHANGED__" # UI sentinel: keep existing secret value
# Config keys that require an Inspector container restart to take effect. # Config keys that require an Inspector container restart to take effect.
@@ -84,7 +91,13 @@ def _color_map(
states: dict[str, ContainerState], states: dict[str, ContainerState],
containers: dict[str, ContainerConfig], containers: dict[str, ContainerConfig],
) -> dict[str, str]: ) -> dict[str, str]:
return {name: _resolve_color(containers[name], i) for i, name in enumerate(states)} # list() snapshot + skip: the inspector loop adds/removes states from another
# thread, and a container removed in the UI keeps its state until the next check.
return {
name: _resolve_color(containers[name], i)
for i, name in enumerate(list(states))
if name in containers
}
def _compute_phase( def _compute_phase(
@@ -246,9 +259,31 @@ def _escape(value: object) -> str:
.replace("<", "&lt;") .replace("<", "&lt;")
.replace(">", "&gt;") .replace(">", "&gt;")
.replace('"', "&quot;") .replace('"', "&quot;")
.replace("'", "&#x27;")
) )
def _js_string_attr(text: str) -> str:
"""A JS string literal safe to put inside a double-quoted HTML attribute."""
return _escape(json.dumps(text))
def _creds_match(username: str, password: str, cfg) -> bool:
"""Constant-time credential check. Never accepts an empty configured
password, and works for non-ASCII input (compare_digest on str doesn't)."""
if not cfg.password:
return False
user_ok = secrets.compare_digest(username.encode("utf-8"), cfg.username.encode("utf-8"))
pass_ok = secrets.compare_digest(password.encode("utf-8"), cfg.password.encode("utf-8"))
return user_ok and pass_ok
def _cred_fingerprint(cfg) -> str:
"""Changes whenever the dashboard username or password changes, which
invalidates every session created with the old credentials."""
return hashlib.sha256(f"{cfg.username}\0{cfg.password}".encode("utf-8")).hexdigest()
_STATUS_BADGE_CLASS = { _STATUS_BADGE_CLASS = {
HEALTHY: "healthy", HEALTHY: "healthy",
UNHEALTHY: "unhealthy", UNHEALTHY: "unhealthy",
@@ -1146,10 +1181,13 @@ def _render_event_item(ev: dict, container_colors: dict[str, str] | None) -> str
) )
def _render_login_page(error: bool = False) -> bytes: def _render_login_page(error: bool = False, throttled: bool = False) -> bytes:
error_html = ( if throttled:
'<div class="login-error">Incorrect username or password.</div>\n' if error else "" error_html = '<div class="login-error">Too many failed sign-ins. Try again in a few minutes.</div>\n'
) elif error:
error_html = '<div class="login-error">Incorrect username or password.</div>\n'
else:
error_html = ""
head = ( head = (
"<!DOCTYPE html>\n" "<!DOCTYPE html>\n"
'<html lang="en">\n<head>\n' '<html lang="en">\n<head>\n'
@@ -1243,6 +1281,14 @@ def _form_float(form: dict, key: str, default: float = 0.0) -> float:
return default return default
def _int_in(form: dict, key: str, default: int, lo: int, hi: int) -> int:
"""Read an int form field and reject values outside lo..hi."""
value = _form_int(form, key, default)
if not lo <= value <= hi:
raise _SettingsError(f"{key} must be between {lo} and {hi} (got {value})")
return value
def _set_or_drop(section: dict, key: str, value) -> None: def _set_or_drop(section: dict, key: str, value) -> None:
"""Store value, or remove the key when it is blank/False so config.yml """Store value, or remove the key when it is blank/False so config.yml
doesn't fill up with empty defaults.""" doesn't fill up with empty defaults."""
@@ -1270,8 +1316,10 @@ def _apply_container_advanced(raw: dict, name: str, cfg: dict, form: dict) -> No
hc["enabled"] = hc_enabled hc["enabled"] = hc_enabled
hc["url"] = hc_url hc["url"] = hc_url
timeout = _form_float(form, "http_check_timeout_seconds", 5.0) timeout = _form_float(form, "http_check_timeout_seconds", 5.0)
if not 0.5 <= timeout <= 120:
raise _SettingsError(f"HTTP check timeout must be between 0.5 and 120 seconds (got {timeout:g})")
hc["timeout_seconds"] = int(timeout) if timeout.is_integer() else timeout hc["timeout_seconds"] = int(timeout) if timeout.is_integer() else timeout
hc["expected_status"] = _form_int(form, "http_check_expected_status", 200) hc["expected_status"] = _int_in(form, "http_check_expected_status", 200, 100, 599)
cfg["http_check"] = hc cfg["http_check"] = hc
else: else:
cfg.pop("http_check", None) cfg.pop("http_check", None)
@@ -1314,6 +1362,15 @@ def _apply_container_advanced(raw: dict, name: str, cfg: dict, form: dict) -> No
cfg.pop("portainer_stack_name", None) cfg.pop("portainer_stack_name", None)
_PORTAINER_ENV_NAME_RE = re.compile(r"^PORTAINER_[A-Z0-9_]+$")
def _token_env_allowed(name: str, saved: str = "") -> bool:
"""Only PORTAINER_* variables (or the name already in config.yml) may be read
as a token, so the dashboard can't be used to send other container secrets out."""
return bool(_PORTAINER_ENV_NAME_RE.match(name)) or (bool(saved) and name == saved)
def _apply_portainer_env(raw: dict, form: dict) -> None: def _apply_portainer_env(raw: dict, form: dict) -> None:
"""Add, edit, rename or delete one integrations.portainer.environments entry.""" """Add, edit, rename or delete one integrations.portainer.environments entry."""
pt = raw.setdefault("integrations", {}).setdefault("portainer", {}) pt = raw.setdefault("integrations", {}).setdefault("portainer", {})
@@ -1350,7 +1407,7 @@ def _apply_portainer_env(raw: dict, form: dict) -> None:
env = envs.get(original) or {} env = envs.get(original) or {}
env["base_url"] = base_url env["base_url"] = base_url
env["endpoint_id"] = _form_int(form, "endpoint_id", 1) env["endpoint_id"] = _int_in(form, "endpoint_id", 1, 1, 1_000_000)
env["verify_ssl"] = _form_bool(form, "verify_ssl") env["verify_ssl"] = _form_bool(form, "verify_ssl")
if _form_str(form, "token_source", "env") == "inline": if _form_str(form, "token_source", "env") == "inline":
_apply_secret(form, env, "api_token", "api_token") _apply_secret(form, env, "api_token", "api_token")
@@ -1361,6 +1418,8 @@ def _apply_portainer_env(raw: dict, form: dict) -> None:
token_env = _form_str(form, "api_token_env", "").strip() token_env = _form_str(form, "api_token_env", "").strip()
if not token_env: if not token_env:
raise _SettingsError("enter the name of the environment variable holding the API token") raise _SettingsError("enter the name of the environment variable holding the API token")
if not _token_env_allowed(token_env, env.get("api_token_env", "")):
raise _SettingsError("the environment variable name must start with PORTAINER_ (e.g. PORTAINER_API_TOKEN)")
env["api_token_env"] = token_env env["api_token_env"] = token_env
env.pop("api_token", None) env.pop("api_token", None)
@@ -1376,57 +1435,81 @@ def _apply_portainer_env(raw: dict, form: dict) -> None:
pt["environments"] = envs pt["environments"] = envs
_SAVE_LOCK = threading.Lock()
def _serialized(fn):
"""Run config read-modify-write functions one at a time, so two saves at
the same moment can't overwrite each other's changes."""
@functools.wraps(fn)
def wrapper(*args, **kwargs):
with _SAVE_LOCK:
return fn(*args, **kwargs)
return wrapper
@_serialized
def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> None: def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> None:
"""Validate the form, merge into the raw config, write atomically, swap ref.""" """Validate the form, merge into the raw config, write atomically, swap ref."""
raw = config_ref.get_raw() raw = config_ref.get_raw()
if section == "inspector": if section == "inspector":
insp = raw.setdefault("inspector", {}) insp = raw.setdefault("inspector", {})
insp["interval_seconds"] = _form_int(form, "interval_seconds", 60) insp["interval_seconds"] = _int_in(form, "interval_seconds", 60, 1, 86400)
insp["log_level"] = _form_str(form, "log_level", "INFO").upper() level = _form_str(form, "log_level", "INFO").strip().upper()
if level not in VALID_LOG_LEVELS:
raise _SettingsError("log_level must be one of DEBUG, INFO, WARNING, ERROR, CRITICAL")
insp["log_level"] = level
insp["dry_run"] = _form_bool(form, "dry_run") insp["dry_run"] = _form_bool(form, "dry_run")
insp["heartbeat"] = _form_bool(form, "heartbeat") insp["heartbeat"] = _form_bool(form, "heartbeat")
insp["log_retention_days"] = _form_int(form, "log_retention_days", 30) insp["log_retention_days"] = _int_in(form, "log_retention_days", 30, 1, 3650)
elif section == "gotify": elif section == "gotify":
notif = raw.setdefault("notifications", {}) notif = raw.setdefault("notifications", {})
gotify = notif.setdefault("gotify", {}) gotify = notif.setdefault("gotify", {})
gotify["enabled"] = _form_bool(form, "enabled") gotify["enabled"] = _form_bool(form, "enabled")
gotify["url"] = _form_str(form, "url", "") gotify["url"] = _form_str(form, "url", "")
_apply_secret(form, gotify, "token", "token") _apply_secret(form, gotify, "token", "token")
gotify["priority"] = _form_int(form, "priority", 5) gotify["priority"] = _int_in(form, "priority", 5, 0, 10)
elif section == "pushover": elif section == "pushover":
notif = raw.setdefault("notifications", {}) notif = raw.setdefault("notifications", {})
pushover = notif.setdefault("pushover", {}) pushover = notif.setdefault("pushover", {})
pushover["enabled"] = _form_bool(form, "enabled") pushover["enabled"] = _form_bool(form, "enabled")
_apply_secret(form, pushover, "token", "token") _apply_secret(form, pushover, "token", "token")
_apply_secret(form, pushover, "user_key", "user_key") _apply_secret(form, pushover, "user_key", "user_key")
pushover["priority"] = _form_int(form, "priority", 5) pushover["priority"] = _int_in(form, "priority", 5, 0, 10)
pushover["device"] = _form_str(form, "device", "") pushover["device"] = _form_str(form, "device", "")
pushover["sound"] = _form_str(form, "sound", "") pushover["sound"] = _form_str(form, "sound", "")
elif section == "dashboard": elif section == "dashboard":
dash = raw.setdefault("dashboard", {}) dash = raw.setdefault("dashboard", {})
dash["enabled"] = _form_bool(form, "enabled") dash["enabled"] = _form_bool(form, "enabled")
dash["username"] = _form_str(form, "username", "admin") dash["username"] = _form_str(form, "username", "admin").strip()
if not dash["username"]:
raise _SettingsError("the dashboard username can't be empty")
_apply_secret(form, dash, "password", "password") _apply_secret(form, dash, "password", "password")
if not dash.get("password"):
raise _SettingsError("the dashboard password can't be empty")
elif section == "database": elif section == "database":
db = raw.setdefault("database", {}) db = raw.setdefault("database", {})
db["enabled"] = _form_bool(form, "enabled") db["enabled"] = _form_bool(form, "enabled")
db["retention_days"] = _form_int(form, "retention_days", 30) db["retention_days"] = _int_in(form, "retention_days", 30, 1, 3650)
elif section == "health_summary": elif section == "health_summary":
hs = raw.setdefault("health_summary", {}) hs = raw.setdefault("health_summary", {})
hs["enabled"] = _form_bool(form, "enabled") hs["enabled"] = _form_bool(form, "enabled")
hs["interval_hours"] = _form_int(form, "interval_hours", 24) hs["interval_hours"] = _int_in(form, "interval_hours", 24, 1, 8760)
hs["priority"] = _form_int(form, "priority", 3) hs["priority"] = _int_in(form, "priority", 3, 0, 10)
elif section == "maintenance": elif section == "maintenance":
m = raw.setdefault("maintenance", {}) m = raw.setdefault("maintenance", {})
m["enabled"] = _form_bool(form, "enabled") m["enabled"] = _form_bool(form, "enabled")
m["start"] = _form_str(form, "start", "00:00") for key, default in (("start", "00:00"), ("end", "06:00")):
m["end"] = _form_str(form, "end", "06:00") value = _form_str(form, key, default).strip()
m["max_hours"] = _form_int(form, "max_hours", 8) if not valid_hhmm(value):
raise _SettingsError(f"{key} must be a 24-hour time like 02:30 (got {value!r})")
m[key] = value
m["max_hours"] = _int_in(form, "max_hours", 8, 1, 720)
elif section == "notification_mute": elif section == "notification_mute":
nm = raw.setdefault("notification_mute", {}) nm = raw.setdefault("notification_mute", {})
nm["enabled"] = _form_bool(form, "enabled") nm["enabled"] = _form_bool(form, "enabled")
nm["default_hours"] = _form_int(form, "default_hours", 12) nm["default_hours"] = _int_in(form, "default_hours", 12, 1, 720)
elif section.startswith("container:"): elif section.startswith("container:"):
name = section.split(":", 1)[1] name = section.split(":", 1)[1]
containers = raw.get("containers") or {} containers = raw.get("containers") or {}
@@ -1434,10 +1517,10 @@ def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> N
raise _SettingsError(f"unknown container {name!r}") raise _SettingsError(f"unknown container {name!r}")
cfg = containers[name] or {} cfg = containers[name] or {}
cfg["enabled"] = _form_bool(form, "enabled") cfg["enabled"] = _form_bool(form, "enabled")
cfg["max_error_count"] = _form_int(form, "max_error_count", 5) cfg["max_error_count"] = _int_in(form, "max_error_count", 5, 1, 1000)
cfg["cooldown_seconds"] = _form_int(form, "cooldown_seconds", 300) cfg["cooldown_seconds"] = _int_in(form, "cooldown_seconds", 300, 0, 86400)
cfg["grace_period_seconds"] = _form_int(form, "grace_period_seconds", 120) cfg["grace_period_seconds"] = _int_in(form, "grace_period_seconds", 120, 0, 86400)
cfg["max_restarts_per_hour"] = _form_int(form, "max_restarts_per_hour", 3) cfg["max_restarts_per_hour"] = _int_in(form, "max_restarts_per_hour", 3, 1, 100)
_set_or_drop(cfg, "color", _form_str(form, "color", "").strip()) _set_or_drop(cfg, "color", _form_str(form, "color", "").strip())
if form.get("restart_on_present"): # only our own form carries this field if form.get("restart_on_present"): # only our own form carries this field
_set_or_drop(cfg, "maintenance_window", _form_bool(form, "maintenance_window")) _set_or_drop(cfg, "maintenance_window", _form_bool(form, "maintenance_window"))
@@ -1467,8 +1550,8 @@ def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> N
if default_env and default_env not in (pt.get("environments") or {}): if default_env and default_env not in (pt.get("environments") or {}):
raise _SettingsError(f"unknown Portainer environment {default_env!r}") raise _SettingsError(f"unknown Portainer environment {default_env!r}")
_set_or_drop(pt, "default_environment", default_env) _set_or_drop(pt, "default_environment", default_env)
pt["stack_redeploy_cooldown_seconds"] = _form_int(form, "stack_redeploy_cooldown_seconds", 300) pt["stack_redeploy_cooldown_seconds"] = _int_in(form, "stack_redeploy_cooldown_seconds", 300, 0, 86400)
pt["post_redeploy_wait_seconds"] = _form_int(form, "post_redeploy_wait_seconds", 90) pt["post_redeploy_wait_seconds"] = _int_in(form, "post_redeploy_wait_seconds", 90, 0, 3600)
elif section == "portainer_env": elif section == "portainer_env":
_apply_portainer_env(raw, form) _apply_portainer_env(raw, form)
else: else:
@@ -1492,6 +1575,7 @@ def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> N
_CONTAINER_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$") _CONTAINER_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$")
@_serialized
def _apply_container_create(config_ref: ConfigRef, form: dict) -> str: def _apply_container_create(config_ref: ConfigRef, form: dict) -> str:
"""Insert a new container under raw['containers']. Returns the new name.""" """Insert a new container under raw['containers']. Returns the new name."""
name = _form_str(form, "name", "").strip() name = _form_str(form, "name", "").strip()
@@ -1507,10 +1591,10 @@ def _apply_container_create(config_ref: ConfigRef, form: dict) -> str:
cfg = { cfg = {
"enabled": _form_bool(form, "enabled"), "enabled": _form_bool(form, "enabled"),
"max_error_count": _form_int(form, "max_error_count", 5), "max_error_count": _int_in(form, "max_error_count", 5, 1, 1000),
"cooldown_seconds": _form_int(form, "cooldown_seconds", 300), "cooldown_seconds": _int_in(form, "cooldown_seconds", 300, 0, 86400),
"grace_period_seconds": _form_int(form, "grace_period_seconds", 120), "grace_period_seconds": _int_in(form, "grace_period_seconds", 120, 0, 86400),
"max_restarts_per_hour": _form_int(form, "max_restarts_per_hour", 3), "max_restarts_per_hour": _int_in(form, "max_restarts_per_hour", 3, 1, 100),
"color": _form_str(form, "color", ""), "color": _form_str(form, "color", ""),
"maintenance_window": _form_bool(form, "maintenance_window"), "maintenance_window": _form_bool(form, "maintenance_window"),
} }
@@ -1536,6 +1620,7 @@ def _apply_container_create(config_ref: ConfigRef, form: dict) -> str:
return name return name
@_serialized
def _apply_container_delete(config_ref: ConfigRef, name: str) -> None: def _apply_container_delete(config_ref: ConfigRef, name: str) -> None:
raw = config_ref.get_raw() raw = config_ref.get_raw()
containers = raw.get("containers") or {} containers = raw.get("containers") or {}
@@ -1583,7 +1668,7 @@ def _run_test_notification(channel: str, form: dict, app: AppConfig) -> dict:
try: try:
resp = requests.post( resp = requests.post(
f"{url}/message", f"{url}/message",
params={"token": token}, headers={"X-Gotify-Key": token},
json={"title": title, "message": message, "priority": priority}, json={"title": title, "message": message, "priority": priority},
timeout=5.0, timeout=5.0,
) )
@@ -1665,6 +1750,9 @@ def _run_test_integration(kind: str, form: dict, app: AppConfig) -> dict:
return {"ok": False, "error": "API token required"} return {"ok": False, "error": "API token required"}
else: else:
var = _form_str(form, "api_token_env", "").strip() var = _form_str(form, "api_token_env", "").strip()
saved = app.integrations.portainer.environments.get(_form_str(form, "env_original", ""))
if var and not _token_env_allowed(var, saved.api_token_env if saved else ""):
return {"ok": False, "error": "the environment variable name must start with PORTAINER_"}
token = os.environ.get(var, "") if var else "" token = os.environ.get(var, "") if var else ""
if not token: if not token:
return {"ok": False, "error": f"environment variable {var!r} is not set in the Inspector container"} return {"ok": False, "error": f"environment variable {var!r} is not set in the Inspector container"}
@@ -2203,7 +2291,7 @@ def _portainer_env_form(env_name: str, env, csrf_token: str, *, suggest_name: st
if env is not None: if env is not None:
delete = ( delete = (
'<button type="submit" name="delete" value="1" class="btn-danger" ' '<button type="submit" name="delete" value="1" class="btn-danger" '
f"onclick=\"return confirm('Delete Portainer environment {_escape(env_name)}?');\">Delete</button>" f'onclick="return confirm({_js_string_attr(f"Delete Portainer environment {env_name}?")});">Delete</button>'
) )
label = "environment" if env is not None else "new environment" label = "environment" if env is not None else "new environment"
return ( return (
@@ -2440,7 +2528,7 @@ def _render_container_advanced(name: str, cfg, app: AppConfig) -> str:
+ '<div class="grid-2">' + '<div class="grid-2">'
+ _settings_field("timeout_seconds", "http_check_timeout_seconds", timeout_str, kind="number") + _settings_field("timeout_seconds", "http_check_timeout_seconds", timeout_str, kind="number")
+ _settings_field("expected_status", "http_check_expected_status", str(hc.expected_status), kind="number", + _settings_field("expected_status", "http_check_expected_status", str(hc.expected_status), kind="number",
hint="Exact HTTP code a healthy service returns (often 200; some apps return 401 or 302)") hint="Exact HTTP code a healthy service returns (often 200; some apps return 401). Redirects are followed, so use the final page's code.")
+ "</div></div>" + "</div></div>"
'<div class="form-group"><h4>Uptime Kuma monitor</h4>' '<div class="form-group"><h4>Uptime Kuma monitor</h4>'
+ _settings_field("enabled", "uptime_kuma_enabled", "on" if ukm.enabled else "", kind="switch") + _settings_field("enabled", "uptime_kuma_enabled", "on" if ukm.enabled else "", kind="switch")
@@ -2580,7 +2668,7 @@ def _render_container_form(
delete_btn = ( delete_btn = (
f'<form method="post" action="/containers/{_escape(name)}/delete" ' f'<form method="post" action="/containers/{_escape(name)}/delete" '
'style="display:inline;" ' 'style="display:inline;" '
f"onsubmit=\"return confirm('Delete container {_escape(name)} from config.yml?');\">" f'onsubmit="return confirm({_js_string_attr(f"Delete container {name} from config.yml?")});">'
f'<input type="hidden" name="csrf" value="{_escape(csrf_token)}">' f'<input type="hidden" name="csrf" value="{_escape(csrf_token)}">'
'<button type="submit" class="btn-danger">Delete container</button>' '<button type="submit" class="btn-danger">Delete container</button>'
"</form>" "</form>"
@@ -2684,8 +2772,39 @@ class _DashboardHTTPServer(ThreadingHTTPServer):
self.states = states self.states = states
self.maintenance = maintenance self.maintenance = maintenance
self.notification_mute = notification_mute self.notification_mute = notification_mute
# token -> (expiry_timestamp, csrf_token) # token -> (expiry_timestamp, csrf_token, credential fingerprint)
self._sessions: dict[str, tuple[float, str]] = {} self._sessions: dict[str, tuple[float, str, str]] = {}
# client address -> timestamps of recent failed logins
self._failed_logins: dict[str, list[float]] = {}
self._failed_lock = threading.Lock()
def login_throttled(self, client: str) -> bool:
cutoff = time.time() - _LOGIN_FAILURE_WINDOW
with self._failed_lock:
recent = [t for t in self._failed_logins.get(client, []) if t > cutoff]
if recent:
self._failed_logins[client] = recent
else:
self._failed_logins.pop(client, None)
return len(recent) >= _LOGIN_MAX_FAILURES
def note_login_failure(self, client: str) -> None:
with self._failed_lock:
self._failed_logins.setdefault(client, []).append(time.time())
def clear_login_failures(self, client: str) -> None:
with self._failed_lock:
self._failed_logins.pop(client, None)
def new_session(self) -> str:
now = time.time()
for tok, entry in list(self._sessions.items()): # drop expired sessions
if entry[0] <= now:
self._sessions.pop(tok, None)
token = secrets.token_urlsafe(32)
fp = _cred_fingerprint(self.current_app().dashboard)
self._sessions[token] = (now + _SESSION_MAX_AGE, secrets.token_urlsafe(32), fp)
return token
def current_app(self) -> AppConfig: def current_app(self) -> AppConfig:
return self.config_ref.get() return self.config_ref.get()
@@ -2710,29 +2829,47 @@ class _Handler(BaseHTTPRequestHandler):
def _is_authenticated(self) -> bool: def _is_authenticated(self) -> bool:
# Session cookie # Session cookie
now = time.time() now = time.time()
cfg = self.server.current_app().dashboard
token = self._session_token() token = self._session_token()
if token: if token:
entry = self.server._sessions.get(token) entry = self.server._sessions.get(token)
if entry and now < entry[0]: if entry and now < entry[0] and entry[2] == _cred_fingerprint(cfg):
return True return True
self.server._sessions.pop(token, None) self.server._sessions.pop(token, None)
# Basic Auth # Basic Auth
header = self.headers.get("Authorization", "") header = self.headers.get("Authorization", "")
if header.startswith("Basic "): if header.startswith("Basic "):
client = self.client_address[0]
if self.server.login_throttled(client):
return False
try: try:
decoded = b64decode(header[len("Basic "):]).decode("utf-8") decoded = b64decode(header[len("Basic "):]).decode("utf-8")
username, _, password = decoded.partition(":") username, _, password = decoded.partition(":")
except Exception: except Exception:
username, password = "", "" username, password = "", ""
cfg = self.server.current_app().dashboard if _creds_match(username, password, cfg):
if secrets.compare_digest(username, cfg.username) and secrets.compare_digest( self.server.clear_login_failures(client)
password, cfg.password
):
return True return True
self.server.note_login_failure(client)
return False return False
def _read_form(self) -> dict | None:
"""Parse the urlencoded request body, or send 400/413 and return None."""
try:
length = int(self.headers.get("Content-Length", 0))
except ValueError:
length = -1
if length < 0:
self._send_bytes(HTTPStatus.BAD_REQUEST, "text/plain; charset=utf-8", b"bad Content-Length")
return None
if length > _MAX_BODY_BYTES:
self._send_bytes(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "text/plain; charset=utf-8", b"request too large")
return None
raw = self.rfile.read(length) if length > 0 else b""
return parse_qs(raw.decode("utf-8", errors="replace"))
def _csrf_token_for_session(self) -> str: def _csrf_token_for_session(self) -> str:
"""Return the CSRF token bound to the current session, or '' if none.""" """Return the CSRF token bound to the current session, or '' if none."""
token = self._session_token() token = self._session_token()
@@ -2890,18 +3027,21 @@ class _Handler(BaseHTTPRequestHandler):
server = self.server server = self.server
if path == "/login": if path == "/login":
length = int(self.headers.get("Content-Length", 0)) data = self._read_form()
raw = self.rfile.read(length) if length > 0 else b"" if data is None:
data = parse_qs(raw.decode("utf-8", errors="replace")) return
client = self.client_address[0]
if server.login_throttled(client):
self._send_bytes(
HTTPStatus.TOO_MANY_REQUESTS, "text/html; charset=utf-8", _render_login_page(throttled=True)
)
return
username = data.get("username", [""])[0] username = data.get("username", [""])[0]
password = data.get("password", [""])[0] password = data.get("password", [""])[0]
cfg = server.current_app().dashboard cfg = server.current_app().dashboard
if secrets.compare_digest(username, cfg.username) and secrets.compare_digest( if _creds_match(username, password, cfg):
password, cfg.password server.clear_login_failures(client)
): token = server.new_session()
token = secrets.token_urlsafe(32)
csrf = secrets.token_urlsafe(32)
server._sessions[token] = (time.time() + _SESSION_MAX_AGE, csrf)
self.send_response(HTTPStatus.SEE_OTHER) self.send_response(HTTPStatus.SEE_OTHER)
self.send_header("Location", "/") self.send_header("Location", "/")
self.send_header( self.send_header(
@@ -2911,6 +3051,7 @@ class _Handler(BaseHTTPRequestHandler):
self.send_header("Content-Length", "0") self.send_header("Content-Length", "0")
self.end_headers() self.end_headers()
else: else:
server.note_login_failure(client)
self._send_bytes( self._send_bytes(
HTTPStatus.UNAUTHORIZED, "text/html; charset=utf-8", _render_login_page(error=True) HTTPStatus.UNAUTHORIZED, "text/html; charset=utf-8", _render_login_page(error=True)
) )
@@ -2919,9 +3060,9 @@ class _Handler(BaseHTTPRequestHandler):
if not self._check_auth(): if not self._check_auth():
return return
length = int(self.headers.get("Content-Length", 0)) form = self._read_form()
raw_body = self.rfile.read(length) if length > 0 else b"" if form is None:
form = parse_qs(raw_body.decode("utf-8", errors="replace")) return
# Only cookie-authenticated requests need a CSRF token. Basic Auth # Only cookie-authenticated requests need a CSRF token. Basic Auth
# clients prove identity on every request so they're not subject to # clients prove identity on every request so they're not subject to
+2 -1
View File
@@ -33,9 +33,10 @@ class GotifyNotifier:
}, },
} }
try: try:
# Header, not ?token=, so the token stays out of access logs.
resp = requests.post( resp = requests.post(
url, url,
params={"token": self._config.token}, headers={"X-Gotify-Key": self._config.token},
json=payload, json=payload,
timeout=self._timeout, timeout=self._timeout,
) )