Security audit: fix IDOR, add rate limiting, HMAC auth, token hashing, XSS sanitization
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m22s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m22s
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:
86
account/alembic/versions/0002_hash_oauth_tokens.py
Normal file
86
account/alembic/versions/0002_hash_oauth_tokens.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Add token_hash columns to oauth_grants and oauth_codes
|
||||
|
||||
Revision ID: acct_0002
|
||||
Revises: acct_0001
|
||||
Create Date: 2026-02-26
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "acct_0002"
|
||||
down_revision = "acct_0001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _hash(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Add new hash columns
|
||||
op.add_column("oauth_grants", sa.Column("token_hash", sa.String(64), nullable=True))
|
||||
op.add_column("oauth_codes", sa.Column("code_hash", sa.String(64), nullable=True))
|
||||
op.add_column("oauth_codes", sa.Column("grant_token_hash", sa.String(64), nullable=True))
|
||||
|
||||
# Backfill hashes from existing plaintext tokens
|
||||
conn = op.get_bind()
|
||||
grants = conn.execute(sa.text("SELECT id, token FROM oauth_grants WHERE token IS NOT NULL"))
|
||||
for row in grants:
|
||||
conn.execute(
|
||||
sa.text("UPDATE oauth_grants SET token_hash = :h WHERE id = :id"),
|
||||
{"h": _hash(row.token), "id": row.id},
|
||||
)
|
||||
|
||||
codes = conn.execute(sa.text("SELECT id, code, grant_token FROM oauth_codes WHERE code IS NOT NULL"))
|
||||
for row in codes:
|
||||
params = {"id": row.id, "ch": _hash(row.code)}
|
||||
params["gh"] = _hash(row.grant_token) if row.grant_token else None
|
||||
conn.execute(
|
||||
sa.text("UPDATE oauth_codes SET code_hash = :ch, grant_token_hash = :gh WHERE id = :id"),
|
||||
params,
|
||||
)
|
||||
|
||||
# Create unique indexes on hash columns
|
||||
op.create_index("ix_oauth_grant_token_hash", "oauth_grants", ["token_hash"], unique=True)
|
||||
op.create_index("ix_oauth_code_code_hash", "oauth_codes", ["code_hash"], unique=True)
|
||||
|
||||
# Make original token columns nullable (keep for rollback safety)
|
||||
op.alter_column("oauth_grants", "token", nullable=True)
|
||||
op.alter_column("oauth_codes", "code", nullable=True)
|
||||
|
||||
# Drop old unique indexes on plaintext columns
|
||||
try:
|
||||
op.drop_index("ix_oauth_grant_token", "oauth_grants")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
op.drop_index("ix_oauth_code_code", "oauth_codes")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Restore original NOT NULL constraints
|
||||
op.alter_column("oauth_grants", "token", nullable=False)
|
||||
op.alter_column("oauth_codes", "code", nullable=False)
|
||||
|
||||
# Drop hash columns and indexes
|
||||
try:
|
||||
op.drop_index("ix_oauth_grant_token_hash", "oauth_grants")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
op.drop_index("ix_oauth_code_code_hash", "oauth_codes")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
op.drop_column("oauth_grants", "token_hash")
|
||||
op.drop_column("oauth_codes", "code_hash")
|
||||
op.drop_column("oauth_codes", "grant_token_hash")
|
||||
|
||||
# Restore original unique indexes
|
||||
op.create_index("ix_oauth_grant_token", "oauth_grants", ["token"], unique=True)
|
||||
op.create_index("ix_oauth_code_code", "oauth_codes", ["code"], unique=True)
|
||||
@@ -17,6 +17,9 @@ def register() -> Blueprint:
|
||||
async def _require_action_header():
|
||||
if not request.headers.get(ACTION_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -19,6 +19,9 @@ def register() -> Blueprint:
|
||||
async def _require_data_header():
|
||||
if not request.headers.get(DATA_HEADER):
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
from shared.infrastructure.internal_auth import validate_internal_request
|
||||
if not validate_internal_request():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user