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:
@@ -18,6 +18,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] = {}
|
||||
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
|
||||
@@ -11,12 +11,23 @@ from shared.browser.app.payments.sumup import create_checkout as sumup_create_ch
|
||||
from shared.config import config
|
||||
|
||||
from shared.infrastructure.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
from bp.cart.services import check_sumup_status
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
from .filters.qs import makeqs_factory, decode
|
||||
|
||||
|
||||
def _owner_filter():
|
||||
"""Return SQLAlchemy clause restricting orders to current user/session."""
|
||||
ident = current_cart_identity()
|
||||
if ident["user_id"]:
|
||||
return Order.user_id == ident["user_id"]
|
||||
if ident["session_id"]:
|
||||
return Order.session_id == ident["session_id"]
|
||||
return None
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("order", __name__, url_prefix='/<int:order_id>')
|
||||
|
||||
@@ -32,12 +43,15 @@ def register() -> Blueprint:
|
||||
"""
|
||||
Show a single order + items.
|
||||
"""
|
||||
owner = _owner_filter()
|
||||
if owner is None:
|
||||
return await make_response("Order not found", 404)
|
||||
result = await g.s.execute(
|
||||
select(Order)
|
||||
.options(
|
||||
selectinload(Order.items).selectinload(OrderItem.product)
|
||||
)
|
||||
.where(Order.id == order_id)
|
||||
.where(Order.id == order_id, owner)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
@@ -58,7 +72,10 @@ def register() -> Blueprint:
|
||||
If already paid, just go back to the order detail.
|
||||
If not, (re)create a SumUp checkout and redirect.
|
||||
"""
|
||||
result = await g.s.execute(select(Order).where(Order.id == order_id))
|
||||
owner = _owner_filter()
|
||||
if owner is None:
|
||||
return await make_response("Order not found", 404)
|
||||
result = await g.s.execute(select(Order).where(Order.id == order_id, owner))
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
return await make_response("Order not found", 404)
|
||||
@@ -115,7 +132,10 @@ def register() -> Blueprint:
|
||||
Manually re-check this order's status with SumUp.
|
||||
Useful if the webhook hasn't fired or the user didn't return correctly.
|
||||
"""
|
||||
result = await g.s.execute(select(Order).where(Order.id == order_id))
|
||||
owner = _owner_filter()
|
||||
if owner is None:
|
||||
return await make_response("Order not found", 404)
|
||||
result = await g.s.execute(select(Order).where(Order.id == order_id, owner))
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
return await make_response("Order not found", 404)
|
||||
|
||||
@@ -11,6 +11,7 @@ from shared.browser.app.payments.sumup import create_checkout as sumup_create_ch
|
||||
from shared.config import config
|
||||
|
||||
from shared.infrastructure.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
from bp.cart.services import check_sumup_status
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from bp import register_order
|
||||
@@ -42,9 +43,25 @@ def register(url_prefix: str) -> Blueprint:
|
||||
# this is the crucial bit for the |qs filter
|
||||
g.makeqs_factory = makeqs_factory
|
||||
|
||||
@bp.before_request
|
||||
async def _require_identity():
|
||||
"""Orders require a logged-in user or at least a cart session."""
|
||||
ident = current_cart_identity()
|
||||
if not ident["user_id"] and not ident["session_id"]:
|
||||
return redirect(url_for("auth.login_form"))
|
||||
|
||||
@bp.get("/")
|
||||
async def list_orders():
|
||||
|
||||
|
||||
# --- ownership: only show orders belonging to current user/session ---
|
||||
ident = current_cart_identity()
|
||||
if ident["user_id"]:
|
||||
owner_clause = Order.user_id == ident["user_id"]
|
||||
elif ident["session_id"]:
|
||||
owner_clause = Order.session_id == ident["session_id"]
|
||||
else:
|
||||
return redirect(url_for("auth.login_form"))
|
||||
|
||||
# --- decode filters from query string (page + search) ---
|
||||
q = decode()
|
||||
page, search = q.page, q.search
|
||||
@@ -97,8 +114,8 @@ def register(url_prefix: str) -> Blueprint:
|
||||
|
||||
where_clause = or_(*conditions)
|
||||
|
||||
# --- total count & total pages (respecting search) ---
|
||||
count_stmt = select(func.count()).select_from(Order)
|
||||
# --- total count & total pages (respecting search + ownership) ---
|
||||
count_stmt = select(func.count()).select_from(Order).where(owner_clause)
|
||||
if where_clause is not None:
|
||||
count_stmt = count_stmt.where(where_clause)
|
||||
|
||||
@@ -110,10 +127,11 @@ def register(url_prefix: str) -> Blueprint:
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
|
||||
# --- paginated orders (respecting search) ---
|
||||
# --- paginated orders (respecting search + ownership) ---
|
||||
offset = (page - 1) * ORDERS_PER_PAGE
|
||||
stmt = (
|
||||
select(Order)
|
||||
.where(owner_clause)
|
||||
.order_by(Order.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(ORDERS_PER_PAGE)
|
||||
|
||||
Reference in New Issue
Block a user