diff --git a/CHANGELOG.md b/CHANGELOG.md index 80b835e..e9b0e85 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### Fixed diff --git a/README.md b/README.md index b30faf4..822b64c 100755 --- a/README.md +++ b/README.md @@ -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 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 the socket's permissions and fix ownership of its own folders. diff --git a/requirements.txt b/requirements.txt index 253a993..d1059da 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ docker==7.1.0 PyYAML==6.0.2 -requests==2.32.3 +requests==2.32.5 colorlog==6.9.0 ruamel.yaml==0.18.6 diff --git a/src/inspector/__init__.py b/src/inspector/__init__.py index d69d16e..a2fecb4 100755 --- a/src/inspector/__init__.py +++ b/src/inspector/__init__.py @@ -1 +1 @@ -__version__ = "0.9.1" +__version__ = "0.9.2" diff --git a/src/inspector/config.py b/src/inspector/config.py index 863537c..8671cde 100755 --- a/src/inspector/config.py +++ b/src/inspector/config.py @@ -1,10 +1,12 @@ from __future__ import annotations import io +import logging import os import shutil import threading from dataclasses import dataclass, field +from datetime import time from pathlib import Path import yaml @@ -23,6 +25,30 @@ VALID_STATUSES = { "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 = { "restart_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. """ 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( - interval_seconds=int(insp_raw.get("interval_seconds", 60)), - log_level=str(insp_raw.get("log_level", "INFO")).upper(), + interval_seconds=_startup_safe(interval, 1, interval >= 1, "inspector.interval_seconds"), + 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)), log_dir=str(insp_raw.get("log_dir", "/app/logs")), 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_start = str(maint_raw.get("start", "00:00")) + maint_end = str(maint_raw.get("end", "06:00")) maintenance = MaintenanceConfig( enabled=bool(maint_raw.get("enabled", False)), - start=str(maint_raw.get("start", "00:00")), - end=str(maint_raw.get("end", "06:00")), + start=_startup_safe(maint_start, "00:00", valid_hhmm(maint_start), "maintenance.start"), + end=_startup_safe(maint_end, "06:00", valid_hhmm(maint_end), "maintenance.end"), dir=str(maint_raw.get("dir", "/app/data/maintenance")), max_hours=int(maint_raw.get("max_hours", 8)), ) diff --git a/src/inspector/dashboard.py b/src/inspector/dashboard.py index f132182..2b8dcd1 100755 --- a/src/inspector/dashboard.py +++ b/src/inspector/dashboard.py @@ -1,5 +1,7 @@ from __future__ import annotations +import functools +import hashlib import json import logging import os @@ -21,9 +23,11 @@ from .config import ( AppConfig, ConfigRef, ContainerConfig, + VALID_LOG_LEVELS, HttpCheckConfig, UptimeKumaMonitorConfig, build_config_from_raw, + valid_hhmm, write_config_raw, ) from .formatting import PACIFIC, format_timestamp @@ -36,6 +40,9 @@ from .state import ContainerState, utcnow log = logging.getLogger("inspector.dashboard") _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 # Config keys that require an Inspector container restart to take effect. @@ -84,7 +91,13 @@ def _color_map( states: dict[str, ContainerState], containers: dict[str, ContainerConfig], ) -> 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( @@ -246,9 +259,31 @@ def _escape(value: object) -> str: .replace("<", "<") .replace(">", ">") .replace('"', """) + .replace("'", "'") ) +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 = { HEALTHY: "healthy", 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: - error_html = ( - '
Incorrect username or password.
\n' if error else "" - ) +def _render_login_page(error: bool = False, throttled: bool = False) -> bytes: + if throttled: + error_html = '
Too many failed sign-ins. Try again in a few minutes.
\n' + elif error: + error_html = '
Incorrect username or password.
\n' + else: + error_html = "" head = ( "\n" '\n\n' @@ -1243,6 +1281,14 @@ def _form_float(form: dict, key: str, default: float = 0.0) -> float: 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: """Store value, or remove the key when it is blank/False so config.yml 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["url"] = hc_url 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["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 else: 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) +_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: """Add, edit, rename or delete one integrations.portainer.environments entry.""" 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["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") if _form_str(form, "token_source", "env") == "inline": _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() if not token_env: 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.pop("api_token", None) @@ -1376,57 +1435,81 @@ def _apply_portainer_env(raw: dict, form: dict) -> None: 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: """Validate the form, merge into the raw config, write atomically, swap ref.""" raw = config_ref.get_raw() if section == "inspector": insp = raw.setdefault("inspector", {}) - insp["interval_seconds"] = _form_int(form, "interval_seconds", 60) - insp["log_level"] = _form_str(form, "log_level", "INFO").upper() + insp["interval_seconds"] = _int_in(form, "interval_seconds", 60, 1, 86400) + 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["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": notif = raw.setdefault("notifications", {}) gotify = notif.setdefault("gotify", {}) gotify["enabled"] = _form_bool(form, "enabled") gotify["url"] = _form_str(form, "url", "") _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": notif = raw.setdefault("notifications", {}) pushover = notif.setdefault("pushover", {}) pushover["enabled"] = _form_bool(form, "enabled") _apply_secret(form, pushover, "token", "token") _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["sound"] = _form_str(form, "sound", "") elif section == "dashboard": dash = raw.setdefault("dashboard", {}) 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") + if not dash.get("password"): + raise _SettingsError("the dashboard password can't be empty") elif section == "database": db = raw.setdefault("database", {}) 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": hs = raw.setdefault("health_summary", {}) hs["enabled"] = _form_bool(form, "enabled") - hs["interval_hours"] = _form_int(form, "interval_hours", 24) - hs["priority"] = _form_int(form, "priority", 3) + hs["interval_hours"] = _int_in(form, "interval_hours", 24, 1, 8760) + hs["priority"] = _int_in(form, "priority", 3, 0, 10) elif section == "maintenance": m = raw.setdefault("maintenance", {}) m["enabled"] = _form_bool(form, "enabled") - m["start"] = _form_str(form, "start", "00:00") - m["end"] = _form_str(form, "end", "06:00") - m["max_hours"] = _form_int(form, "max_hours", 8) + for key, default in (("start", "00:00"), ("end", "06:00")): + value = _form_str(form, key, default).strip() + 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": nm = raw.setdefault("notification_mute", {}) 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:"): name = section.split(":", 1)[1] 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}") cfg = containers[name] or {} cfg["enabled"] = _form_bool(form, "enabled") - cfg["max_error_count"] = _form_int(form, "max_error_count", 5) - cfg["cooldown_seconds"] = _form_int(form, "cooldown_seconds", 300) - cfg["grace_period_seconds"] = _form_int(form, "grace_period_seconds", 120) - cfg["max_restarts_per_hour"] = _form_int(form, "max_restarts_per_hour", 3) + cfg["max_error_count"] = _int_in(form, "max_error_count", 5, 1, 1000) + cfg["cooldown_seconds"] = _int_in(form, "cooldown_seconds", 300, 0, 86400) + cfg["grace_period_seconds"] = _int_in(form, "grace_period_seconds", 120, 0, 86400) + 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()) if form.get("restart_on_present"): # only our own form carries this field _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 {}): raise _SettingsError(f"unknown Portainer environment {default_env!r}") _set_or_drop(pt, "default_environment", default_env) - pt["stack_redeploy_cooldown_seconds"] = _form_int(form, "stack_redeploy_cooldown_seconds", 300) - pt["post_redeploy_wait_seconds"] = _form_int(form, "post_redeploy_wait_seconds", 90) + pt["stack_redeploy_cooldown_seconds"] = _int_in(form, "stack_redeploy_cooldown_seconds", 300, 0, 86400) + pt["post_redeploy_wait_seconds"] = _int_in(form, "post_redeploy_wait_seconds", 90, 0, 3600) elif section == "portainer_env": _apply_portainer_env(raw, form) 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}$") +@_serialized def _apply_container_create(config_ref: ConfigRef, form: dict) -> str: """Insert a new container under raw['containers']. Returns the new name.""" name = _form_str(form, "name", "").strip() @@ -1507,10 +1591,10 @@ def _apply_container_create(config_ref: ConfigRef, form: dict) -> str: cfg = { "enabled": _form_bool(form, "enabled"), - "max_error_count": _form_int(form, "max_error_count", 5), - "cooldown_seconds": _form_int(form, "cooldown_seconds", 300), - "grace_period_seconds": _form_int(form, "grace_period_seconds", 120), - "max_restarts_per_hour": _form_int(form, "max_restarts_per_hour", 3), + "max_error_count": _int_in(form, "max_error_count", 5, 1, 1000), + "cooldown_seconds": _int_in(form, "cooldown_seconds", 300, 0, 86400), + "grace_period_seconds": _int_in(form, "grace_period_seconds", 120, 0, 86400), + "max_restarts_per_hour": _int_in(form, "max_restarts_per_hour", 3, 1, 100), "color": _form_str(form, "color", ""), "maintenance_window": _form_bool(form, "maintenance_window"), } @@ -1536,6 +1620,7 @@ def _apply_container_create(config_ref: ConfigRef, form: dict) -> str: return name +@_serialized def _apply_container_delete(config_ref: ConfigRef, name: str) -> None: raw = config_ref.get_raw() containers = raw.get("containers") or {} @@ -1583,7 +1668,7 @@ def _run_test_notification(channel: str, form: dict, app: AppConfig) -> dict: try: resp = requests.post( f"{url}/message", - params={"token": token}, + headers={"X-Gotify-Key": token}, json={"title": title, "message": message, "priority": priority}, 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"} else: 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 "" if not token: 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: delete = ( '" + f'onclick="return confirm({_js_string_attr(f"Delete Portainer environment {env_name}?")});">Delete' ) label = "environment" if env is not None else "new environment" return ( @@ -2440,7 +2528,7 @@ def _render_container_advanced(name: str, cfg, app: AppConfig) -> str: + '
' + _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", - 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.") + "
" '

Uptime Kuma monitor

' + _settings_field("enabled", "uptime_kuma_enabled", "on" if ukm.enabled else "", kind="switch") @@ -2580,7 +2668,7 @@ def _render_container_form( delete_btn = ( f'
" + f'onsubmit="return confirm({_js_string_attr(f"Delete container {name} from config.yml?")});">' f'' '' "
" @@ -2684,8 +2772,39 @@ class _DashboardHTTPServer(ThreadingHTTPServer): self.states = states self.maintenance = maintenance self.notification_mute = notification_mute - # token -> (expiry_timestamp, csrf_token) - self._sessions: dict[str, tuple[float, str]] = {} + # token -> (expiry_timestamp, csrf_token, credential fingerprint) + 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: return self.config_ref.get() @@ -2710,29 +2829,47 @@ class _Handler(BaseHTTPRequestHandler): def _is_authenticated(self) -> bool: # Session cookie now = time.time() + cfg = self.server.current_app().dashboard token = self._session_token() if 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 self.server._sessions.pop(token, None) # Basic Auth header = self.headers.get("Authorization", "") if header.startswith("Basic "): + client = self.client_address[0] + if self.server.login_throttled(client): + return False try: decoded = b64decode(header[len("Basic "):]).decode("utf-8") username, _, password = decoded.partition(":") except Exception: username, password = "", "" - cfg = self.server.current_app().dashboard - if secrets.compare_digest(username, cfg.username) and secrets.compare_digest( - password, cfg.password - ): + if _creds_match(username, password, cfg): + self.server.clear_login_failures(client) return True + self.server.note_login_failure(client) 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: """Return the CSRF token bound to the current session, or '' if none.""" token = self._session_token() @@ -2890,18 +3027,21 @@ class _Handler(BaseHTTPRequestHandler): server = self.server if path == "/login": - length = int(self.headers.get("Content-Length", 0)) - raw = self.rfile.read(length) if length > 0 else b"" - data = parse_qs(raw.decode("utf-8", errors="replace")) + data = self._read_form() + if data is None: + 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] password = data.get("password", [""])[0] cfg = server.current_app().dashboard - if secrets.compare_digest(username, cfg.username) and secrets.compare_digest( - password, cfg.password - ): - token = secrets.token_urlsafe(32) - csrf = secrets.token_urlsafe(32) - server._sessions[token] = (time.time() + _SESSION_MAX_AGE, csrf) + if _creds_match(username, password, cfg): + server.clear_login_failures(client) + token = server.new_session() self.send_response(HTTPStatus.SEE_OTHER) self.send_header("Location", "/") self.send_header( @@ -2911,6 +3051,7 @@ class _Handler(BaseHTTPRequestHandler): self.send_header("Content-Length", "0") self.end_headers() else: + server.note_login_failure(client) self._send_bytes( HTTPStatus.UNAUTHORIZED, "text/html; charset=utf-8", _render_login_page(error=True) ) @@ -2919,9 +3060,9 @@ class _Handler(BaseHTTPRequestHandler): if not self._check_auth(): return - length = int(self.headers.get("Content-Length", 0)) - raw_body = self.rfile.read(length) if length > 0 else b"" - form = parse_qs(raw_body.decode("utf-8", errors="replace")) + form = self._read_form() + if form is None: + return # Only cookie-authenticated requests need a CSRF token. Basic Auth # clients prove identity on every request so they're not subject to diff --git a/src/inspector/notifier.py b/src/inspector/notifier.py index 5538c1b..c54e96c 100755 --- a/src/inspector/notifier.py +++ b/src/inspector/notifier.py @@ -33,9 +33,10 @@ class GotifyNotifier: }, } try: + # Header, not ?token=, so the token stays out of access logs. resp = requests.post( url, - params={"token": self._config.token}, + headers={"X-Gotify-Key": self._config.token}, json=payload, timeout=self._timeout, )