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
112 lines
4.6 KiB
Python
112 lines
4.6 KiB
Python
"""
|
|
Regression tests for registration account-existence disclosure (finding #8).
|
|
|
|
/register answered 409 "Email already registered", which let anyone probe
|
|
whether a given address has a PassKeeper account — a ready-made target list for
|
|
phishing, and precisely what /login goes out of its way not to reveal.
|
|
|
|
Both branches must now be indistinguishable in status, body, and timing.
|
|
"""
|
|
import time
|
|
|
|
from app import db
|
|
from app.models.audit_log import AuditLog
|
|
from app.models.user import User
|
|
from tests.conftest import login, register
|
|
|
|
|
|
def test_duplicate_registration_is_indistinguishable(client, app):
|
|
first = register(client, 'user@example.com', 'HASH-1', 'SALT-1')
|
|
second = register(client, 'user@example.com', 'HASH-2', 'SALT-2')
|
|
|
|
assert first.status_code == second.status_code == 202
|
|
assert first.get_json() == second.get_json(), (
|
|
'the response differs for an existing address, so it can be probed'
|
|
)
|
|
|
|
|
|
def test_duplicate_registration_does_not_touch_the_existing_account(client, app):
|
|
"""The generic response must not come at the cost of overwriting credentials."""
|
|
register(client, 'user@example.com', 'HASH-1', 'SALT-1')
|
|
original = User.query.filter_by(email='user@example.com').first()
|
|
original_hash, original_salt = original.master_hash, original.enc_key_salt
|
|
|
|
register(client, 'user@example.com', 'ATTACKER-HASH', 'ATTACKER-SALT')
|
|
|
|
user = User.query.filter_by(email='user@example.com').first()
|
|
assert user.master_hash == original_hash, 'existing credentials overwritten'
|
|
assert user.enc_key_salt == original_salt
|
|
assert User.query.filter_by(email='user@example.com').count() == 1
|
|
|
|
# The original password must still be the one that works.
|
|
assert login(client, 'user@example.com', 'HASH-1').status_code == 200
|
|
assert login(client, 'user@example.com', 'ATTACKER-HASH').status_code == 401
|
|
|
|
|
|
def test_registration_response_does_not_name_the_cause(client, app):
|
|
register(client, 'user@example.com')
|
|
body = register(client, 'user@example.com').get_json()
|
|
text = ' '.join(str(v) for v in body.values()).lower()
|
|
|
|
for leak in ('already', 'exists', 'taken', 'registered account', 'duplicate'):
|
|
assert leak not in text, f'response body leaks existence via {leak!r}: {body}'
|
|
|
|
|
|
def test_timing_does_not_disclose_existence(client, app):
|
|
"""
|
|
Creating an account runs Argon2id, which is deliberately slow. If the
|
|
duplicate branch returned early it would be measurably faster and the oracle
|
|
would survive in the timing even though the body is identical.
|
|
|
|
Uses a loose bound: this asserts the expensive work happens on both paths,
|
|
not that timing is cryptographically uniform.
|
|
"""
|
|
register(client, 'taken@example.com')
|
|
|
|
def elapsed(email):
|
|
start = time.perf_counter()
|
|
register(client, email)
|
|
return time.perf_counter() - start
|
|
|
|
new_times = [elapsed(f'fresh{i}@example.com') for i in range(3)]
|
|
dup_times = [elapsed('taken@example.com') for _ in range(3)]
|
|
|
|
new_avg = sum(new_times) / len(new_times)
|
|
dup_avg = sum(dup_times) / len(dup_times)
|
|
slower, faster = max(new_avg, dup_avg), min(new_avg, dup_avg)
|
|
|
|
assert slower < faster * 4, (
|
|
f'timing distinguishes the branches: new={new_avg:.4f}s dup={dup_avg:.4f}s'
|
|
)
|
|
|
|
|
|
def test_duplicate_attempt_is_audited(client, app):
|
|
"""Invisible to the prober, but the operator should still see the attempts."""
|
|
register(client, 'user@example.com')
|
|
register(client, 'user@example.com')
|
|
|
|
entry = (AuditLog.query.filter_by(action='auth.register_duplicate')
|
|
.order_by(AuditLog.id.desc()).first())
|
|
assert entry is not None, 'duplicate registration attempt was not audited'
|
|
assert 'user@example.com' not in (entry.detail or ''), (
|
|
'audit detail should not need the probed address to be useful'
|
|
)
|
|
|
|
|
|
def test_validation_errors_still_reported(client, app):
|
|
"""Input validation does not reveal existence, so it stays specific."""
|
|
assert register(client, 'not-an-email').status_code == 400
|
|
assert client.post('/api/auth/register', json={'email': 'a@b.co'}).status_code == 400
|
|
assert client.post('/api/auth/register',
|
|
json={'email': 'a@b.co', 'auth_hash': 'h'}).status_code == 400
|
|
|
|
|
|
def test_new_registration_still_creates_a_usable_account(client, app):
|
|
"""The privacy fix must not break the happy path."""
|
|
assert register(client, 'fresh@example.com', 'HASH', 'SALT').status_code == 202
|
|
assert User.query.filter_by(email='fresh@example.com').first() is not None
|
|
|
|
res = login(client, 'fresh@example.com', 'HASH')
|
|
assert res.status_code == 200
|
|
assert res.get_json()['enc_key_salt'] == 'SALT'
|