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.
This commit is contained in:
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user