Security audit: fix IDOR, add rate limiting, HMAC auth, token hashing, XSS sanitization

Critical: Add ownership checks to all order routes (IDOR fix).
High: Redis rate limiting on auth endpoints, HMAC-signed internal
service calls replacing header-presence-only checks, nh3 HTML
sanitization on ghost_sync and product import, internal auth on
market API endpoints, SHA-256 hashed OAuth grant/code tokens.
Medium: SECRET_KEY production guard, AP signature enforcement,
is_admin param removal, cart_sid validation, SSRF protection on
remote actor fetch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 13:30:27 +00:00
parent 404449fcab
commit c015f3f02f
27 changed files with 607 additions and 33 deletions

View File

@@ -26,9 +26,10 @@ from sqlalchemy.exc import SQLAlchemyError
from shared.db.session import get_session
from shared.models import User
from shared.models.oauth_code import OAuthCode
from shared.models.oauth_grant import OAuthGrant
from shared.models.oauth_grant import OAuthGrant, hash_token
from shared.infrastructure.urls import account_url, app_url
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.rate_limit import rate_limit, check_poll_backoff
from shared.events import emit_activity
from .services import (
@@ -98,7 +99,8 @@ def register(url_prefix="/auth"):
async with get_session() as s:
async with s.begin():
grant = OAuthGrant(
token=grant_token,
token=None,
token_hash=hash_token(grant_token),
user_id=g.user.id,
client_id=client_id,
issuer_session=account_sid,
@@ -107,12 +109,14 @@ def register(url_prefix="/auth"):
s.add(grant)
oauth_code = OAuthCode(
code=code,
code=None,
code_hash=hash_token(code),
user_id=g.user.id,
client_id=client_id,
redirect_uri=redirect_uri,
expires_at=expires,
grant_token=grant_token,
grant_token=None,
grant_token_hash=hash_token(grant_token),
)
s.add(oauth_code)
@@ -149,11 +153,15 @@ def register(url_prefix="/auth"):
now = datetime.now(timezone.utc)
code_h = hash_token(code)
async with get_session() as s:
async with s.begin():
# Look up by hash first (new grants), fall back to plaintext (migration)
result = await s.execute(
select(OAuthCode)
.where(OAuthCode.code == code)
.where(
(OAuthCode.code_hash == code_h) | (OAuthCode.code == code)
)
.with_for_update()
)
oauth_code = result.scalar_one_or_none()
@@ -197,9 +205,12 @@ def register(url_prefix="/auth"):
if not token:
return jsonify({"valid": False}), 200
token_h = hash_token(token)
async with get_session() as s:
grant = await s.scalar(
select(OAuthGrant).where(OAuthGrant.token == token)
select(OAuthGrant).where(
(OAuthGrant.token_hash == token_h) | (OAuthGrant.token == token)
)
)
if not grant or grant.revoked_at is not None:
return jsonify({"valid": False}), 200
@@ -257,12 +268,19 @@ def register(url_prefix="/auth"):
store_login_redirect_target()
cross_cart_sid = request.args.get("cart_sid")
if cross_cart_sid:
qsession["cart_sid"] = cross_cart_sid
import re
# Validate cart_sid is a hex token (32 chars from token_hex(16))
if re.fullmatch(r"[0-9a-f]{32}", cross_cart_sid):
qsession["cart_sid"] = cross_cart_sid
if g.get("user"):
redirect_url = pop_login_redirect_target()
return redirect(redirect_url)
return await render_template("auth/login.html")
@rate_limit(
key_func=lambda: request.headers.get("X-Forwarded-For", request.remote_addr),
max_requests=10, window_seconds=900, scope="magic_ip",
)
@auth_bp.post("/start/")
async def start_login():
form = await request.form
@@ -279,6 +297,22 @@ def register(url_prefix="/auth"):
400,
)
# Per-email rate limit: 5 magic links per 15 minutes
from shared.infrastructure.rate_limit import _check_rate_limit
try:
allowed, _ = await _check_rate_limit(f"magic_email:{email}", 5, 900)
if not allowed:
return (
await render_template(
"auth/check_email.html",
email=email,
email_error=None,
),
200,
)
except Exception:
pass # Redis down — allow the request
user = await find_or_create_user(g.s, email)
token, expires = await create_magic_link(g.s, user.id)
@@ -521,7 +555,8 @@ def register(url_prefix="/auth"):
async with get_session() as s:
async with s.begin():
grant = OAuthGrant(
token=grant_token,
token=None,
token_hash=hash_token(grant_token),
user_id=user.id,
client_id=blob["client_id"],
issuer_session=account_sid,
@@ -546,6 +581,10 @@ def register(url_prefix="/auth"):
return True
@rate_limit(
key_func=lambda: request.headers.get("X-Forwarded-For", request.remote_addr),
max_requests=10, window_seconds=3600, scope="dev_auth",
)
@csrf_exempt
@auth_bp.post("/device/authorize")
@auth_bp.post("/device/authorize/")
@@ -600,6 +639,14 @@ def register(url_prefix="/auth"):
if not device_code or client_id not in ALLOWED_CLIENTS:
return jsonify({"error": "invalid_request"}), 400
# Enforce polling backoff per RFC 8628
try:
poll_ok, interval = await check_poll_backoff(device_code)
if not poll_ok:
return jsonify({"error": "slow_down", "interval": interval}), 400
except Exception:
pass # Redis down — allow the request
from shared.infrastructure.auth_redis import get_auth_redis
r = await get_auth_redis()