Files
shopdb-flask/tests/test_plugins/test_tools_securesend.py
cproudlock 8750ba2e74
Some checks failed
CI / backend (push) Failing after 7m12s
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / naming (push) Has been cancelled
Let a site open secure send to guests, and choose how many times a link opens
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.
2026-08-21 15:53:30 -04:00

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