Secure send: guests by setting, and a custom view count

This commit is contained in:
2026-08-21 15:53:42 -04:00
parent 21af2731f3
commit 0b4a1db842
4 changed files with 247 additions and 24 deletions

View File

@@ -6,9 +6,15 @@ on first read needs somewhere to keep the ciphertext until then.
TWO DIFFERENT DOORS, on purpose.
CREATE needs a login and a permission. Anonymous creation would be an
untraceable way to move data out of a system that otherwise records who did
what, and the creator is the only person these rows can ever name.
CREATE needs a LOGIN and nothing more. Sharing a password is something anybody
at the site may legitimately need to do, and a permission somebody has to be
granted first turns a two-minute job into a ticket - which is how people end up
pasting credentials into chat instead.
The login stays, though, and is not a formality: anonymous creation would make
this an unattributable write endpoint that stores arbitrary ciphertext, and the
creator is the only person these rows can ever name. Everybody browsing this app
is already signed in, so requiring it costs nothing.
READ needs neither, because the recipient usually has no account here and the
link is normally handed over in a call or a chat. The unguessable token IS the
@@ -27,12 +33,12 @@ import secrets as pysecrets
from datetime import datetime, timedelta, timezone
from flask import Blueprint, request
from flask_jwt_extended import jwt_required, get_jwt_identity
from flask import current_app
from flask_jwt_extended import jwt_required, get_jwt_identity, current_user
from shopdb.api import (
db,
db, cache, Setting,
success_response, error_response, ErrorCodes,
require_permission,
)
from ..models import SecureSecret
@@ -50,18 +56,113 @@ MAX_CIPHERTEXT = 4096
DEFAULT_EXPIRY_HOURS = 24
MAX_EXPIRY_HOURS = 168
MAX_VIEWS = 5
# Generous, because "how many people need this password" is the site's business
# and not a number worth arguing with. The floor matters more than the ceiling:
# a secret readable an unlimited number of times is a password on a web page.
MAX_VIEWS = 100
# Guest creation, when a site enables it, is an unauthenticated write. Bounded
# per address so it cannot be used to fill a table or park data: a person
# sharing passwords does it a handful of times an hour, not hundreds.
GUEST_CREATE_MAX = 10
GUEST_CREATE_WINDOW_SECONDS = 600
def _utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
def _sweep_expired():
"""Delete secrets whose window has passed, whoever asked.
Redeeming removes a row, and so does touching an expired one - but a secret
nobody ever opens was removed by NOTHING. The recipient goes on holiday, the
link is never clicked, and the ciphertext sits there for good: not a
disclosure risk, since it cannot be read, but it makes "nothing lingers"
untrue and grows the table in exactly the case where the feature failed.
Swept opportunistically rather than on a schedule: creating a secret is
authenticated and infrequent, and an air-gapped site is not going to have a
cron job anybody remembers to add.
"""
stale = SecureSecret.query.filter(SecureSecret.expiresat < _utcnow()).all()
for secret in stale:
db.session.delete(secret)
return len(stale)
def _guests_allowed():
"""May somebody with no account create a secret? Default NO.
Read straight from the row rather than the settings cache: this decides
whether an unauthenticated caller may write, and being wrong on the
permissive side for five minutes is the wrong way round to be wrong.
"""
try:
row = Setting.query.filter_by(key='securesend_allow_guests').first()
except Exception:
# FAIL CLOSED. If the setting cannot be read - the row is missing, the
# table is not there yet on a half-migrated site, the database is
# unhappy - the answer is no. An error must never be the thing that
# opens an unauthenticated write endpoint.
current_app.logger.exception('could not read securesend_allow_guests')
return False
return bool(row and str(row.value).strip().lower() in ('true', '1', 'yes'))
def _guest_ratelimited():
"""Bounded guest creation, per address, in a fixed window.
Same shape as the login limiter. Not a security boundary - a determined
person has more addresses - but it stops an open endpoint being an easy
place to park data or fill a disk.
"""
address = (request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
or request.remote_addr or 'unknown')
bucket = int(_utcnow().timestamp()) // GUEST_CREATE_WINDOW_SECONDS
key = f'securesendguest:{address}:{bucket}'
used = cache.get(key) or 0
if used >= GUEST_CREATE_MAX:
return True
cache.set(key, used + 1, timeout=GUEST_CREATE_WINDOW_SECONDS)
return False
@tools_bp.route('/secrets', methods=['POST'])
@jwt_required()
@require_permission('tools.secrets')
@jwt_required(optional=True)
def create_secret():
"""Store an already-encrypted secret and return its token."""
"""Store an already-encrypted secret and return its token.
OPTIONAL jwt so the SETTING decides rather than the decorator: a guest has
to reach the handler to be told whether this site lets them in.
"""
# WHO MAY CREATE. Signed in: the tools.secrets permission, with the usual
# admin bypass. Not signed in: only when the site has deliberately opened
# it, and then within a rate limit.
# get_jwt_identity(), NOT `current_user is None`: current_user is a
# LocalProxy that is never None itself, so that test silently took the
# signed-in branch for a guest and then blew up dereferencing it.
identity = get_jwt_identity()
if identity is None:
if not _guests_allowed():
return error_response(
ErrorCodes.UNAUTHORIZED,
'Sign in to create a secret link.',
http_code=401)
if _guest_ratelimited():
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Too many links from this address. Try again shortly.',
http_code=429)
elif not current_user.haspermission('tools.secrets'):
return error_response(
ErrorCodes.FORBIDDEN,
'You do not have permission to create secret links.',
http_code=403)
# Tidy up on the way past. Cheap, and it keeps the table bounded without a
# scheduler existing anywhere.
_sweep_expired()
data = request.get_json(silent=True) or {}
ciphertext = (data.get('ciphertext') or '').strip()
iv = (data.get('iv') or '').strip()
@@ -173,4 +274,9 @@ def peek_secret(token):
"""
secret = SecureSecret.query.filter_by(token=(token or '').strip()).first()
live = bool(secret and not secret.is_expired() and not secret.is_spent())
if secret is not None and not live:
# A late click tidies its own row, so a dead secret does not need
# somebody to create a new one before it is cleaned up.
db.session.delete(secret)
db.session.commit()
return success_response({'available': live})