Unbounded essence/item farming: every other reward trigger (summon,
question, fragment) had both a per-user and per-IP limiter, but
ritual_start and judgment had none at all — and judgment has no
"already resolved" state either. A scripted client could replay
{"type":"judgment","verdict":"cross_over"} in a tight loop and mint
CROSS_OVER_ESSENCE (25) plus a 20% item roll every iteration, forever.
Same for ritual_start -> 4x ritual_step. Added ritual/judgment limiters
in both flavors, matching the existing pattern.
WS session crash: _handle_question did `if state.entity is None:
await _handle_summon(state)` then `assert state.entity is not None`.
_handle_summon returns early *without* setting state.entity when the
seeker is rate-limited, so the assert fired unhandled — and the message
loop only catches WebSocketDisconnect, so it killed the whole connection.
Reachable with no malice: click summon a few times impatiently, then ask a
question. Now returns cleanly (the rate_limited frame was already sent).
Essence double-spend: purchase_unlock() deliberately uses SELECT ... FOR
UPDATE to serialize concurrent purchases, but the three credit_essence
call sites in ws.py did an unlocked db.get() read-modify-write. An
unlocked read doesn't block on a row lock, so a reward computed from a
pre-purchase balance could be written after the purchase committed,
silently reverting the deduction — user keeps the unlock and the essence.
All three now lock the row the same way.
Entity mint collision: _summon does a racy check-then-insert against
Entity.signature and Entity.name, both DB-unique, with no IntegrityError
handling — a concurrent mint of the same signature crashed the session.
Forceable by a user with two accounts (anomaly frequency/magnitude are
client-controlled), and plausible without malice in wire mode, where
sample_network() reads host-wide /proc/net/dev counters so two idle
sessions genuinely measure the same traffic. Now retries once, which
re-runs the match against whatever the winner committed.
Also added a unique constraint on unlocks(user_id, unlock_key) as
defense-in-depth, with an idempotent catalog-guarded migration.
221 backend tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
31 lines
1.2 KiB
Python
31 lines
1.2 KiB
Python
import uuid
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from app.db import Base
|
|
|
|
|
|
class UnlockRecord(Base):
|
|
"""A permanent unlock a seeker has purchased with essence (e.g. the
|
|
listening tool). One row per (user, unlock_key).
|
|
|
|
The unique constraint is defense-in-depth: today, idempotency (no double
|
|
charge for an already-owned unlock) is enforced entirely by
|
|
`app.inventory.purchase_unlock`'s row-locked check-then-insert. This
|
|
constraint means any *other* code path that ever inserts an
|
|
UnlockRecord without going through that lock still can't create a
|
|
duplicate row for the same (user, unlock_key).
|
|
"""
|
|
|
|
__tablename__ = "unlocks"
|
|
__table_args__ = (UniqueConstraint("user_id", "unlock_key", name="uq_unlocks_user_key"),)
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
|
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True)
|
|
unlock_key: Mapped[str] = mapped_column(String(64), index=True)
|
|
unlocked_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
|
)
|