From 5b2db1cf485111cc3ff6f4c4f2c7fabaa3c12c17 Mon Sep 17 00:00:00 2001 From: IK Date: Fri, 11 Sep 2026 07:44:07 +0700 Subject: [PATCH] fix: reject idempotency key payload conflicts --- apps/api/alembic/versions/20260910_recovery_columns.py | 2 ++ apps/api/app/main.py | 6 +++++- apps/api/app/models.py | 1 + apps/api/tests/test_api.py | 3 +++ 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/api/alembic/versions/20260910_recovery_columns.py b/apps/api/alembic/versions/20260910_recovery_columns.py index 34bda05..5286164 100644 --- a/apps/api/alembic/versions/20260910_recovery_columns.py +++ b/apps/api/alembic/versions/20260910_recovery_columns.py @@ -14,6 +14,7 @@ depends_on = None def upgrade() -> None: op.add_column("submission_attempt", sa.Column("idempotency_key", sa.String(128), nullable=True)) op.add_column("submission_attempt", sa.Column("catch_report_id", sa.Uuid(), nullable=True)) + op.add_column("submission_attempt", sa.Column("payload_hash", sa.String(64), nullable=True)) op.create_foreign_key("fk_submission_attempt_report", "submission_attempt", "catch_report", ["catch_report_id"], ["id"]) op.create_index("ix_submission_attempt_idempotency_key", "submission_attempt", ["idempotency_key"], unique=True) op.add_column("import_record_event", sa.Column("changes", sa.JSON(), nullable=True)) @@ -25,4 +26,5 @@ def downgrade() -> None: op.drop_index("ix_submission_attempt_idempotency_key", table_name="submission_attempt") op.drop_constraint("fk_submission_attempt_report", "submission_attempt", type_="foreignkey") op.drop_column("submission_attempt", "catch_report_id") + op.drop_column("submission_attempt", "payload_hash") op.drop_column("submission_attempt", "idempotency_key") diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 92b8825..46ec460 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -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) diff --git a/apps/api/app/models.py b/apps/api/app/models.py index dd0c9d0..db6b354 100644 --- a/apps/api/app/models.py +++ b/apps/api/app/models.py @@ -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() diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index 71af93a..0a9f482 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -441,3 +441,6 @@ def test_catch_report_idempotency_key_prevents_duplicates(monkeypatch) -> None: assert second.json()["idempotent"] is True assert second.json()["id"] == report_id assert second.json()["screenshot_upload_token"] == first.json()["screenshot_upload_token"] + changed = dict(payload, weight_g=7800) + conflict = client.post("/api/v1/catch-reports", json=changed, headers=headers) + assert conflict.status_code == 409