Defensive coding: sanitize free text, constrain inputs

Player-authored free text is scrubbed at the API boundary before storage via a
new validation.py: drop control characters and unpaired surrogates, trim
whitespace, and cap length (names 120, other text 2000). Applied to crew/player
names, the character sheet, like/hate answers, techniques, recruit techniques,
the renamed Name, the Gat description, and challenge stakes. Values matched
against stored text (swap offers, face-card assignments) and identifiers/enums
are left untouched so they still compare equal.

Constrained set-role to the two real roles so it can't stash an arbitrary string
in the column. Audited the rest: queries are parameterized by SQLModel (the lone
f-string SQL in database.py is the hardcoded legacy-migration list, no user
input); objective-type and rollback writes already whitelist their keys; no
endpoint accepts a generic (field, value) write.

Tests cover the sanitizer (control chars, surrogates, emoji, caps) and the
create/join HTTP path end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 15:54:21 -07:00
parent ccb32e7103
commit 122e66b817
9 changed files with 120 additions and 14 deletions

View File

@@ -2471,3 +2471,57 @@ def test_purge_old_games(session):
assert summary["reason"] == "finished"
assert summary["crew_name"] == "DoneOld"
assert summary["estimated_bytes"] > 0
def test_sanitize_text_and_name():
from pirats.validation import (
sanitize_text, sanitize_name, MAX_TEXT_LENGTH, MAX_NAME_LENGTH,
)
# Control characters dropped, surrounding whitespace trimmed.
assert sanitize_text(" hi\x00\x07there ") == "hithere"
# Tabs/newlines kept inside the text.
assert sanitize_text("a\nb\tc") == "a\nb\tc"
# Zero-width joiner (emoji sequences) survives.
assert sanitize_text("ab") == "ab"
# Lone surrogate removed without raising.
assert sanitize_text("a" + chr(0xD800) + "b") == "ab"
# Length capped.
assert len(sanitize_text("x" * (MAX_TEXT_LENGTH + 50))) == MAX_TEXT_LENGTH
# Empty / falsy in -> empty out.
assert sanitize_text("") == ""
# Names collapse internal whitespace and use the tighter cap.
assert sanitize_name(" Captain Redbeard\n ") == "Captain Redbeard"
assert len(sanitize_name("y" * (MAX_NAME_LENGTH + 10))) == MAX_NAME_LENGTH
def test_create_and_join_sanitize_names():
from fastapi.testclient import TestClient
from sqlalchemy.pool import StaticPool
from pirats.main import app
from pirats.database import get_session
from sqlmodel import SQLModel, create_engine, Session
engine = create_engine(
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool,
)
SQLModel.metadata.create_all(engine)
session = Session(engine)
def override():
yield session
app.dependency_overrides[get_session] = override
client = TestClient(app)
try:
r = client.post("/api/game", data={"crew_name": " The\x00 Salty\x07 Dogs "})
gid = r.json()["id"]
assert session.get(Game, gid).crew_name == "The Salty Dogs"
r = client.post(f"/api/game/{gid}/join", data={"name": " Bad\x01name "})
player = session.get(Player, r.json()["id"])
assert player.player_name == "Badname"
assert "\x01" not in player.name
finally:
app.dependency_overrides.clear()