fix: the Passage was an unbounded essence faucet
`passage_start` rewinds the rite to `listen` at any time, and PassagePanel offers exactly that button after a resisted release. Every replayed layer re-credited its essence, so one summon funded an endless loop — the 20/60s limiter caps the rate, never the total. Measured at ~110 essence/minute, indefinitely. Each layer now pays the first time it opens for a presence and never again, cleared only by a genuine summon. Re-walking still reveals; it just doesn't mint. The frame reports what was ACTUALLY credited, so the UI's running total can't drift from the ledger. Three further fixes in the same handlers: - judgment -> passage double-paid a crossing. The passage -> cross_over direction was already guarded; the reverse ran free, favor included. - both handlers read `state.entity["id"]` AFTER their DB round-trip. The HTTP telemetry path drives the same SeanceState and can summon concurrently, so a crossing could mark the presence that just arrived. Pinned before the awaits. tests/test_ws_passage.py is new, and covers the gap that let all of this hide: test_passage.py tests the pure module, and nothing exercised these handlers over a real connection. Efficacy proven by reverting the fix — the replay test then reports "minted 280 extra essence". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
294
backend/tests/test_ws_passage.py
Normal file
294
backend/tests/test_ws_passage.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""WS integration tests for the Passage handlers in app.ws.
|
||||
|
||||
Why this file exists: `tests/test_passage.py` covers app/passage.py, which is
|
||||
pure — it decides outcomes and returns them. It cannot see the handler that
|
||||
banks the essence those outcomes describe, and that handler is where an
|
||||
unbounded essence faucet lived undetected through a 400-test suite: nothing
|
||||
exercised `passage_start` / `passage_layer` over a real connection.
|
||||
|
||||
The faucet: `passage_start` rewinds the rite to `listen` at any time, and the
|
||||
UI offers exactly that button after a resisted release (PassagePanel.tsx's
|
||||
"walk it again"). Every replayed layer re-credited its essence, so one summon
|
||||
funded an endless loop. The rate limiter caps how FAST that runs, never how
|
||||
much it totals.
|
||||
|
||||
These tests are written against the exploit path, not against the
|
||||
implementation: they drive the same frames a browser sends and then read the
|
||||
ledger straight out of the database.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
import app.ws
|
||||
from app.entities import fallback_profile
|
||||
from app.inventory import SUMMON_ESSENCE_TRICKLE
|
||||
from app.models.user import User
|
||||
from app.passage import LAYERS, LAYER_ESSENCE
|
||||
from app.rate_limit import RateLimiter
|
||||
|
||||
|
||||
class FakeSpiritService:
|
||||
async def mint_profile(
|
||||
self, signature, channel, anomalies, language="en", entropy=None, sky=None
|
||||
):
|
||||
return fallback_profile(signature)
|
||||
|
||||
async def fragment(self, source, anomaly, language="en"):
|
||||
return "listen"
|
||||
|
||||
async def wire_whisper(self, telemetry, language="en"):
|
||||
return "the wire hums"
|
||||
|
||||
def chat_stream(self, entity, question, history, language="en"):
|
||||
async def gen():
|
||||
yield "here."
|
||||
|
||||
return gen()
|
||||
|
||||
def ambient_ready(self):
|
||||
return False
|
||||
|
||||
|
||||
async def _fake_synth(text, voice, profile, instability=0.0):
|
||||
return b"RIFFfake wav bytes"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fake_spirits(monkeypatch):
|
||||
monkeypatch.setattr(app.ws, "spirit_service", FakeSpiritService())
|
||||
monkeypatch.setattr(app.ws, "synthesize_spirit_voice", _fake_synth)
|
||||
# Module-level limiters are shared singletons that accumulate real hit
|
||||
# counts across the whole session (precedent: test_ws_ritual_judgment.py).
|
||||
# The passage limiter matters most here — the exploit these tests replay
|
||||
# is deliberately high-volume, and the real 20/60s cap would mask it
|
||||
# behind a rate limit rather than letting us observe the actual total.
|
||||
for name in (
|
||||
"summon_limiter",
|
||||
"summon_ip_limiter",
|
||||
"question_limiter",
|
||||
"question_ip_limiter",
|
||||
"fragment_limiter",
|
||||
"fragment_ip_limiter",
|
||||
"ritual_limiter",
|
||||
"ritual_ip_limiter",
|
||||
"judgment_limiter",
|
||||
"judgment_ip_limiter",
|
||||
"passage_limiter",
|
||||
"passage_ip_limiter",
|
||||
):
|
||||
monkeypatch.setattr(app.ws, name, RateLimiter(max_requests=10_000, window_seconds=60))
|
||||
|
||||
|
||||
def _no_twists(monkeypatch):
|
||||
"""Pin both Passage draws to "no twist", leaving every other draw real.
|
||||
|
||||
A PassageDraw of 1.0 never fires, since both twists trigger on
|
||||
`draw < chance`. Only the `passage:` contexts are pinned — blanketing
|
||||
`veil_float` would also steer the summon and manifest draws, and these
|
||||
tests are about the ledger, not about the room's noise.
|
||||
"""
|
||||
real = app.ws.veil_float
|
||||
|
||||
def selective(entropy, context, *args, **kwargs):
|
||||
if context.startswith("passage:"):
|
||||
return 1.0
|
||||
return real(entropy, context, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(app.ws, "veil_float", selective)
|
||||
|
||||
|
||||
# The exploit never reaches `release`. Crossing latches `passage_crossed`,
|
||||
# which correctly blocks replay — the faucet ran on the four beats BEFORE
|
||||
# release, rewound by the "walk it again" button after a resisted release.
|
||||
BEATS_BEFORE_RELEASE = len(LAYERS) - 1
|
||||
|
||||
|
||||
def _read_until(ws, msg_type, max_frames=80, **match):
|
||||
for _ in range(max_frames):
|
||||
frame = ws.receive_json()
|
||||
if frame.get("type") != msg_type:
|
||||
continue
|
||||
if all(frame.get(key) == value for key, value in match.items()):
|
||||
return frame
|
||||
raise AssertionError(f"never saw frame of type {msg_type!r} matching {match!r}")
|
||||
|
||||
|
||||
def _login(sync_client, username):
|
||||
sync_client.post("/auth/register", json={"username": username, "password": "spookyspooky"})
|
||||
sync_client.post("/auth/login", json={"username": username, "password": "spookyspooky"})
|
||||
return sync_client.cookies.get("qm_session")
|
||||
|
||||
|
||||
def _ws_connect(sync_client, token):
|
||||
return sync_client.websocket_connect(
|
||||
"/ws/session", headers={"cookie": f"qm_session={token}"}
|
||||
)
|
||||
|
||||
|
||||
def _summon(ws):
|
||||
ws.send_json({"type": "summon"})
|
||||
frame = _read_until(ws, "entity")
|
||||
_read_until(ws, "utterance", kind="greeting")
|
||||
return frame
|
||||
|
||||
|
||||
def _settle(ws):
|
||||
"""Force a full round trip so any post-frame DB write has landed.
|
||||
|
||||
The essence credit runs before the frame is queued, but the sender task
|
||||
drains that queue concurrently with the handler's remaining awaits. The
|
||||
connection's message loop is sequential, so a pong is a hard guarantee
|
||||
that the previous handler returned — not a poll-and-hope.
|
||||
"""
|
||||
ws.send_json({"type": "ping"})
|
||||
_read_until(ws, "pong")
|
||||
|
||||
|
||||
def _walk(ws, beats):
|
||||
"""Send `beats` passage_layer frames, returning the result frames."""
|
||||
frames = []
|
||||
for _ in range(beats):
|
||||
ws.send_json({"type": "passage_layer"})
|
||||
frames.append(_read_until(ws, "passage_result"))
|
||||
_settle(ws)
|
||||
return frames
|
||||
|
||||
|
||||
async def _essence(db_session, user_id):
|
||||
db_session.expire_all()
|
||||
user = await db_session.get(User, user_id)
|
||||
return user.essence
|
||||
|
||||
|
||||
def _user_id(sync_client, token):
|
||||
return uuid.UUID(
|
||||
sync_client.get("/auth/me", headers={"cookie": f"qm_session={token}"}).json()["id"]
|
||||
)
|
||||
|
||||
|
||||
# --- the faucet ------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replaying_the_rite_never_mints_essence_twice(
|
||||
sync_client, db_session, monkeypatch
|
||||
):
|
||||
"""The exploit, executed literally.
|
||||
|
||||
Walk every layer, hit `passage_start` to rewind, walk them all again —
|
||||
ten times over. The seeker must finish with exactly one rite's worth of
|
||||
essence, because there was only ever one spirit.
|
||||
|
||||
Before the fix this credited the full ladder on every pass; with the
|
||||
ladder summing to 53, ten passes paid 530 instead of 53.
|
||||
"""
|
||||
# No collapses and no crossing: this test is about the ledger, and a
|
||||
# random rewind or an early `crossed` would make the totals depend on
|
||||
# the draw rather than on the replay guard.
|
||||
_no_twists(monkeypatch)
|
||||
|
||||
token = _login(sync_client, "faucet-walker")
|
||||
user_id = _user_id(sync_client, token)
|
||||
|
||||
with _ws_connect(sync_client, token) as ws:
|
||||
_read_until(ws, "session")
|
||||
_summon(ws)
|
||||
baseline = await _essence(db_session, user_id)
|
||||
|
||||
ws.send_json({"type": "passage_start"})
|
||||
_settle(ws)
|
||||
_walk(ws, BEATS_BEFORE_RELEASE)
|
||||
after_one_rite = await _essence(db_session, user_id)
|
||||
|
||||
for _ in range(10):
|
||||
ws.send_json({"type": "passage_start"})
|
||||
_settle(ws)
|
||||
_walk(ws, BEATS_BEFORE_RELEASE)
|
||||
|
||||
after_ten_replays = await _essence(db_session, user_id)
|
||||
|
||||
earned = after_one_rite - baseline
|
||||
assert earned > 0, "the rite paid nothing at all — the test proves nothing"
|
||||
assert earned <= sum(LAYER_ESSENCE.values())
|
||||
assert after_ten_replays == after_one_rite, (
|
||||
f"replaying the rite minted {after_ten_replays - after_one_rite} extra essence; "
|
||||
"the passage is an unbounded faucet again"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_reported_essence_matches_what_was_actually_credited(
|
||||
sync_client, db_session, monkeypatch
|
||||
):
|
||||
"""The UI sums the frame's `essence` field into its running total.
|
||||
|
||||
If a replayed layer reports the layer's face value while the ledger
|
||||
credits nothing, the seeker watches a total climb that their account
|
||||
never receives — the most corrosive kind of bug in a game about trust.
|
||||
"""
|
||||
_no_twists(monkeypatch)
|
||||
|
||||
token = _login(sync_client, "honest-ledger")
|
||||
user_id = _user_id(sync_client, token)
|
||||
|
||||
with _ws_connect(sync_client, token) as ws:
|
||||
_read_until(ws, "session")
|
||||
_summon(ws)
|
||||
baseline = await _essence(db_session, user_id)
|
||||
|
||||
ws.send_json({"type": "passage_start"})
|
||||
_settle(ws)
|
||||
first = _walk(ws, BEATS_BEFORE_RELEASE)
|
||||
|
||||
ws.send_json({"type": "passage_start"})
|
||||
_settle(ws)
|
||||
second = _walk(ws, BEATS_BEFORE_RELEASE)
|
||||
|
||||
final = await _essence(db_session, user_id)
|
||||
|
||||
assert sum(f["essence"] for f in first) == final - baseline
|
||||
assert all(f["essence"] == 0 for f in second), (
|
||||
"a replayed layer advertised essence it did not pay"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_genuinely_new_presence_reopens_the_purse(
|
||||
sync_client, db_session, monkeypatch
|
||||
):
|
||||
"""The guard must not overshoot: each spirit is worth its own rite.
|
||||
|
||||
A `passage_paid` set that survived a summon would silently make every
|
||||
spirit after the first worthless, which is a worse bug than the faucet.
|
||||
"""
|
||||
_no_twists(monkeypatch)
|
||||
|
||||
token = _login(sync_client, "second-spirit")
|
||||
user_id = _user_id(sync_client, token)
|
||||
|
||||
with _ws_connect(sync_client, token) as ws:
|
||||
_read_until(ws, "session")
|
||||
_summon(ws)
|
||||
baseline = await _essence(db_session, user_id)
|
||||
|
||||
ws.send_json({"type": "passage_start"})
|
||||
_settle(ws)
|
||||
_walk(ws, BEATS_BEFORE_RELEASE)
|
||||
after_first = await _essence(db_session, user_id)
|
||||
|
||||
_summon(ws)
|
||||
ws.send_json({"type": "passage_start"})
|
||||
_settle(ws)
|
||||
_walk(ws, BEATS_BEFORE_RELEASE)
|
||||
after_second = await _essence(db_session, user_id)
|
||||
|
||||
first_rite = after_first - baseline
|
||||
# The second summon also pays its own trickle, so compare the rite only.
|
||||
second_rite = after_second - after_first - SUMMON_ESSENCE_TRICKLE
|
||||
assert first_rite > 0
|
||||
assert second_rite == first_rite, (
|
||||
"a fresh presence did not re-open the purse — every spirit after the "
|
||||
"first is worth nothing"
|
||||
)
|
||||
Reference in New Issue
Block a user