Aug 26 - Enhance security 3
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:
@@ -107,3 +107,100 @@ 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'
|
||||
|
||||
Reference in New Issue
Block a user