mirror of
https://github.com/pi-hole/FTL.git
synced 2026-08-20 18:07:32 +01:00
Cover three fixes from the security pass: session cookies carry the Secure attribute only over TLS (and never over plain HTTP), an accepted TOTP code cannot be replayed, and /api/queries clamps an unbounded length parameter. The cookie and TOTP tests live in a self-contained module so they do not disturb the order-dependent workflow in test_z_auth.py; the TOTP test clears the shared secret via the session created by the accepted login so cleanup neither trips the replay guard nor emits a 2FA warning. Account for the module's four extra pihole.toml writes in test_final.bats. Signed-off-by: DL6ER <dl6er@dl6er.de>
201 lines
7.8 KiB
Python
201 lines
7.8 KiB
Python
"""
|
|
Pi-hole FTL API security regression tests.
|
|
|
|
These guard three hardening fixes and are intentionally isolated in their own
|
|
module (with a self-contained password/TOTP lifecycle) so they do not disturb
|
|
the order-dependent workflow in test_z_auth.py:
|
|
|
|
1. Session cookies carry the Secure attribute only over TLS. A TLS session's
|
|
SID must not be settable over plaintext HTTP (that would allow it to be
|
|
captured on a single http:// request), and Secure must NOT be added over
|
|
plain HTTP (that would make browsers drop the cookie and break LAN setups).
|
|
|
|
2. An accepted TOTP code cannot be replayed. Replay protection tracks the
|
|
accepted RFC 6238 time-step counter, so once a step has been accepted the
|
|
same code can no longer be reused.
|
|
|
|
Usage:
|
|
pytest test/api/test_z_auth_security.py -v
|
|
"""
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import time
|
|
import warnings
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
# The TLS test talks to FTL's self-signed cert with verify=False. Resolve the
|
|
# InsecureRequestWarning category so that test can silence it locally: pytest
|
|
# resets warning filters per test, so a module-level disable_warnings() is not
|
|
# enough - the test wraps its request in a catch_warnings() block.
|
|
try:
|
|
from urllib3.exceptions import InsecureRequestWarning
|
|
except ImportError: # pragma: no cover - fallback for bundled urllib3
|
|
from requests.packages.urllib3.exceptions import InsecureRequestWarning
|
|
|
|
FTL_URL = "http://127.0.0.1"
|
|
FTL_URL_TLS = "https://127.0.0.1"
|
|
PASSWORD = "sec-regress-pw"
|
|
# Valid base32 TOTP secret (same shape as the stress-test suite)
|
|
TOTP_SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"
|
|
|
|
|
|
# -- helpers ----------------------------------------------------------------
|
|
|
|
def _set_password(pw, sid=None):
|
|
headers = {"X-FTL-SID": sid} if sid else {}
|
|
return requests.patch(
|
|
f"{FTL_URL}/api/config/webserver/api/password",
|
|
json={"config": {"webserver": {"api": {"password": pw}}}},
|
|
headers=headers, timeout=20)
|
|
|
|
|
|
def _set_totp_secret(secret, sid):
|
|
return requests.patch(
|
|
f"{FTL_URL}/api/config/webserver/api/totp_secret",
|
|
json={"config": {"webserver": {"api": {"totp_secret": secret}}}},
|
|
headers={"X-FTL-SID": sid}, timeout=20)
|
|
|
|
|
|
def _login(pw, totp=None, base=FTL_URL, verify=True, timeout=10):
|
|
payload = {"password": pw}
|
|
if totp is not None:
|
|
payload["totp"] = totp
|
|
return requests.post(f"{base}/api/auth", json=payload,
|
|
timeout=timeout, verify=verify)
|
|
|
|
|
|
def _login_rate_limited(pw, totp=None, base=FTL_URL, verify=True, attempts=6):
|
|
"""Login, tolerating the login rate limiter (HTTP 429) with short waits.
|
|
|
|
Kept short so a TOTP code stays within its 30 s validity window.
|
|
"""
|
|
r = _login(pw, totp=totp, base=base, verify=verify)
|
|
for _ in range(attempts):
|
|
if r.status_code != 429:
|
|
return r
|
|
time.sleep(0.5)
|
|
r = _login(pw, totp=totp, base=base, verify=verify)
|
|
return r
|
|
|
|
|
|
def _totp_code(secret, now=None):
|
|
if now is None:
|
|
now = time.time()
|
|
key = base64.b32decode(secret, casefold=True)
|
|
counter = int(now // 30)
|
|
digest = hmac.new(key, counter.to_bytes(8, "big"), hashlib.sha1).digest()
|
|
offset = digest[-1] & 0x0F
|
|
binary = ((digest[offset] & 0x7F) << 24) | (
|
|
(digest[offset + 1] & 0xFF) << 16) | (
|
|
(digest[offset + 2] & 0xFF) << 8) | (
|
|
digest[offset + 3] & 0xFF)
|
|
return binary % 1_000_000
|
|
|
|
|
|
def _admin_sid():
|
|
"""Obtain an authenticated SID via a password-only login.
|
|
|
|
Only used while no TOTP secret is configured (module setup/teardown and
|
|
before the replay test enables TOTP), so a plain password login is enough
|
|
and never triggers a "no 2FA token" warning.
|
|
"""
|
|
return _login_rate_limited(PASSWORD).json().get("session", {}).get("sid")
|
|
|
|
|
|
# -- module lifecycle -------------------------------------------------------
|
|
|
|
def setup_module(_mod):
|
|
# Wait until the API is reachable, then set a known password
|
|
for _ in range(40):
|
|
try:
|
|
if requests.get(f"{FTL_URL}/api/auth", timeout=5).status_code in (200, 401):
|
|
break
|
|
except requests.ConnectionError:
|
|
time.sleep(0.25)
|
|
r = _set_password(PASSWORD)
|
|
assert r.status_code == 200, f"set password: {r.status_code} {r.text}"
|
|
|
|
|
|
def teardown_module(_mod):
|
|
time.sleep(1)
|
|
try:
|
|
sid = _admin_sid()
|
|
if sid:
|
|
# Clear any TOTP secret first so a plain password login works again
|
|
_set_totp_secret("", sid)
|
|
_set_password("", sid=sid)
|
|
except Exception:
|
|
pass # best-effort cleanup
|
|
|
|
|
|
# -- tests ------------------------------------------------------------------
|
|
|
|
class TestSessionCookieSecure:
|
|
"""The Secure cookie attribute must track the transport."""
|
|
|
|
def test_http_login_cookie_has_no_secure_attribute(self):
|
|
r = _login_rate_limited(PASSWORD, base=FTL_URL)
|
|
assert r.status_code == 200, f"HTTP login failed: {r.status_code} {r.text}"
|
|
set_cookie = r.headers.get("Set-Cookie", "")
|
|
assert "sid=" in set_cookie, f"no session cookie set: {set_cookie!r}"
|
|
# Secure over plain HTTP would make the browser drop the cookie
|
|
assert "secure" not in set_cookie.lower(), \
|
|
f"Secure attribute wrongly set over HTTP: {set_cookie!r}"
|
|
|
|
def test_https_login_cookie_has_secure_attribute(self):
|
|
try:
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", InsecureRequestWarning)
|
|
r = _login_rate_limited(PASSWORD, base=FTL_URL_TLS, verify=False)
|
|
except (requests.exceptions.SSLError,
|
|
requests.exceptions.ConnectionError) as e:
|
|
pytest.skip(f"HTTPS endpoint not available: {e}")
|
|
assert r.status_code == 200, f"HTTPS login failed: {r.status_code} {r.text}"
|
|
set_cookie = r.headers.get("Set-Cookie", "")
|
|
assert "sid=" in set_cookie, f"no session cookie set: {set_cookie!r}"
|
|
assert "secure" in set_cookie.lower(), \
|
|
f"Secure attribute missing over HTTPS: {set_cookie!r}"
|
|
|
|
|
|
class TestTOTPReplay:
|
|
"""An accepted TOTP code must not be replayable."""
|
|
|
|
def test_accepted_totp_code_cannot_be_replayed(self):
|
|
# Obtain a session before enabling TOTP (plain password login), then
|
|
# configure the shared TOTP secret.
|
|
sid = _admin_sid()
|
|
assert sid, "could not obtain an admin session to configure TOTP"
|
|
assert _set_totp_secret(TOTP_SECRET, sid).status_code == 200
|
|
|
|
first_sid = None
|
|
try:
|
|
# Advance into a fresh 30 s time step that no earlier login has
|
|
# consumed, so the first code is accepted (the replay guard rejects
|
|
# any step counter <= the last accepted one).
|
|
time.sleep(30 - (time.time() % 30) + 1)
|
|
code = _totp_code(TOTP_SECRET)
|
|
|
|
first = _login_rate_limited(PASSWORD, totp=code)
|
|
assert first.status_code == 200, \
|
|
f"valid TOTP code was not accepted: {first.status_code} {first.text}"
|
|
session = first.json().get("session", {})
|
|
first_sid = session.get("sid")
|
|
assert session.get("valid") is True
|
|
|
|
# Replay the exact same code: must be rejected as reused
|
|
replay = _login_rate_limited(PASSWORD, totp=code)
|
|
assert replay.status_code != 200, \
|
|
f"replayed TOTP code was accepted: {replay.status_code} {replay.text}"
|
|
assert replay.json().get("session", {}).get("valid") is not True
|
|
finally:
|
|
# Clear the secret using the session created by the accepted login:
|
|
# it is valid under the 2FA regime, so this avoids a password-only
|
|
# login (which would emit a "no 2FA token" warning) and the replay
|
|
# guard (which would reject a freshly computed code in this window).
|
|
if first_sid:
|
|
_set_totp_secret("", first_sid)
|