fix: replay idempotent upload token safely
This commit is contained in:
@@ -478,7 +478,12 @@ def create_catch_report(
|
|||||||
report = existing.catch_report
|
report = existing.catch_report
|
||||||
if report is None:
|
if report is None:
|
||||||
raise HTTPException(status_code=409, detail="idempotency record is incomplete; retry with a new key")
|
raise HTTPException(status_code=409, detail="idempotency record is incomplete; retry with a new key")
|
||||||
return JSONResponse(status_code=200, content={"id": str(report.id), "moderation_status": report.moderation_status.value, "screenshot_upload_token": "", "idempotent": True})
|
# Re-derive the one-time upload token from the idempotency key;
|
||||||
|
# only its hash is persisted, so the secret is never stored.
|
||||||
|
replay_token = hmac.new(settings.rate_limit_secret.encode(), (key_hash + ":upload").encode(), hashlib.sha256).hexdigest()
|
||||||
|
if not hmac.compare_digest(hashlib.sha256(replay_token.encode()).hexdigest(), report.screenshot_upload_token_hash or ""):
|
||||||
|
raise HTTPException(status_code=409, detail="idempotency record token mismatch; retry with a new key")
|
||||||
|
return JSONResponse(status_code=200, content={"id": str(report.id), "moderation_status": report.moderation_status.value, "screenshot_upload_token": replay_token, "idempotent": True})
|
||||||
logger.info("idempotency check miss", extra={"idempotency_key": idempotency_key[:8]})
|
logger.info("idempotency check miss", extra={"idempotency_key": idempotency_key[:8]})
|
||||||
_check_rate_limit(request, db)
|
_check_rate_limit(request, db)
|
||||||
fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug))
|
fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug))
|
||||||
@@ -496,7 +501,8 @@ def create_catch_report(
|
|||||||
if bait is None:
|
if bait is None:
|
||||||
bait = Bait(name=payload.bait_name.strip(), normalized_name=key, kind=BaitKind.unknown)
|
bait = Bait(name=payload.bait_name.strip(), normalized_name=key, kind=BaitKind.unknown)
|
||||||
db.add(bait)
|
db.add(bait)
|
||||||
upload_token = secrets.token_urlsafe(32)
|
upload_token = (hmac.new(settings.rate_limit_secret.encode(), (key_hash + ":upload").encode(), hashlib.sha256).hexdigest()
|
||||||
|
if idempotency_key else secrets.token_urlsafe(32))
|
||||||
report = CatchReport(fish=fish, spot=spot, waterbody=waterbody, bait=bait, weight_g=payload.weight_g, fishing_method=payload.fishing_method, rig_type=payload.rig_type, retrieve_method=payload.retrieve_method, retrieve_speed=payload.retrieve_speed, caught_at=payload.caught_at, reported_at=datetime.now(timezone.utc), player_name=payload.player_name, source_type=SourceType.user, source_url=payload.source_url, source_confidence=60, moderation_status=ModerationStatus.pending, raw_payload={"comment": payload.comment} if payload.comment else None, screenshot_upload_token_hash=hashlib.sha256(upload_token.encode()).hexdigest())
|
report = CatchReport(fish=fish, spot=spot, waterbody=waterbody, bait=bait, weight_g=payload.weight_g, fishing_method=payload.fishing_method, rig_type=payload.rig_type, retrieve_method=payload.retrieve_method, retrieve_speed=payload.retrieve_speed, caught_at=payload.caught_at, reported_at=datetime.now(timezone.utc), player_name=payload.player_name, source_type=SourceType.user, source_url=payload.source_url, source_confidence=60, moderation_status=ModerationStatus.pending, raw_payload={"comment": payload.comment} if payload.comment else None, screenshot_upload_token_hash=hashlib.sha256(upload_token.encode()).hexdigest())
|
||||||
db.add(report)
|
db.add(report)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -439,3 +439,5 @@ def test_catch_report_idempotency_key_prevents_duplicates(monkeypatch) -> None:
|
|||||||
second = client.post("/api/v1/catch-reports", json=payload, headers=headers)
|
second = client.post("/api/v1/catch-reports", json=payload, headers=headers)
|
||||||
assert second.status_code == 200, f"Expected 200, got {second.status_code}. Response: {second.json()}"
|
assert second.status_code == 200, f"Expected 200, got {second.status_code}. Response: {second.json()}"
|
||||||
assert second.json()["idempotent"] is True
|
assert second.json()["idempotent"] is True
|
||||||
|
assert second.json()["id"] == report_id
|
||||||
|
assert second.json()["screenshot_upload_token"] == first.json()["screenshot_upload_token"]
|
||||||
|
|||||||
Reference in New Issue
Block a user