fix: reject idempotency key payload conflicts
This commit is contained in:
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from ipaddress import IPv4Address, IPv6Address, IPv4Network, IPv6Network
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time as time_module
|
||||
@@ -460,6 +461,7 @@ def create_catch_report(
|
||||
) -> CatchReportAccepted:
|
||||
if payload.website:
|
||||
raise HTTPException(status_code=400, detail="invalid submission")
|
||||
payload_hash = hashlib.sha256(json.dumps(payload.model_dump(mode="json"), sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
# A05: Server-side idempotency — check BEFORE rate limit to avoid polluting table
|
||||
if idempotency_key:
|
||||
key_hash = hmac.new(settings.rate_limit_secret.encode(), idempotency_key.encode(), hashlib.sha256).hexdigest()
|
||||
@@ -476,6 +478,8 @@ def create_catch_report(
|
||||
# Return 200 with idempotent flag — client can retry safely
|
||||
logger.info("idempotent hit", extra={"idempotency_key": idempotency_key[:8]})
|
||||
report = existing.catch_report
|
||||
if existing.payload_hash and not hmac.compare_digest(existing.payload_hash, payload_hash):
|
||||
raise HTTPException(status_code=409, detail="Idempotency-Key was already used with different payload")
|
||||
if report is None:
|
||||
raise HTTPException(status_code=409, detail="idempotency record is incomplete; retry with a new key")
|
||||
# Re-derive the one-time upload token from the idempotency key;
|
||||
@@ -509,7 +513,7 @@ def create_catch_report(
|
||||
# Store idempotency key if provided
|
||||
if idempotency_key:
|
||||
key_hash = hmac.new(settings.rate_limit_secret.encode(), idempotency_key.encode(), hashlib.sha256).hexdigest()
|
||||
db.add(SubmissionAttempt(client_hash="", idempotency_key=key_hash, catch_report_id=report.id, created_at=datetime.now(timezone.utc)))
|
||||
db.add(SubmissionAttempt(client_hash="", idempotency_key=key_hash, catch_report_id=report.id, payload_hash=payload_hash, created_at=datetime.now(timezone.utc)))
|
||||
db.commit()
|
||||
logger.info("idempotency key stored", extra={"idempotency_key": idempotency_key[:8]})
|
||||
return CatchReportAccepted(id=report.id, moderation_status=report.moderation_status.value, screenshot_upload_token=upload_token, idempotent=False)
|
||||
|
||||
@@ -138,6 +138,7 @@ class SubmissionAttempt(Base):
|
||||
client_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||
idempotency_key: Mapped[str | None] = mapped_column(String(128), index=True)
|
||||
catch_report_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("catch_report.id"), nullable=True)
|
||||
payload_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
catch_report: Mapped[CatchReport | None] = relationship()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user