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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user