Secure send: guests by setting, and a custom view count
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