Files
Commonwealth-Online-Server/server/ProtocolValidation.cs
T

342 lines
20 KiB
C#

using System.Text.Json.Nodes;
namespace CommonwealthOnline.Server;
internal static class ProtocolValidation
{
private const int MaxCharacterNameChars = 128;
private const int MaxEquippedItems = 32;
private const int MaxEquipmentSlotChars = 64;
private const int MaxHeadParts = 64;
private const int MaxMorphs = 128;
private const int MaxMorphRegions = 128;
private const int MaxFacialBoneMorphs = 128;
private const int MaxTints = 128;
public static JsonArray NormalizeActionEvents(JsonNode? value, bool strict)
{
var output = new JsonArray();
if (value is not JsonArray input) return output;
var count = Math.Min(input.Count, ProtocolConstants.MaxActionEvents);
for (var i = 0; i < count; i++)
{
if (input[i] is not JsonObject item)
{
if (strict) return new JsonArray();
continue;
}
if (!JsonHelpers.TryUInt32(item["sequence"], 1, uint.MaxValue, out var sequence) ||
!JsonHelpers.TryUInt32(item["type"], 1, 3, out var actionType) ||
JsonHelpers.String(item["eventName"]) is not { } eventName ||
!((actionType is 1 or 2 && eventName == "meleeattackStart") || (actionType == 3 && eventName == "fireSingle")))
{
if (strict) return new JsonArray();
continue;
}
var clean = new JsonObject { ["sequence"] = sequence, ["type"] = actionType, ["eventName"] = eventName };
foreach (var name in new[] { "actorStateFlags1", "actorStateFlags2" })
if (JsonHelpers.TryUInt32(item[name], 0, uint.MaxValue, out var flags)) clean[name] = flags;
output.Add(clean);
}
return output;
}
public static JsonObject? NormalizePlayerState(JsonObject packet)
{
var clean = new JsonObject { ["type"] = "playerState" };
var hasState = false;
if (packet.ContainsKey("equippedItems"))
{
if (packet["equippedItems"] is not JsonArray items || items.Count > MaxEquippedItems) return null;
var cleanItems = new JsonArray();
foreach (var node in items)
{
if (node is not JsonObject item) return null;
var slot = JsonHelpers.String(item["slot"]);
var form = JsonHelpers.String(item["formId"]) ?? string.Empty;
if (slot is null || slot.Length is < 1 or > MaxEquipmentSlotChars || !JsonHelpers.IsHexFormId(form, true, true)) return null;
cleanItems.Add(new JsonObject { ["slot"] = slot, ["formId"] = form.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(form) });
}
clean["equippedItems"] = cleanItems;
hasState = true;
}
if (packet.ContainsKey("appearance"))
{
if (packet["appearance"] is not JsonObject appearance) return null;
var normalizedAppearance = NormalizeAppearance(appearance);
if (normalizedAppearance is null) return null;
clean["appearance"] = normalizedAppearance;
hasState = true;
}
if (packet.ContainsKey("actionEvents"))
{
if (packet["actionEvents"] is not JsonArray actions) return null;
var normalized = NormalizeActionEvents(actions, true);
if (normalized.Count != actions.Count) return null;
clean["actionEvents"] = normalized;
hasState = true;
}
if (packet.ContainsKey("characterName"))
{
var name = JsonHelpers.String(packet["characterName"]);
if (name is null || name.Length > MaxCharacterNameChars) return null;
clean["characterName"] = name;
hasState = true;
}
return hasState ? clean : null;
}
private static JsonObject? NormalizeAppearance(JsonObject value)
{
var clean = new JsonObject();
uint version = 4;
if (value.ContainsKey("version") && !JsonHelpers.TryUInt32(value["version"], 1, 1000, out version)) return null;
clean["version"] = version;
foreach (var name in new[] { "raceFormId", "hairColorFormId", "facialHairColorFormId", "complexionFormId" })
{
if (!value.ContainsKey(name)) continue;
var form = JsonHelpers.String(value[name]);
if (form is null || !JsonHelpers.IsHexFormId(form, true, true)) return null;
clean[name] = form.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(form);
}
if (value.ContainsKey("height"))
{
if (!JsonHelpers.TryDouble(value["height"], 0.25, 4.0, out var height)) return null;
clean["height"] = height;
}
if (value.ContainsKey("isFemale"))
{
var female = JsonHelpers.Boolean(value["isFemale"]);
if (female is null) return null;
clean["isFemale"] = female.Value;
}
if (value.ContainsKey("morphWeight"))
{
if (value["morphWeight"] is not JsonObject weights) return null;
var normalized = new JsonObject();
foreach (var name in new[] { "thin", "muscular", "large" })
{
if (!JsonHelpers.TryDouble(weights[name], -100, 100, out var weight)) return null;
normalized[name] = weight;
}
clean["morphWeight"] = normalized;
}
if (value.ContainsKey("bodyTintColor"))
{
if (value["bodyTintColor"] is not JsonObject color) return null;
var normalized = new JsonObject();
foreach (var name in new[] { "r", "g", "b", "a" })
{
if (!JsonHelpers.TryUInt32(color[name], 0, 255, out var component)) return null;
normalized[name] = component;
}
clean["bodyTintColor"] = normalized;
}
if (value.ContainsKey("headParts"))
{
if (value["headParts"] is not JsonArray parts || parts.Count > MaxHeadParts) return null;
var normalized = new JsonArray();
foreach (var node in parts)
{
var form = JsonHelpers.String(node);
if (form is null || !JsonHelpers.IsHexFormId(form, false, true)) return null;
normalized.Add(JsonHelpers.NormalizeFormId(form));
}
clean["headParts"] = normalized;
}
if (value.ContainsKey("morphs"))
{
if (value["morphs"] is not JsonArray morphs || morphs.Count > MaxMorphs) return null;
var normalized = new JsonArray();
foreach (var node in morphs)
{
if (node is not JsonObject morph) return null;
var id = JsonHelpers.String(morph["id"]);
if (id is null || !JsonHelpers.IsHexFormId(id, false, true) || !JsonHelpers.TryDouble(morph["value"], -1000, 1000, out var amount)) return null;
normalized.Add(new JsonObject { ["id"] = JsonHelpers.NormalizeFormId(id), ["value"] = amount });
}
clean["morphs"] = normalized;
}
if (value.ContainsKey("morphRegions"))
{
if (value["morphRegions"] is not JsonArray regions || regions.Count > MaxMorphRegions) return null;
var normalized = new JsonArray();
foreach (var node in regions)
{
if (!JsonHelpers.TryDouble(node, -1000, 1000, out var amount)) return null;
normalized.Add(amount);
}
clean["morphRegions"] = normalized;
}
if (value.ContainsKey("facialBoneMorphs"))
{
if (value["facialBoneMorphs"] is not JsonArray morphs || morphs.Count > MaxFacialBoneMorphs) return null;
var normalized = new JsonArray();
foreach (var node in morphs)
{
if (node is not JsonObject morph) return null;
var id = JsonHelpers.String(morph["id"]);
var position = NormalizeVec3(morph["position"], -10000, 10000);
var rotation = NormalizeVec3(morph["rotation"], -10000, 10000);
var scale = NormalizeVec3(morph["scale"], -100, 100);
if (id is null || !JsonHelpers.IsHexFormId(id, false, true) || position is null || rotation is null || scale is null) return null;
normalized.Add(new JsonObject { ["id"] = JsonHelpers.NormalizeFormId(id), ["position"] = position, ["rotation"] = rotation, ["scale"] = scale });
}
clean["facialBoneMorphs"] = normalized;
}
if (value.ContainsKey("tints"))
{
if (value["tints"] is not JsonArray tints || tints.Count > MaxTints) return null;
var normalized = new JsonArray();
foreach (var node in tints)
{
if (node is not JsonObject tint || !JsonHelpers.TryUInt32(tint["id"], 0, ushort.MaxValue, out var id) ||
!JsonHelpers.TryUInt32(tint["type"], 0, uint.MaxValue, out var type) || !JsonHelpers.TryUInt32(tint["value"], 0, 255, out var amount)) return null;
var cleanTint = new JsonObject { ["id"] = id, ["type"] = type, ["value"] = amount };
if (tint.ContainsKey("color"))
{
var color = JsonHelpers.String(tint["color"]);
var swatchNode = tint["swatch"] ?? JsonValue.Create(0);
if (color is null || !JsonHelpers.IsHexFormId(color, false, true) || !JsonHelpers.TryUInt32(swatchNode, 0, ushort.MaxValue, out var swatch)) return null;
cleanTint["color"] = JsonHelpers.NormalizeFormId(color);
cleanTint["swatch"] = swatch;
}
normalized.Add(cleanTint);
}
clean["tints"] = normalized;
}
return clean;
}
private static JsonArray? NormalizeVec3(JsonNode? node, double min, double max)
{
if (node is not JsonArray array || array.Count != 3) return null;
var output = new JsonArray();
foreach (var component in array)
{
if (!JsonHelpers.TryDouble(component, min, max, out var value)) return null;
output.Add(value);
}
return output;
}
public static JsonObject? NormalizeTransform(JsonObject packet)
{
foreach (var field in new[] { "x", "y", "z", "angleZ" })
if (!JsonHelpers.TryDouble(packet[field], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out _)) return null;
var cell = JsonHelpers.String(packet["cellId"]);
var world = JsonHelpers.String(packet["worldspaceId"]) ?? string.Empty;
if (cell is null || !JsonHelpers.IsHexFormId(cell, false, false) || !JsonHelpers.IsHexFormId(world, true, true)) return null;
var movementType = JsonHelpers.String(packet["movementType"]) ?? "normal";
if (!ProtocolConstants.AllowedMovementTypes.Contains(movementType)) return null;
var normalized = JsonHelpers.CloneObject(packet);
foreach (var field in new[] { "x", "y", "z", "angleZ" }) { JsonHelpers.TryDouble(packet[field], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var v); normalized[field] = v; }
normalized["cellId"] = JsonHelpers.NormalizeFormId(cell);
normalized["worldspaceId"] = world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world);
normalized["movementType"] = movementType;
foreach (var field in new[] { "movementSpeed", "animationGraphSpeed" })
{
if (!packet.ContainsKey(field)) continue;
var min = field == "animationGraphSpeed" ? -1.0 : 0.0;
if (JsonHelpers.TryDouble(packet[field], min, ProtocolConstants.MaxMovementSpeed, out var value)) normalized[field] = value; else normalized.Remove(field);
}
foreach (var (field, min, max) in new[] { ("animationDirection", -360.0, 360.0), ("aimPitch", -180.0, 180.0), ("turnDelta", -10000.0, 10000.0) })
{
if (!packet.ContainsKey(field)) continue;
if (JsonHelpers.TryDouble(packet[field], min, max, out var value)) normalized[field] = value; else normalized.Remove(field);
}
foreach (var field in new[] { "isMoving", "isSprinting", "isSneaking", "isJumping", "isCrouching", "weaponDrawn" })
if (packet.ContainsKey(field) && JsonHelpers.Boolean(packet[field]) is null) normalized.Remove(field);
foreach (var field in new[] { "actorStateFlags1", "actorStateFlags2" })
if (packet.ContainsKey(field) && !JsonHelpers.TryUInt32(packet[field], 0, uint.MaxValue, out _)) normalized.Remove(field);
if (packet.ContainsKey("actionEvents")) normalized["actionEvents"] = NormalizeActionEvents(packet["actionEvents"], false);
return normalized;
}
public static (bool Accepted, string Reason) ValidateMovement(JsonObject? previous, double? previousMonotonic, JsonObject current, double nowMonotonic)
{
if (previous is null || previousMonotonic is null) return (true, "first transform");
var movementType = JsonHelpers.String(current["movementType"]) ?? "normal";
if (ProtocolConstants.MovementTransitionTypes.Contains(movementType)) return (true, $"explicit {movementType} transition");
if ((JsonHelpers.String(previous["cellId"]) ?? string.Empty) != (JsonHelpers.String(current["cellId"]) ?? string.Empty) ||
(JsonHelpers.String(previous["worldspaceId"]) ?? string.Empty) != (JsonHelpers.String(current["worldspaceId"]) ?? string.Empty))
return (false, "scope changed without an explicit movement transition");
var elapsed = nowMonotonic - previousMonotonic.Value;
if (!double.IsFinite(elapsed) || elapsed < 0) elapsed = 0;
elapsed = Math.Min(elapsed, ProtocolConstants.MaxMovementValidationElapsedSeconds);
JsonHelpers.TryDouble(current["x"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var x);
JsonHelpers.TryDouble(current["y"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var y);
JsonHelpers.TryDouble(current["z"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var z);
JsonHelpers.TryDouble(previous["x"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var px);
JsonHelpers.TryDouble(previous["y"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var py);
JsonHelpers.TryDouble(previous["z"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var pz);
var dx = x - px; var dy = y - py; var dz = z - pz;
var distance = Math.Sqrt(dx * dx + dy * dy + dz * dz);
var allowed = ProtocolConstants.MovementGraceDistance + ProtocolConstants.MaxNormalMovementSpeed * elapsed;
return double.IsFinite(distance) && distance <= allowed ? (true, "normal movement accepted") : (false, $"normal movement exceeded server envelope: distance={distance:F1}, allowed={allowed:F1}, elapsed={elapsed:F3}s");
}
public static JsonObject? NormalizeWorldState(JsonObject packet)
{
var normalized = JsonHelpers.CloneObject(packet);
if (packet.ContainsKey("gameHour")) { if (!JsonHelpers.TryDouble(packet["gameHour"], 0, 24, out var hour)) return null; normalized["gameHour"] = hour; }
if (packet.ContainsKey("gameDaysPassed")) { if (!JsonHelpers.TryDouble(packet["gameDaysPassed"], 0, 10_000_000, out var days)) return null; normalized["gameDaysPassed"] = days; }
if (packet.ContainsKey("weatherFormId"))
{
var weather = JsonHelpers.String(packet["weatherFormId"]);
if (weather is null || !JsonHelpers.IsHexFormId(weather, true, true)) return null;
normalized["weatherFormId"] = weather.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(weather);
}
return normalized;
}
public static JsonObject? NormalizeNpcState(JsonObject packet)
{
if (packet["npcs"] is not JsonArray npcs || npcs.Count > ProtocolConstants.MaxNpcsPerPacket) return null;
var cleanNpcs = new JsonArray();
foreach (var node in npcs)
{
if (node is not JsonObject npc) return null;
var source = JsonHelpers.String(npc["sourceFormId"]); var cell = JsonHelpers.String(npc["cellId"]); var world = JsonHelpers.String(npc["worldspaceId"]) ?? string.Empty;
if (source is null || cell is null || !JsonHelpers.IsHexFormId(source, false, false) || !JsonHelpers.IsHexFormId(cell, false, false) || !JsonHelpers.IsHexFormId(world, true, true)) return null;
foreach (var field in new[] { "x", "y", "z", "angleZ" }) if (!JsonHelpers.TryDouble(npc[field], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out _)) return null;
var clean = JsonHelpers.CloneObject(npc);
clean["sourceFormId"] = JsonHelpers.NormalizeFormId(source); clean["cellId"] = JsonHelpers.NormalizeFormId(cell); clean["worldspaceId"] = world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world);
foreach (var field in new[] { "x", "y", "z", "angleZ" }) { JsonHelpers.TryDouble(npc[field], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var v); clean[field] = v; }
cleanNpcs.Add(clean);
}
var normalized = JsonHelpers.CloneObject(packet); normalized["npcs"] = cleanNpcs;
if (packet.ContainsKey("authorityEpoch")) { if (!JsonHelpers.TryUInt32(packet["authorityEpoch"], 1, uint.MaxValue, out var epoch)) return null; normalized["authorityEpoch"] = epoch; }
if (packet.ContainsKey("authorityCellId")) { var cell = JsonHelpers.String(packet["authorityCellId"]); if (cell is null || !JsonHelpers.IsHexFormId(cell, false, false)) return null; normalized["authorityCellId"] = JsonHelpers.NormalizeFormId(cell); }
if (packet.ContainsKey("authorityWorldspaceId")) { var world = JsonHelpers.String(packet["authorityWorldspaceId"]); if (world is null || !JsonHelpers.IsHexFormId(world, true, true)) return null; normalized["authorityWorldspaceId"] = world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world); }
return normalized;
}
public static JsonObject? NormalizeCombatHit(JsonObject packet)
{
if (!JsonHelpers.TryUInt32(packet["targetPlayerId"], 1, uint.MaxValue, out var target) || !JsonHelpers.TryUInt32(packet["sequence"], 1, uint.MaxValue, out var sequence) || !JsonHelpers.TryDouble(packet["damage"], 0.000001, 10000, out var damage)) return null;
var normalized = JsonHelpers.CloneObject(packet); normalized["targetPlayerId"] = target; normalized["sequence"] = sequence; normalized["damage"] = damage;
if (packet.ContainsKey("weaponFormId")) { var weapon = JsonHelpers.String(packet["weaponFormId"]); if (weapon is null || !JsonHelpers.IsHexFormId(weapon, false, true)) return null; normalized["weaponFormId"] = JsonHelpers.NormalizeFormId(weapon); }
return normalized;
}
public static StateScope? ScopeFromState(JsonObject? state)
{
if (state is null) return null;
var cell = JsonHelpers.String(state["cellId"]); var world = JsonHelpers.String(state["worldspaceId"]) ?? string.Empty;
if (cell is null || !JsonHelpers.IsHexFormId(cell, false, false) || !JsonHelpers.IsHexFormId(world, true, true)) return null;
if (!JsonHelpers.TryDouble(state["x"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var x) || !JsonHelpers.TryDouble(state["y"], -ProtocolConstants.MaxAbsCoordinate, ProtocolConstants.MaxAbsCoordinate, out var y)) return null;
return new StateScope(JsonHelpers.NormalizeFormId(cell), world.Length == 0 ? string.Empty : JsonHelpers.NormalizeFormId(world), x, y);
}
public static bool StatesShareInterest(JsonObject? a, JsonObject? b)
{
var left = ScopeFromState(a); var right = ScopeFromState(b);
if (left is null || right is null) return true;
if (left.Value.CellId == right.Value.CellId) return true;
if (string.IsNullOrEmpty(left.Value.WorldspaceId) || left.Value.WorldspaceId != right.Value.WorldspaceId) return false;
var dx = left.Value.X - right.Value.X; var dy = left.Value.Y - right.Value.Y;
return Math.Sqrt(dx * dx + dy * dy) <= ProtocolConstants.ExteriorInterestRadius;
}
}