Files
PassKeeper/tests/test_deploy_config.py
T
nngo cc216b0d98
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
Aug 26 - Enhance security 3
2026-08-26 13:54:48 -04:00

207 lines
8.3 KiB
Python

"""
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'
)
def test_api_rate_limit_is_not_absurdly_tight():
"""
api_limit was 60r/m — 1 req/s for the entire API. A vault page load fires
several /api/* calls, so normal use produced spurious 429s. The units are
easy to misread, which is exactly why this is pinned.
"""
m = re.search(r'zone=api_limit:\S+\s+rate=(\d+)r/([sm]);', NGINX)
assert m, 'api_limit zone not found'
per_second = int(m.group(1)) / (1 if m.group(2) == 's' else 60)
assert per_second >= 5, (
f'api_limit is {per_second:.2f} req/s — too tight for normal vault use'
)
def test_auth_rate_limit_stays_tight():
"""The brute-force surface must NOT be widened along with api_limit."""
m = re.search(r'zone=auth_limit:\S+\s+rate=(\d+)r/([sm]);', NGINX)
assert m, 'auth_limit zone not found'
per_minute = int(m.group(1)) * (60 if m.group(2) == 's' else 1)
assert per_minute <= 60, f'auth_limit is {per_minute} req/min — too permissive'
# -- Extension packaging -----------------------------------------------------
EXT = ROOT / 'extension'
@pytest.mark.parametrize('manifest_name',
['manifest.json', 'manifest.firefox.json'])
def test_manifest_loads_psl_before_content_script(manifest_name):
"""
content.js calls PkPsl at match time. If psl.js is missing from the manifest
the matcher silently falls back to exact-hostname equality, quietly losing
every subdomain match.
"""
import json
manifest = json.loads((EXT / manifest_name).read_text(encoding='utf-8'))
blocks = [cs for cs in manifest.get('content_scripts', [])
if any('content/content.js' in f for f in cs['js'])]
assert blocks, f'{manifest_name} has no content.js block'
for cs in blocks:
assert 'shared/psl.js' in cs['js'], f'{manifest_name}: psl.js not loaded'
assert cs['js'].index('shared/psl.js') < cs['js'].index('content/content.js'), \
f'{manifest_name}: psl.js must load BEFORE content.js'
def test_popup_html_loads_psl():
# Compare the parsed <script src> order, not raw string positions — the
# surrounding comments mention these filenames too.
html = (EXT / 'popup' / 'popup.html').read_text(encoding='utf-8')
srcs = re.findall(r'<script src="([^"]+)"', html)
assert '../shared/psl.js' in srcs, 'popup.html does not load psl.js'
assert srcs.index('../shared/psl.js') < srcs.index('popup.js'), \
f'psl.js must load before popup.js, got {srcs}'
def test_psl_includes_the_private_section():
"""
The PRIVATE section (github.io, vercel.app, herokuapp.com) is where an
attacker can actually register a neighbouring subdomain. Regenerating the
list with only the ICANN section would silently reopen finding #5.
"""
psl = (EXT / 'shared' / 'psl.js').read_text(encoding='utf-8')
for suffix in ('github.io', 'vercel.app', 'herokuapp.com'):
assert f'\n{suffix}\n' in psl, f'PSL is missing the private suffix {suffix}'
def test_no_suffix_matching_remains_in_the_extension():
"""
The endsWith("." + host) pattern is the finding-#5 bug. If it reappears,
credentials are being offered across public-suffix boundaries again.
"""
for rel in ('content/content.js', 'popup/popup.js',
'background.js', 'background.firefox.js'):
src = (EXT / rel).read_text(encoding='utf-8')
code = '\n'.join(ln for ln in src.splitlines()
if not ln.strip().startswith(('*', '//', '/*')))
for pattern in ('endsWith("." + host', 'endsWith(`.${host',
'endsWith(`.${h}`)', "endsWith('.' + host"):
assert pattern not in code, f'{rel}: suffix matching is back ({pattern})'
def test_background_scripts_load_psl():
"""
The badge counts matching items and must use the same same-site rule.
Chrome pulls psl.js in via importScripts; Firefox via background.scripts.
"""
import json
mv3 = (EXT / 'background.js').read_text(encoding='utf-8')
assert 'importScripts("shared/psl.js")' in mv3, 'background.js does not importScripts psl.js'
ff = json.loads((EXT / 'manifest.firefox.json').read_text(encoding='utf-8'))
scripts = ff['background']['scripts']
assert 'shared/psl.js' in scripts, 'firefox background does not load psl.js'
assert scripts.index('shared/psl.js') < scripts.index('background.firefox.js'), 'psl.js must load before background.firefox.js'