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 = (
- '
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'"
@@ -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,
)