Files
tiennm99 81234b5ab9 fix(wsapi): stop a kicked token resuming into another seat, harden limits
A held seat now remembers which connection it waits for, so a token
from a kicked player is refused instead of landing in whoever took the
seat. A room answers every input left in its inbox when it exits,
disconnect notices no longer share the lossy inbox, quick-match no
longer leaves autoStart armed after a pairing that never started, only
lobby changes restart the idle window, and draining refuses new queue
entries.

Sockets that never send Hello close after ten seconds and the room
budget is charged per address, so one client cannot hold the global
caps. IPv6 limiter keys use the /64. Corpus log lines get a process-wide
rate limit with a suppressed counter. Responses carry nosniff,
frame-ancestors and referrer headers. Unicode spaces in a word become
spaces rather than vanishing, and blank-rendering letters are dropped
from names and chat. A resume into a lobby whose game ended during the
absence is replayed that seat's GameOver. Unknown payloads get
unknown_message.
2026-09-29 20:33:16 +07:00

160 lines
5.7 KiB
Go

package wsapi
import (
"slices"
"strings"
"testing"
"unicode"
"unicode/utf8"
)
// FuzzSanitizeText guards the filter every player-supplied string crosses
// before it is shown to a stranger: a nickname, a chat line and a submitted
// word all go through it. The invariants are the ones sanitizeText's own
// comment promises — no control or format character survives, no rune count
// over the cap, no run of combining marks longer than the cap allows — plus
// the one every caller depends on implicitly: it never panics on arbitrary
// input, however malformed.
//
// Bounds are fixed at the nickname's rather than fuzzed themselves: every real
// call site passes a compile-time constant, so a bound the fuzzer chooses
// would be exercising a contract sanitizeText was never asked to keep.
func FuzzSanitizeText(f *testing.F) {
seeds := []string{
"Minh",
"Nguyễn Thuý",
"",
" \t\n ",
"Mi\x00nh\x07",
"Mi\u200bnh\u200d",
"Minh\u202e",
" Minh Nguyen ",
"Minh\nNguyen",
strings.Repeat("a", 40),
strings.Repeat("ữ", maxNicknameRunes),
"Minh" + strings.Repeat(string(stackingMark), 50),
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, raw string) {
got := sanitizeText(raw, maxNicknameRunes, maxNicknameMarks)
if n := utf8.RuneCountInString(got); n > maxNicknameRunes {
t.Fatalf("sanitizeText(%q) kept %d runes, want at most %d", raw, n, maxNicknameRunes)
}
marks := 0
for _, r := range got {
switch {
case unicode.IsControl(r), unicode.Is(unicode.Cf, r):
t.Fatalf("sanitizeText(%q) = %q kept a control/format character %U", raw, got, r)
case !unicode.IsPrint(r):
t.Fatalf("sanitizeText(%q) = %q kept a non-printing character %U", raw, got, r)
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Me, r):
marks++
if marks > maxNicknameMarks {
t.Fatalf("sanitizeText(%q) = %q stacked %d combining marks, want at most %d", raw, got, marks, maxNicknameMarks)
}
default:
marks = 0
}
}
})
}
func TestSanitizeNickname(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"plain", "Minh", "Minh"},
{"vietnamese kept", "Nguyễn Thuý", "Nguyễn Thuý"},
{"empty falls back", "", defaultNickname},
{"whitespace only falls back", " \t\n ", defaultNickname},
{"control characters stripped", "Mi\x00nh\x07", "Minh"},
{"zero width stripped", "Mi\u200bnh\u200d", "Minh"},
{"bidi override stripped", "Minh\u202e", "Minh"},
{"whitespace collapsed", " Minh Nguyen ", "Minh Nguyen"},
{"newlines become spaces", "Minh\nNguyen", "Minh Nguyen"},
{"over length truncated", strings.Repeat("a", 40), strings.Repeat("a", maxNicknameRunes)},
{"no-break space separates", "Minh\u00a0Nguyen", "Minh Nguyen"},
{"blank-rendering letters alone fall back", "\u3164\u115f\u1160\u2800\uffa0", defaultNickname},
{"blank-rendering letters stripped from a name", "Mi\u3164nh\u2800", "Minh"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := sanitizeNickname(tc.in); got != tc.want {
t.Errorf("sanitizeNickname(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// TestSanitizeNicknameCountsRunesNotBytes guards the cap against being applied
// in bytes, which would cut a Vietnamese name to a third of the length a Latin
// one keeps.
func TestSanitizeNicknameCountsRunesNotBytes(t *testing.T) {
in := strings.Repeat("ữ", maxNicknameRunes)
got := sanitizeNickname(in)
if n := len([]rune(got)); n != maxNicknameRunes {
t.Errorf("kept %d runes, want %d (byte-based truncation?)", n, maxNicknameRunes)
}
}
// TestDistinguishSeparatesIdenticalNames covers the collision the fallback
// creates — players who all send nothing — and the one it always could:
// players who choose the same name.
func TestDistinguishSeparatesIdenticalNames(t *testing.T) {
if got := distinguish("Minh", []string{"Thuý"}); got != "Minh" {
t.Errorf("distinct names should be left alone, got %q", got)
}
// A whole room of unnamed players, seated one at a time. Every one of them
// has to end up with a name none of the others is already using: two
// suffixed identically is the same failure as two unsuffixed.
var taken []string
for range maxPlayers {
got := distinguish(defaultNickname, taken)
if slices.Contains(taken, got) {
t.Fatalf("distinguish returned %q, which is already in %v", got, taken)
}
if n := len([]rune(got)); n > maxNicknameRunes {
t.Errorf("distinguished name %q is %d runes, over the %d cap", got, n, maxNicknameRunes)
}
taken = append(taken, got)
}
// The suffix must not push a name that is already at the cap past it.
long := strings.Repeat("a", maxNicknameRunes)
if got := distinguish(long, []string{long}); len([]rune(got)) > maxNicknameRunes {
t.Errorf("distinguished name is %d runes, over the %d cap", len([]rune(got)), maxNicknameRunes)
}
}
// TestSanitizeTextMapsEverySpaceToASpace: an IME or a paste can put a
// no-break, thin or ideographic space between syllables, and deleting it
// would hand the engine "ngữpháp" for "ngữ pháp".
func TestSanitizeTextMapsEverySpaceToASpace(t *testing.T) {
for _, sp := range []string{"\u00a0", "\u2009", "\u202f", "\u3000", "\u2003", "\u0085", "\u2028", "\v", "\f"} {
in := "ngữ" + sp + "pháp"
if got := sanitizeText(in, maxWordRunes, maxNicknameMarks); got != "ngữ pháp" {
t.Errorf("sanitizeText(%q) = %q, want two syllables", in, got)
}
}
}
// TestBlankLettersAreDropped covers the printable characters that draw
// nothing, which IsPrint accepts.
func TestBlankLettersAreDropped(t *testing.T) {
for r := range blankLetters {
in := "a" + string(r) + "b"
if got := sanitizeText(in, maxChatRunes, maxChatMarks); got != "ab" {
t.Errorf("sanitizeText(%q) = %q, want the %U dropped", in, got, r)
}
}
}