Two asks, and the first is a real security decision so it is a SETTING that defaults to the safe answer rather than a code change somebody inherits. securesend_allow_guests, OFF by default. With it off the model is unchanged: a login plus tools.secrets. With it on, somebody with no account - at a shared bay terminal, a contractor - can create a link too. The cost is named in the setting's own description and pinned by a test: a guest secret has NO CREATOR, so the audit trail can say when and from where but never who. That is the whole reason it is a switch a person turns on deliberately. IT FAILS CLOSED. A missing row, a half-migrated site, an unhappy database - the answer is no. An error must never be the thing that opens an unauthenticated write endpoint. A SIGNED-IN USER STILL NEEDS THE PERMISSION even when guests are allowed, which is surprising enough that a test pins it: opening the door to guests is a decision about the door, not about revoking what a role holds. Guest creation is rate limited per address, same shape as the login limiter. Not a security boundary - a determined person has more addresses - but an open endpoint should not be an easy place to park data or fill a disk. VIEWS ARE NOW A NUMBER, 1 to 100, instead of a four-item dropdown. How many people need a password is the sender's business: a shift handover is not one colleague on a call. The floor is what matters, not the ceiling - unlimited views would just be a password on a web page. Also folded in, from the retention question: nothing removed a secret that EXPIRED WITHOUT BEING OPENED. The recipient goes on holiday, the link is never clicked, and the ciphertext sat there for good - not readable, but it made "nothing lingers" untrue and grew the table in exactly the case where the feature failed. Any create now sweeps expired rows and a late click tidies its own, so it stays bounded without a scheduler an air-gapped site would never add. Two gates caught mistakes on the way: current_user is a LocalProxy and is never None, so `current_user is None` silently took the signed-in branch for a guest; and cache was imported straight from shopdb.extensions when it is already on the shopdb.api contract surface. 1836 tests green.
245 lines
10 KiB
Python
245 lines
10 KiB
Python
"""Handing over a credential by link: who may create, who may read, what dies.
|
|
|
|
The interesting tests here are the refusals. A secret that leaks, outlives its
|
|
window, or is consumed by a link preview before its recipient ever sees it, all
|
|
fail in ways nobody notices until somebody needs the password.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from plugins.tools.models import SecureSecret
|
|
from plugins.tools.api.routes import MAX_CIPHERTEXT
|
|
|
|
CIPHER = 'ZmFrZS1jaXBoZXJ0ZXh0LWZvci10ZXN0cw=='
|
|
IV = 'MTIzNDU2Nzg5MDEy'
|
|
|
|
|
|
def _create(client, headers, **overrides):
|
|
body = {'ciphertext': CIPHER, 'iv': IV}
|
|
body.update(overrides)
|
|
return client.post('/api/tools/secrets', json=body, headers=headers)
|
|
|
|
|
|
# ------------------------------------------------------------------- creating
|
|
|
|
def test_creating_requires_a_login(client):
|
|
"""Anonymous creation would be an untraceable way to move data out of a
|
|
system that otherwise records who did what."""
|
|
resp = client.post('/api/tools/secrets', json={'ciphertext': CIPHER, 'iv': IV})
|
|
assert resp.status_code in (401, 403, 422)
|
|
|
|
|
|
def test_a_logged_in_user_with_the_permission_can_create(client, auth_headers):
|
|
resp = _create(client, auth_headers)
|
|
assert resp.status_code in (200, 201), resp.get_data(as_text=True)[:300]
|
|
data = resp.get_json()['data']
|
|
assert data['token']
|
|
assert len(data['token']) == 32 # 128 bits of randomness, as hex
|
|
# The receipt must NOT hand the payload back: that would put the secret in a
|
|
# second place for no reason.
|
|
assert 'ciphertext' not in data
|
|
|
|
|
|
def test_two_secrets_never_share_a_token(client, auth_headers):
|
|
first = _create(client, auth_headers).get_json()['data']['token']
|
|
second = _create(client, auth_headers).get_json()['data']['token']
|
|
assert first != second
|
|
|
|
|
|
# -------------------------------------------------------------- size limiting
|
|
|
|
def test_something_document_sized_is_refused(client, auth_headers):
|
|
"""The ONLY server-side control a zero-knowledge design leaves available.
|
|
|
|
The key never reaches the server, so it cannot tell a password from a page
|
|
of a drawing - it can only bound how much it will hold.
|
|
"""
|
|
resp = _create(client, auth_headers, ciphertext='A' * (MAX_CIPHERTEXT + 1))
|
|
assert resp.status_code == 400
|
|
body = resp.get_data(as_text=True)
|
|
assert 'too large' in body.lower()
|
|
# The message has to explain what the limit is FOR: somebody hitting it is
|
|
# usually trying to send a file, and the answer is a different tool.
|
|
assert 'document' in body.lower()
|
|
|
|
|
|
def test_a_credential_sized_secret_is_fine(client, auth_headers):
|
|
assert _create(client, auth_headers,
|
|
ciphertext='A' * 200).status_code in (200, 201)
|
|
|
|
|
|
# --------------------------------------------------------------- reading once
|
|
|
|
def test_anyone_can_read_it_without_logging_in(client, auth_headers):
|
|
"""The recipient usually has no account here. The unguessable token is the
|
|
authorisation."""
|
|
token = _create(client, auth_headers).get_json()['data']['token']
|
|
resp = client.post('/api/tools/secrets/%s' % token)
|
|
assert resp.status_code == 200
|
|
assert resp.get_json()['data']['ciphertext'] == CIPHER
|
|
|
|
|
|
def test_the_second_read_finds_nothing(client, auth_headers):
|
|
token = _create(client, auth_headers).get_json()['data']['token']
|
|
assert client.post('/api/tools/secrets/%s' % token).status_code == 200
|
|
assert client.post('/api/tools/secrets/%s' % token).status_code == 404
|
|
|
|
|
|
def test_a_spent_secret_is_deleted_not_just_marked(client, db, auth_headers):
|
|
"""A secret that has been read has no reason to keep its ciphertext."""
|
|
token = _create(client, auth_headers).get_json()['data']['token']
|
|
client.post('/api/tools/secrets/%s' % token)
|
|
assert SecureSecret.query.filter_by(token=token).first() is None
|
|
|
|
|
|
def test_more_than_one_view_can_be_allowed(client, auth_headers):
|
|
token = _create(client, auth_headers, views=2).get_json()['data']['token']
|
|
assert client.post('/api/tools/secrets/%s' % token).status_code == 200
|
|
assert client.post('/api/tools/secrets/%s' % token).status_code == 200
|
|
assert client.post('/api/tools/secrets/%s' % token).status_code == 404
|
|
|
|
|
|
# ------------------------------------------------------- link previews (Teams)
|
|
|
|
def test_a_get_does_not_spend_the_secret(client, auth_headers):
|
|
"""THE case that would break this in practice.
|
|
|
|
Teams, Outlook and Safe Links prefetch GET urls to build previews and scan
|
|
for malware. If a GET redeemed the secret, the preview would consume it and
|
|
the recipient would open the link to find it already used. Redeeming is a
|
|
POST for exactly this reason.
|
|
"""
|
|
token = _create(client, auth_headers).get_json()['data']['token']
|
|
|
|
peek = client.get('/api/tools/secrets/%s' % token)
|
|
assert peek.status_code == 200
|
|
assert peek.get_json()['data']['available'] is True
|
|
assert 'ciphertext' not in peek.get_json()['data']
|
|
|
|
# Still there for the person it was meant for.
|
|
assert client.post('/api/tools/secrets/%s' % token).status_code == 200
|
|
|
|
|
|
def test_peeking_at_a_spent_link_says_so_without_leaking(client, auth_headers):
|
|
token = _create(client, auth_headers).get_json()['data']['token']
|
|
client.post('/api/tools/secrets/%s' % token)
|
|
peek = client.get('/api/tools/secrets/%s' % token)
|
|
assert peek.get_json()['data']['available'] is False
|
|
|
|
|
|
# ------------------------------------------------------------------- expiry
|
|
|
|
def test_an_expired_secret_is_gone(client, db, auth_headers):
|
|
from datetime import datetime, timedelta, timezone
|
|
token = _create(client, auth_headers).get_json()['data']['token']
|
|
secret = SecureSecret.query.filter_by(token=token).first()
|
|
secret.expiresat = (datetime.now(timezone.utc).replace(tzinfo=None)
|
|
- timedelta(minutes=1))
|
|
db.session.commit()
|
|
|
|
assert client.post('/api/tools/secrets/%s' % token).status_code == 404
|
|
assert SecureSecret.query.filter_by(token=token).first() is None
|
|
|
|
|
|
def test_an_absurd_expiry_is_refused(client, auth_headers):
|
|
assert _create(client, auth_headers, expiryhours=10000).status_code == 400
|
|
assert _create(client, auth_headers, expiryhours=0).status_code == 400
|
|
|
|
|
|
# ------------------------------------------------------------ not enumerable
|
|
|
|
def test_an_unknown_token_answers_the_same_as_a_spent_one(client, auth_headers):
|
|
"""Distinguishing them would confirm to anyone holding a guessed token
|
|
whether it ever named anything."""
|
|
token = _create(client, auth_headers).get_json()['data']['token']
|
|
client.post('/api/tools/secrets/%s' % token)
|
|
|
|
spent = client.post('/api/tools/secrets/%s' % token)
|
|
never = client.post('/api/tools/secrets/%s' % ('f' * 32))
|
|
assert spent.status_code == never.status_code == 404
|
|
assert (spent.get_json()['data']['error']['message']
|
|
== never.get_json()['data']['error']['message'])
|
|
|
|
|
|
# ------------------------------------------------- guests, when a site allows
|
|
|
|
def _allow_guests(db, allowed=True):
|
|
from shopdb.core.models import Setting
|
|
row = Setting.query.filter_by(key='securesend_allow_guests').first()
|
|
if row is None:
|
|
row = Setting(key='securesend_allow_guests', valuetype='boolean',
|
|
category='tools')
|
|
db.session.add(row)
|
|
row.value = 'true' if allowed else 'false'
|
|
db.session.commit()
|
|
|
|
|
|
def test_a_guest_is_refused_by_default(client, db):
|
|
"""Default OFF. An unauthenticated write endpoint is not something to get
|
|
by accident."""
|
|
_allow_guests(db, False)
|
|
resp = client.post('/api/tools/secrets', json={'ciphertext': CIPHER, 'iv': IV})
|
|
assert resp.status_code in (401, 403, 422)
|
|
|
|
|
|
def test_a_guest_can_create_once_a_site_allows_it(client, db):
|
|
_allow_guests(db, True)
|
|
resp = client.post('/api/tools/secrets', json={'ciphertext': CIPHER, 'iv': IV})
|
|
assert resp.status_code in (200, 201), resp.get_data(as_text=True)[:300]
|
|
assert resp.get_json()['data']['token']
|
|
|
|
|
|
def test_a_guest_secret_has_no_creator(client, db):
|
|
"""The cost of the setting, pinned: the audit trail can say when and from
|
|
where, but never who."""
|
|
_allow_guests(db, True)
|
|
token = client.post('/api/tools/secrets',
|
|
json={'ciphertext': CIPHER, 'iv': IV}
|
|
).get_json()['data']['token']
|
|
secret = SecureSecret.query.filter_by(token=token).first()
|
|
assert secret.createdbyuserid is None
|
|
|
|
|
|
def test_guest_creation_is_rate_limited(client, db):
|
|
"""An open endpoint must not be an easy place to park data."""
|
|
from plugins.tools.api.routes import GUEST_CREATE_MAX
|
|
_allow_guests(db, True)
|
|
codes = [client.post('/api/tools/secrets',
|
|
json={'ciphertext': CIPHER, 'iv': IV}).status_code
|
|
for _ in range(GUEST_CREATE_MAX + 2)]
|
|
assert 429 in codes, codes
|
|
|
|
|
|
def test_a_signed_in_user_still_needs_the_permission(client, db, member_headers):
|
|
"""Opening the door to guests is a decision about the DOOR, not about
|
|
revoking what a role holds."""
|
|
_allow_guests(db, True)
|
|
resp = client.post('/api/tools/secrets',
|
|
json={'ciphertext': CIPHER, 'iv': IV},
|
|
headers=member_headers)
|
|
assert resp.status_code == 403
|
|
|
|
|
|
# ----------------------------------------------------------------- retention
|
|
|
|
def test_an_expired_secret_nobody_opened_is_swept(client, db, auth_headers):
|
|
"""Nothing removed a secret that was never clicked: the recipient goes on
|
|
holiday and the ciphertext sits there for good."""
|
|
from datetime import datetime, timedelta, timezone
|
|
token = _create(client, auth_headers).get_json()['data']['token']
|
|
secret = SecureSecret.query.filter_by(token=token).first()
|
|
secret.expiresat = (datetime.now(timezone.utc).replace(tzinfo=None)
|
|
- timedelta(hours=1))
|
|
db.session.commit()
|
|
|
|
# Any later create sweeps it, without a scheduler existing anywhere.
|
|
_create(client, auth_headers)
|
|
assert SecureSecret.query.filter_by(token=token).first() is None
|
|
|
|
|
|
def test_a_custom_view_count_is_allowed(client, auth_headers):
|
|
from plugins.tools.api.routes import MAX_VIEWS
|
|
assert _create(client, auth_headers, views=25).status_code in (200, 201)
|
|
assert _create(client, auth_headers, views=MAX_VIEWS).status_code in (200, 201)
|
|
assert _create(client, auth_headers, views=MAX_VIEWS + 1).status_code == 400
|