Add vanilla parity harness and harden server boundaries

This commit is contained in:
Daniar Mannanov 2026-08-10 22:52:44 +03:00
parent 1924cb5591
commit ca019756ec
25 changed files with 1118 additions and 217 deletions

View file

@ -0,0 +1,67 @@
package network
import (
"log/slog"
"math"
"testing"
"regionio/internal/protocol"
"regionio/internal/server"
"regionio/internal/world"
)
func TestValidPlayerName(t *testing.T) {
for _, name := range []string{"Steve", "player_123", "A"} {
if !validPlayerName(name) {
t.Errorf("validPlayerName(%q) = false", name)
}
}
for _, name := range []string{"", "seventeen_chars_1", "player-name", "имя"} {
if validPlayerName(name) {
t.Errorf("validPlayerName(%q) = true", name)
}
}
}
func TestPlayerMoveRejectsCoordinatesOutsideWorld(t *testing.T) {
cfg := server.DefaultConfig()
cfg.WorldDir = ""
srv, err := server.NewWithCache(cfg, world.NewCache(-1, func(cx, cz int32) *world.Chunk {
return world.GenerateFlat(cx, cz)
}))
if err != nil {
t.Fatal(err)
}
session, err := srv.RegisterPlayer(server.Profile{Name: "Steve"}, nil)
if err != nil {
t.Fatal(err)
}
h := &handler{srv: srv, session: session, log: slog.Default()}
for _, position := range [][3]float64{
{maxPlayerXZ + 1, 0, 0},
{0, maxPlayerY + 1, 0},
{0, 0, -maxPlayerXZ - 1},
{math.NaN(), 0, 0},
} {
if err := h.onPlayerMove(position[0], position[1], position[2], 0, 0, true); err == nil {
t.Errorf("accepted position %v", position)
}
}
}
func TestKeepAliveRequiresPendingMatchingID(t *testing.T) {
h := &handler{log: slog.Default()}
w := protocol.NewWriter(8).Int64(42)
pkt := protocol.Packet{ID: protocol.PlayKeepAliveServer, Data: w.Bytes()}
if err := h.handlePlay(pkt); err == nil {
t.Fatal("accepted keep-alive response without a pending challenge")
}
h.keepAlivePending = true
h.keepAliveID = 42
if err := h.handlePlay(pkt); err != nil {
t.Fatalf("matching response: %v", err)
}
if h.keepAlivePending {
t.Fatal("matching response did not clear pending challenge")
}
}

View file

@ -4,6 +4,7 @@ package network
import (
"bufio"
"io"
"net"
"sync"
"time"
@ -12,6 +13,8 @@ import (
"regionio/internal/server"
)
const networkWriteTimeout = 30 * time.Second
// Conn wraps a TCP connection with buffered reads and tracks protocol state.
type Conn struct {
raw net.Conn
@ -70,6 +73,10 @@ func (c *Conn) ReadPacket() (protocol.Packet, error) {
func (c *Conn) Send(id int32, body []byte) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
if err := c.raw.SetWriteDeadline(time.Now().Add(networkWriteTimeout)); err != nil {
return err
}
defer c.raw.SetWriteDeadline(time.Time{})
return protocol.WritePacket(c.raw, c.compressionThreshold, id, body)
}
@ -84,8 +91,21 @@ func (c *Conn) SendWriter(id int32, w *protocol.Writer) error {
func (c *Conn) SendFramed(frame []byte) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
_, err := c.raw.Write(frame)
return err
if err := c.raw.SetWriteDeadline(time.Now().Add(networkWriteTimeout)); err != nil {
return err
}
defer c.raw.SetWriteDeadline(time.Time{})
for len(frame) > 0 {
n, err := c.raw.Write(frame)
if err != nil {
return err
}
if n <= 0 || n > len(frame) {
return io.ErrShortWrite
}
frame = frame[n:]
}
return nil
}
// CompressionThreshold returns the active threshold (-1 if disabled).

View file

@ -6,6 +6,8 @@ import (
"io"
"log/slog"
"net"
"sync"
"time"
"regionio/internal/protocol"
"regionio/internal/server"
@ -27,11 +29,17 @@ type handler struct {
streamer *streamer
// viewDistance is the client's requested view distance (from
// client_information), clamped; used to size the streamer.
viewDistance int
session *server.PlayerSession
knownPlayers map[[16]byte]bool
knownEntities map[int32]visibleEntity
spawnY float64
viewDistance int
session *server.PlayerSession
knownPlayers map[[16]byte]bool
knownEntities map[int32]visibleEntity
spawnY float64
protocolVersion int32
keepAliveMu sync.Mutex
keepAlivePending bool
keepAliveID int64
keepAliveSent time.Time
// Creative inventory state for block placement.
heldSlot int32 // selected hotbar index (0-8)
@ -111,6 +119,7 @@ func (h *handler) handleHandshake(pkt protocol.Packet) error {
h.log.Debug("handshake",
"protocol", protoVer, "addr", addr, "port", port, "next", next)
h.protocolVersion = protoVer
switch next {
case protocol.NextStateStatus:

View file

@ -2,6 +2,7 @@ package network
import (
"errors"
"fmt"
"regionio/internal/protocol"
"regionio/internal/server"
@ -32,12 +33,15 @@ func (h *handler) handleLogin(pkt protocol.Packet) error {
}
func (h *handler) handleLoginStart(pkt protocol.Packet) error {
if h.protocolVersion != protocol.ProtocolVersion {
return fmt.Errorf("unsupported protocol %d, want %d", h.protocolVersion, protocol.ProtocolVersion)
}
r := pkt.Body()
name, err := r.String()
if err != nil {
return err
}
if name == "" || len(name) > 16 {
if !validPlayerName(name) {
return errors.New("invalid login name")
}
// The client also sends a UUID, but in offline mode we derive our own so it
@ -65,6 +69,18 @@ func (h *handler) handleLoginStart(pkt protocol.Packet) error {
return h.sendLoginSuccess()
}
func validPlayerName(name string) bool {
if len(name) == 0 || len(name) > 16 {
return false
}
for _, r := range name {
if r != '_' && (r < '0' || r > '9') && (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') {
return false
}
}
return true
}
// sendLoginSuccess writes the Login Success packet. For protocol 775 the body
// is: UUID, Username, then a VarInt-prefixed array of profile properties (none
// in offline mode).

View file

@ -15,8 +15,10 @@ import (
// Spawn column. The feet-level Y is resolved from the generated surface when
// the player enters the play phase.
const (
spawnX = 8.5
spawnZ = 8.5
spawnX = 8.5
spawnZ = 8.5
maxPlayerXZ = 30_000_000.0
maxPlayerY = 20_000_000.0
)
// beginPlay sends the join sequence once the client enters the Play phase and
@ -107,6 +109,9 @@ func (h *handler) onPlayerMove(x, y, z float64, yaw, pitch float32, onGround boo
math.IsInf(float64(yaw), 0) || math.IsInf(float64(pitch), 0) {
return errors.New("invalid player position")
}
if math.Abs(x) > maxPlayerXZ || math.Abs(z) > maxPlayerXZ || math.Abs(y) > maxPlayerY {
return errors.New("player position outside world bounds")
}
h.srv.SetPlayerTransform(h.session, x, y, z, yaw, pitch, onGround)
cx := int32(int64(math.Floor(x)) >> 4)
cz := int32(int64(math.Floor(z)) >> 4)
@ -203,7 +208,21 @@ func (h *handler) keepAliveLoop() {
case <-h.ctx.Done():
return
case <-ticker.C:
h.keepAliveMu.Lock()
if h.keepAlivePending {
timedOut := time.Since(h.keepAliveSent) >= 30*time.Second
h.keepAliveMu.Unlock()
if timedOut {
_ = h.conn.Close()
return
}
continue
}
id := time.Now().UnixMilli()
h.keepAlivePending = true
h.keepAliveID = id
h.keepAliveSent = time.Now()
h.keepAliveMu.Unlock()
w := protocol.NewWriter(8)
w.Int64(id)
if err := h.conn.SendWriter(protocol.PlayKeepAliveCB, w); err != nil {
@ -434,8 +453,20 @@ func (h *handler) handlePlay(pkt protocol.Packet) error {
return nil
case protocol.PlayKeepAliveServer:
// A response to our keep-alive; presence is enough for liveness.
h.log.Debug("keep-alive ack")
id, err := pkt.Body().Int64()
if err != nil {
return err
}
h.keepAliveMu.Lock()
valid := h.keepAlivePending && id == h.keepAliveID
if valid {
h.keepAlivePending = false
}
h.keepAliveMu.Unlock()
if !valid {
return errors.New("unexpected keep-alive response")
}
h.log.Debug("keep-alive ack", "id", id)
return nil
case protocol.PlayPlayerLoaded: