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
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:
@@ -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'
|
||||
)
|
||||
Reference in New Issue
Block a user