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

158 lines
5.4 KiB
Go

package wsapi
import (
"fmt"
"slices"
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
// maxNicknameRunes caps the display name. Runes, not bytes: a Vietnamese name
// is multi-byte, and a byte cap would cut it far shorter than a Latin one.
const maxNicknameRunes = 20
// maxNicknameMarks is how many combining marks may follow one base rune. Two
// covers every Vietnamese cluster - a vowel can carry a diacritic and a tone
// mark and no more - so anything beyond it is stacking, not writing.
const maxNicknameMarks = 2
// denylisted is the hook for blocking names outright. It is deliberately a
// variable and deliberately empty: v1 has no abuse signal to tune a list
// against, and this way adding one later is a data change in one place rather
// than a new pass through the sanitizer.
var denylisted = func(string) bool { return false }
// sanitizeNickname turns untrusted input into something safe to show a
// stranger.
//
// This is an input-validation boundary, not cosmetics: the result is rendered
// in another player's browser, so anything that could forge UI or hide
// characters has to be gone before it is stored. The order matters —
// normalizing first means the length cap and the character filters see the
// same form the client will render, rather than a decomposed variant that
// counts differently.
//
// When nothing usable survives, the caller gets the generic name. Making that
// unique is the room's job, not this function's: two players can just as
// easily both *choose* "Minh", so the collision has to be resolved where both
// names are known.
func sanitizeNickname(raw string) string {
s := sanitizeText(raw, maxNicknameRunes, maxNicknameMarks)
if s == "" || denylisted(s) {
return defaultNickname
}
return s
}
// sanitizeText is the filter itself, without the nickname policy around it.
//
// Shared with chat, which needs the same guarantees at a different size: text
// that is safe to render in a stranger's browser, on one line, bounded. The
// caller decides the bounds and what an empty result means.
//
// Returns "" when nothing usable survives.
func sanitizeText(raw string, maxRunes, maxMarks int) string {
s := norm.NFC.String(raw)
// Drop anything non-printing. Format characters (Cf) are the important
// case: zero-width joiners and bidi overrides are invisible, so they can
// pad text past a visual check or reverse how it renders.
//
// Every kind of space becomes a plain one first — tab, newline, no-break
// and the wide and thin spaces IMEs and paste introduce — and is collapsed
// below. Deleting them instead would glue "ngữ pháp" typed with a
// no-break space into one syllable.
s = strings.Map(func(r rune) rune {
switch {
case unicode.IsSpace(r):
return ' '
case unicode.IsControl(r), unicode.Is(unicode.Cf, r):
return -1
case blankLetters[r]:
return -1
case !unicode.IsPrint(r):
return -1
}
return r
}, s)
s = capMarks(s, maxMarks)
// Collapse runs of whitespace so text cannot be padded into a column of
// its own, then trim the edges.
s = strings.Join(strings.Fields(s), " ")
if runes := []rune(s); len(runes) > maxRunes {
s = strings.TrimSpace(string(runes[:maxRunes]))
}
return s
}
// blankLetters are printable characters that draw nothing: Hangul fillers,
// the braille blank and Khmer inherent vowels. IsPrint accepts them, so
// without this list a name made of them looks empty, or is "Người chơi" plus
// invisible padding that makes it a different string.
var blankLetters = map[rune]bool{
0x115F: true, // HANGUL CHOSEONG FILLER
0x1160: true, // HANGUL JUNGSEONG FILLER
0x17B4: true, // KHMER VOWEL INHERENT AQ
0x17B5: true, // KHMER VOWEL INHERENT AA
0x2800: true, // BRAILLE PATTERN BLANK
0x3164: true, // HANGUL FILLER
0xFFA0: true, // HALFWIDTH HANGUL FILLER
}
// capMarks limits how many combining marks may follow one base rune.
//
// The filter above cannot catch these: a combining mark is printable, is not a
// control or format character, and NFC leaves an uncomposable one where it is.
// A base rune followed by a hundred of them renders as a glyph cluster tall
// enough to cover the page it is displayed on, which is a layout attack rather
// than a word.
func capMarks(s string, maxMarks int) string {
var b strings.Builder
b.Grow(len(s))
marks := 0
for _, r := range s {
if unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r) {
if marks >= maxMarks {
continue
}
marks++
} else {
marks = 0
}
b.WriteRune(r)
}
return b.String()
}
// defaultNickname is what an unusable name falls back to.
const defaultNickname = "Người chơi"
// distinguish returns a name the joining player cannot be confused with any of
// the ones already in the room. Nicknames are the only way to tell strangers
// apart, so letting two of them render the same string defeats the point of
// having names at all.
//
// The counter is bounded by the room: taken holds at most one name per seat, so
// a free suffix is always found within that many tries.
func distinguish(name string, taken []string) string {
if !slices.Contains(taken, name) {
return name
}
for n := 2; ; n++ {
suffix := fmt.Sprintf(" %d", n)
trimmed := name
if runes := []rune(name); len(runes)+len(suffix) > maxNicknameRunes {
trimmed = strings.TrimSpace(string(runes[:maxNicknameRunes-len(suffix)]))
}
if candidate := trimmed + suffix; !slices.Contains(taken, candidate) {
return candidate
}
}
}