Aug 26 - Enhance security 2
CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Pytest (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled

This commit is contained in:
2026-08-26 12:54:17 -04:00
parent 82dd7c5aef
commit 6c1bef73c8
20 changed files with 1193 additions and 79 deletions
+109
View File
@@ -0,0 +1,109 @@
"""
Guards on deployment configuration that only bites in production.
These are the settings whose failure mode is an intermittent 502 rather than a
stack trace, so nothing else catches them drifting apart.
"""
import re
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
NGINX = (ROOT / 'scripts' / 'passkeeper-nginx.conf').read_text(encoding='utf-8')
UNIT = (ROOT / 'scripts' / 'passkeeper.service').read_text(encoding='utf-8')
@pytest.fixture(scope='module')
def gunicorn_conf():
ns = {}
exec(compile((ROOT / 'gunicorn.conf.py').read_text(encoding='utf-8'),
'gunicorn.conf.py', 'exec'), ns)
return ns
def _nginx_seconds(directive):
m = re.search(rf'^\s*{directive}\s+(\d+)s;', NGINX, re.M)
assert m, f'{directive} not found in passkeeper-nginx.conf'
return int(m.group(1))
def test_nginx_gives_up_before_gunicorn_kills_the_worker(gunicorn_conf):
"""
The 502 invariant. If Gunicorn's timeout fires first the connection is
severed mid-response and Nginx reports 502; if Nginx times out first the
client gets a clean 504 instead.
"""
assert _nginx_seconds('proxy_read_timeout') < gunicorn_conf['timeout']
def test_gunicorn_holds_keepalive_longer_than_nginx(gunicorn_conf):
"""
Nginx must be the side that closes an idle upstream connection. If Gunicorn
closes one as Nginx is reusing it, that request fails as a 502.
"""
assert gunicorn_conf['keepalive'] > _nginx_seconds('keepalive_timeout')
def test_preload_app_is_disabled(gunicorn_conf):
"""
create_app() starts an APScheduler thread, and threads do not survive
fork(). Under preload_app the scheduler would exist only in the arbiter,
which serves no requests, so the cleanup job would silently never run.
"""
assert gunicorn_conf['preload_app'] is False
def test_static_location_repeats_every_security_header():
"""
Nginx drops ALL inherited add_header directives in any location that
declares one of its own. /static/ sets Cache-Control, so without explicit
copies every JS and CSS asset ships with no CSP, HSTS or X-Frame-Options.
"""
static = re.search(r'location /static/ \{(.*?)\n \}', NGINX, re.S)
assert static, 'no /static/ location block found'
body = static.group(1)
for header in ('Strict-Transport-Security', 'X-Frame-Options',
'X-Content-Type-Options', 'Referrer-Policy',
'Permissions-Policy', 'Content-Security-Policy'):
assert header in body, f'/static/ is missing {header}'
def test_hibp_origin_is_allowed_in_every_csp():
"""The security dashboard's breach check needs this origin in connect-src."""
policies = re.findall(r'connect-src[^;"]*', NGINX)
assert policies, 'no connect-src directive found'
for p in policies:
assert 'https://api.pwnedpasswords.com' in p, p
def test_unit_has_no_watchdog():
"""
WatchdogSec without Type=notify meant systemd never received a keepalive,
declared the service hung, and SIGKILLed it on a loop — a repeating window
of 502s. Re-enabling it requires Type=notify AND NotifyAccess=main.
"""
active = [ln for ln in UNIT.splitlines()
if ln.strip().startswith('WatchdogSec')]
if active:
assert 'Type=notify' in UNIT and 'NotifyAccess=main' in UNIT, (
'WatchdogSec requires Type=notify + NotifyAccess=main or systemd '
'will kill the service on a loop'
)
def test_unit_reload_does_not_use_usr2():
"""
USR2 forks a second master without retiring the first, leaving systemd's
$MAINPID tracking a stale process.
"""
reload_line = next((ln for ln in UNIT.splitlines()
if ln.strip().startswith('ExecReload=')), '')
assert 'USR2' not in reload_line, reload_line
def test_unit_loads_the_gunicorn_config_file():
assert 'gunicorn.conf.py' in UNIT, (
'the unit no longer references gunicorn.conf.py, so its tuning is dead code'
)