From 8571056fb31dc0528daaefdc1214e8755aed4b94 Mon Sep 17 00:00:00 2001 From: Nomads_Reach <144523850+NomadsReach@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:48:19 -0400 Subject: [PATCH] Reject impossible movement before relay and correct sender --- server/server_core.py | 99 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 94 insertions(+), 5 deletions(-) diff --git a/server/server_core.py b/server/server_core.py index 3b4a2fc..dffb802 100644 --- a/server/server_core.py +++ b/server/server_core.py @@ -41,6 +41,18 @@ MAX_PACKETS_PER_SECOND = 120 MAX_CONNECT_ATTEMPTS = 8 CONNECT_ATTEMPT_WINDOW_SECONDS = 10.0 EXTERIOR_INTEREST_RADIUS = 8192.0 +MAX_NORMAL_MOVEMENT_SPEED = 2500.0 +MOVEMENT_GRACE_DISTANCE = 512.0 +MAX_MOVEMENT_VALIDATION_ELAPSED_SECONDS = 5.0 +MOVEMENT_TRANSITION_TYPES = frozenset({ + "teleport", + "cell_change", + "worldspace_change", + "load", + "spawn", + "fast_travel", +}) +ALLOWED_MOVEMENT_TYPES = MOVEMENT_TRANSITION_TYPES | {"normal"} _LOG_LEVELS = { "debug": 10, @@ -228,6 +240,8 @@ class FalloutTogetherServer: "transformPacketsReceived": 0, "transformPacketsBroadcast": 0, "transformPacketsInterestFiltered": 0, + "movementPacketsRejected": 0, + "movementCorrectionsSent": 0, "worldStatePacketsReceived": 0, "worldStatePacketsBroadcast": 0, "npcStatePacketsReceived": 0, @@ -589,7 +603,7 @@ class FalloutTogetherServer: "serverName": self.server_name, "serverDescription": self.server_description, "protocolVersion": PROTOCOL_VERSION, - "capabilities": ["interest-v1", "hello-v2", "bounded-framing", "rate-limit-v1"], + "capabilities": ["interest-v1", "hello-v2", "bounded-framing", "rate-limit-v1", "movement-correction-v1"], }, ) buffer = b"" @@ -705,9 +719,21 @@ class FalloutTogetherServer: if normalized is None: self._reject_packet(client, "Malformed transform") return False + accepted_monotonic = time.monotonic() + movement_valid, movement_reason = self._validate_transform_movement( + client, + normalized, + accepted_monotonic, + ) + if not movement_valid: + with self._lock: + self._stats["movementPacketsRejected"] += 1 + self._reject_packet(client, movement_reason) + self._send_position_correction(client, movement_reason) + return False normalized["playerId"] = client.player_id normalized["serverTime"] = time.time() - client.record_transform(normalized) + client.record_transform(normalized, accepted_monotonic) with self._lock: self._stats["transformPacketsReceived"] += 1 self._broadcast_transform(client, normalized) @@ -789,6 +815,10 @@ class FalloutTogetherServer: if not _is_hex_form_id(worldspace_id, allow_empty=True, allow_zero=True): return None + movement_type = packet.get("movementType", "normal") + if not isinstance(movement_type, str) or movement_type not in ALLOWED_MOVEMENT_TYPES: + return None + normalized = dict(packet) normalized["x"] = float(packet["x"]) normalized["y"] = float(packet["y"]) @@ -796,9 +826,7 @@ class FalloutTogetherServer: normalized["angleZ"] = float(packet["angleZ"]) normalized["cellId"] = _normalize_hex_form_id(packet["cellId"]) normalized["worldspaceId"] = _normalize_hex_form_id(worldspace_id) if worldspace_id else "" - - movement_type = packet.get("movementType", "normal") - normalized["movementType"] = str(movement_type)[:32] if isinstance(movement_type, str) else "normal" + normalized["movementType"] = movement_type for field_name in ("movementSpeed", "animationGraphSpeed"): if field_name in packet: value = packet[field_name] @@ -826,6 +854,67 @@ class FalloutTogetherServer: normalized["actionEvents"] = _normalize_action_events(packet["actionEvents"]) return normalized + def _validate_transform_movement( + self, + client: ClientSession, + packet: dict[str, Any], + now_monotonic: float, + ) -> tuple[bool, str]: + previous, previous_monotonic = client.get_last_transform_anchor() + if previous is None or previous_monotonic is None: + return True, "first transform" + + movement_type = packet.get("movementType", "normal") + if movement_type in MOVEMENT_TRANSITION_TYPES: + return True, f"explicit {movement_type} transition" + + previous_cell = previous.get("cellId", "") + current_cell = packet.get("cellId", "") + previous_world = previous.get("worldspaceId", "") + current_world = packet.get("worldspaceId", "") + if previous_cell != current_cell or previous_world != current_world: + return False, "scope changed without an explicit movement transition" + + elapsed = now_monotonic - previous_monotonic + if not math.isfinite(elapsed) or elapsed < 0.0: + elapsed = 0.0 + elapsed = min(elapsed, MAX_MOVEMENT_VALIDATION_ELAPSED_SECONDS) + + dx = packet["x"] - previous["x"] + dy = packet["y"] - previous["y"] + dz = packet["z"] - previous["z"] + distance = math.sqrt((dx * dx) + (dy * dy) + (dz * dz)) + allowed_distance = MOVEMENT_GRACE_DISTANCE + (MAX_NORMAL_MOVEMENT_SPEED * elapsed) + if not math.isfinite(distance) or distance > allowed_distance: + return ( + False, + f"normal movement exceeded server envelope: distance={distance:.1f}, allowed={allowed_distance:.1f}, elapsed={elapsed:.3f}s", + ) + return True, "normal movement accepted" + + def _send_position_correction(self, client: ClientSession, reason: str) -> None: + previous, _previous_monotonic = client.get_last_transform_anchor() + if previous is None: + return + packet: dict[str, Any] = { + "type": "positionCorrection", + "reason": reason[:160], + "x": previous["x"], + "y": previous["y"], + "z": previous["z"], + "angleZ": previous["angleZ"], + "cellId": previous["cellId"], + "worldspaceId": previous.get("worldspaceId", ""), + "serverTime": time.time(), + } + try: + self._send_packet(client, packet) + except (OSError, ValueError): + self._disconnect_client(client) + return + with self._lock: + self._stats["movementCorrectionsSent"] += 1 + def _normalize_world_state_packet(self, packet: dict[str, Any]) -> dict[str, Any] | None: normalized = dict(packet) if "gameHour" in packet: