From 7b1e796c4872962d3fdd038aa8b136d118840ae5 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Mon, 17 Aug 2026 22:52:33 -0400 Subject: [PATCH] CLOSES #1496 --- README.md | 3 +- docs/link-security.md | 120 +++++++++++ src/core/Game.lua | 37 +++- src/link/Handshake.lua | 8 +- src/link/Json.lua | 18 +- src/link/LinkBattle.lua | 4 +- src/link/Net.lua | 24 ++- src/link/Protocol.lua | 131 +++++++---- src/link/Session.lua | 21 +- src/link/Wire.lua | 344 +++++++++++++++++++++++++++++ tests/engine/link_session.lua | 42 ++-- tests/link_desync_fuzz.lua | 58 ++++- tests/link_hostile.lua | 396 ++++++++++++++++++++++++++++++++++ tests/run_link_tests.lua | 8 + 14 files changed, 1124 insertions(+), 90 deletions(-) create mode 100644 docs/link-security.md create mode 100644 src/link/Wire.lua create mode 100644 tests/link_hostile.lua diff --git a/README.md b/README.md index 108c7dd2..0083d402 100644 --- a/README.md +++ b/README.md @@ -354,7 +354,8 @@ request with real detail is one that can actually get built. - [Save editor](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Save-Editor) — edit party, boxes, items, events, and Pokédex flags outside the game. - `docs/architecture.md` — runtime details; -`docs/behavior-porting-notes.md` — formula provenance. +`docs/behavior-porting-notes.md` — formula provenance; +`docs/link-security.md` — what link play defends against, and what it doesn't. diff --git a/docs/link-security.md b/docs/link-security.md new file mode 100644 index 00000000..e4daf858 --- /dev/null +++ b/docs/link-security.md @@ -0,0 +1,120 @@ +# Link play: threat model and what the code actually guarantees + +Link play is the only part of this game that reads bytes written by +somebody else. This is what it defends against, what it does not, and +where each guarantee lives. + +## The boundary + +Everything a peer or the relay sends arrives as one JSON object per line. +There is exactly one place it becomes a message: + + src/link/Net.lua reads bytes, frames lines, decodes JSON + src/link/Wire.lua rebuilds each line as a typed message + src/link/Session.lua the only path from a transport into a mode + +`Session:update` runs `Wire.sanitize` on every message before anything +else sees it. A schema returns a **new** table holding only the fields it +names, at the Lua types it names, so the rest of `src/link/` can read +`msg.slot`, `msg.parts.actives` or `msg.mons[i].dvs.hp` directly and be +right by construction. A message with no schema (a mod's, or a future +build's) keeps a bounded, scalar-only copy of its payload instead of +being dropped. + +A message that fails its schema is **dropped and logged**, never fatal. +Latching a terminal failure would hand a hostile peer a cheaper +disconnect than sending nothing at all. + +### Why the bounds are loose + +Wire's numeric bounds are deliberately wider than the game's own clamps in +`Protocol.unpackMon`. Both peers run identical clamps over identical +packets; a bound that bit an honest value would change one side's copy of +a mon and desync the lockstep. Wire's job is types and sizes. Rules are +`Protocol`'s job, and it keeps its own clamps for the callers that reach +it without a Session (the mod API, `tests/`). + +### Containment behind it + +Assume something still gets through: + +- `Game:step` pcalls the link pump, and pcalls `stack:update` **only + while a link session is active**. On a throw, `Game:breakLink` closes + the connection, unwinds to the overworld and says "The link was + broken." Outside link play the stack is unguarded on purpose: a blanket + pcall would swallow real engine bugs and leave the game silently wrong + instead of loudly broken. +- `Net` caps `rxBuf` at 256KB and its per-frame read at 512KB, so a peer + that never sends a newline ends as a clean disconnect. +- `Json.decode` refuses documents nested past 64 levels, and takes an + optional length cap that the link path passes and the mod-manifest path + does not. + +## The relay (`../pokeserver`) + +- A line that is not a JSON **object** with a string `type` is dropped + before any handler runs, and `onLine` is wrapped in try/catch. + `server.js` installs `uncaughtException`/`unhandledRejection` handlers: + one bad packet must never take every live match down with the process. +- Line buffers are capped, lines per second are capped, connections per + IP and in total are capped, and an unbound connection that never hosts + or joins is swept after 30s. +- `SERVER_ONLY` is the set of message types the server is the only + legitimate author of (`peer_gone`, `bracket_update`, `match_start`, + `tournament_over`, `spectate`, ...). A peer that sends one has them + dropped rather than forwarded, so a bracket opponent cannot forge a + tournament result or fake "your opponent left". +- Trainer names are reduced to a printable subset and capped at the same + 10 characters the game enforces, on the way in, because they are + rendered by the dashboard and broadcast to every participant. + +`pokeserver/test/hostile.js` is the regression net for all of that. + +## What is NOT defended + +**Party legality is trust-the-client.** Online play meets strangers, and +`Handshake.onlineAllowed` is a Lua function in the same VM the mods load +into. It cannot be made tamper-proof in-process, and pretending otherwise +would only cost honest mod authors. What lockstep and +`Protocol.unpackMon`'s recompute-from-species-data *do* guarantee is that +a cheater cannot invent stats, moves, or a shiny: every derived value is +rebuilt locally from real species data. They can send a legal party they +farmed or edited. That is the honest boundary. + +What the relay does instead is **observe and record**. It already sees +every `hello`, so it keeps each connection's self-reported +`engineVersion`, `fingerprint` and `linkModified`, compares the two sides +of a room or a live tournament match, and logs and surfaces a +`modded` / `fingerprint_mismatch` / `version_skew` flag on the dashboard. +A patched client can still lie; what it cannot do is lie without the +tournament organizer having a record of it. + +Client-side attestation is deliberately not built. This is an +open-source Lua game: it would be theater, and it would break honest +mods. + +**The relay has no TLS.** Port 7778 is plaintext, so party contents, +trades and trainer names are visible to anyone on the network path. There +is nothing secret in a Pokemon party, but it is a real property of the +system and not an oversight. Fixing it means a TLS terminator in front of +the relay and a client that speaks it, which is a version break for every +shipped build. + +**The dashboard has no default password.** `DASHBOARD_PASSWORD` is +required; with it unset the relay runs and the dashboard simply does not +start. It is still Basic Auth over plain HTTP, so it belongs behind an +IP restriction or an SSH tunnel (`pokeserver/DEPLOY.md`). + +## Tests + + luajit tests/link_hostile.lua every message type x every wrong type + luajit tests/link_desync_fuzz.lua lockstep fuzz, plus a mutation mode + luajit tests/run_link_tests.lua both of the above, plus the rest + cd ../pokeserver && npm test relay smoke, 16-player bracket, hostile + +`tests/link_hostile.lua` builds its corpus from a template per message +type, replaces each field (and several nested ones) with every wrong Lua +type, and drives the survivors through the real trade session, a real +lockstep battle, a real spectator battle, and the tournament screen +**including its draw** -- because the two nastiest payloads are +delayed-fuse ones that crash on render rather than on receipt. diff --git a/src/core/Game.lua b/src/core/Game.lua index 1b6019e1..1c01d889 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -222,6 +222,26 @@ function Game:touchSkinHotkey(action, pressed) end end +function Game:breakLink(err) + Logger.error("link: torn down after an error\n%s", tostring(err)) + self.linkSession = nil + local net = self.linkNet + self.linkNet = nil + if net then pcall(net.close, net) end + pcall(ModRuntime.emit, "link.ended", { reason = "error" }) + local stack = self.stack + local guard = 0 + while #stack.states > 1 and stack:top() ~= self.overworld and guard < 64 do + guard = guard + 1 + pcall(stack.pop, stack) + end + pcall(function() + local Strings = require("src.core.Strings") + local TextBox = require("src.render.TextBox") + stack:push(TextBox.new(self, Strings("The link was\nbroken."))) + end) +end + function Game:step(dt) -- Tool mods (autoplay, accessibility drivers, input visualizers) act on -- the same fixed-step boundary as a physical controller. Run them before @@ -249,9 +269,22 @@ function Game:step(dt) -- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily -- on top of BattleState (see LinkBattle.new) if self.linkNet and not self.linkNet.closed then - self.linkNet:update() + local ok, err = pcall(self.linkNet.update, self.linkNet) + if not ok then + self:breakLink(err) + return + end + end + if self.linkSession or self.linkNet then + local ok, err = xpcall(function() self.stack:update(dt) end, + function(e) return debug.traceback(tostring(e), 2) end) + if not ok then + self:breakLink(err) + return + end + else + self.stack:update(dt) end - self.stack:update(dt) -- play time for the trainer card / save screen self.save.playTime = (self.save.playTime or 0) + dt -- Music.update is NOT serviced here: it decrements fade counters and diff --git a/src/link/Handshake.lua b/src/link/Handshake.lua index cea7b147..876a79d6 100644 --- a/src/link/Handshake.lua +++ b/src/link/Handshake.lua @@ -270,7 +270,10 @@ end local function index(mods) local byId = {} - for _, mod in ipairs(mods or {}) do byId[tostring(mod.id)] = mod end + if type(mods) ~= "table" then return byId end + for _, mod in ipairs(mods) do + if type(mod) == "table" then byId[tostring(mod.id)] = mod end + end return byId end @@ -327,7 +330,8 @@ end -- lines for the incompatibility screen: what differs, then what still works function Handshake.describe(localHello, remoteHello, verdict, mode) local lines = {} - local peer = (remoteHello and remoteHello.name) or "THEY" + local peerName = remoteHello and remoteHello.name + local peer = type(peerName) == "string" and peerName or "THEY" if verdict == "refused" then -- checked before the v1 arm for the same reason checkCompat checks it -- first: a Gen 1 peer meeting a Gen 2 one has no `protocol` to read yet diff --git a/src/link/Json.lua b/src/link/Json.lua index b6bd58a4..6f2fca1a 100644 --- a/src/link/Json.lua +++ b/src/link/Json.lua @@ -4,6 +4,8 @@ local Json = {} +Json.MAX_DEPTH = 64 + local function encodeValue(v, out) local t = type(v) if v == nil then @@ -109,7 +111,9 @@ local function decodeString(s, i) error("unterminated string") end -decodeValue = function(s, i) +decodeValue = function(s, i, depth) + depth = (depth or 0) + 1 + assert(depth <= Json.MAX_DEPTH, "json nested too deeply") i = skipWs(s, i) local c = s:sub(i, i) if c == '"' then @@ -124,7 +128,7 @@ decodeValue = function(s, i) i = skipWs(s, i) assert(s:sub(i, i) == ":", "expected :") local val - val, i = decodeValue(s, i + 1) + val, i = decodeValue(s, i + 1, depth) obj[key] = val i = skipWs(s, i) local d = s:sub(i, i) @@ -138,7 +142,7 @@ decodeValue = function(s, i) if s:sub(i, i) == "]" then return arr, i + 1 end while true do local val - val, i = decodeValue(s, i) + val, i = decodeValue(s, i, depth) arr[#arr + 1] = val i = skipWs(s, i) local d = s:sub(i, i) @@ -162,9 +166,13 @@ decodeValue = function(s, i) end end -function Json.decode(s) +function Json.decode(s, maxLength) + if type(s) ~= "string" then return nil, "json input is not a string" end + if maxLength and #s > maxLength then + return nil, ("json input is %d bytes (max %d)"):format(#s, maxLength) + end local ok, v = pcall(function() - local val = select(1, decodeValue(s, 1)) + local val = select(1, decodeValue(s, 1, 0)) return val end) if ok then return v end diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 40c094d7..2d899738 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -29,7 +29,9 @@ local LinkBattle = {} -- Deterministic Park-Miller PRNG: both sides must roll identical -- streams, so love.math.random can't be used. local function makeRng(seed) - local s = seed % 2147483647 + local s = tonumber(seed) or 1 + if s ~= s or s == math.huge or s == -math.huge then s = 1 end + s = math.floor(s) % 2147483647 if s <= 0 then s = s + 2147483646 end return function(a, b) s = (s * 16807) % 2147483647 diff --git a/src/link/Net.lua b/src/link/Net.lua index b8d4654c..6d1ad4b2 100644 --- a/src/link/Net.lua +++ b/src/link/Net.lua @@ -40,6 +40,10 @@ Net.__index = Net Net.DEFAULT_PORT = 7777 Net.DEFAULT_RELAY_ADDRESS = "147.182.215.255:7778" +Net.MAX_LINE = 256 * 1024 +Net.MAX_RX_PER_FRAME = 512 * 1024 +Net.ENET_BANDWIDTH = 256 * 1024 + function Net.available() return enet ~= nil end @@ -115,7 +119,8 @@ function Net:host(port) return false end port = tonumber(port) or Net.defaultPort() - local ok, h, err = pcall(enet.host_create, ("*:%d"):format(port), 2, 1) + local ok, h, err = pcall(enet.host_create, ("*:%d"):format(port), 2, 1, + Net.ENET_BANDWIDTH, Net.ENET_BANDWIDTH) if not ok or not h then self.error = ("can't open UDP port %d (%s)"):format( port, tostring(ok and err or h)) @@ -255,7 +260,7 @@ local function handleGenericRelayControl(self, msg) end function Net:handleTCPLine(line) - local msg = Json.decode(line) + local msg = Json.decode(line, Net.MAX_LINE) if msg == nil then Logger.warn("link: bad relay message %q", line:sub(1, 60)) return @@ -277,6 +282,11 @@ function Net:drainLines() self.rxBuf = self.rxBuf:sub(nl + 1) if #line > 0 then self:handleTCPLine(line) end end + if #self.rxBuf > Net.MAX_LINE then + self.rxBuf = "" + self.error = Strings("The other side\nsent bad data.") + self.closed = true + end end -- non-blocking pump for the relay TCP backend: flush queued writes, drain @@ -300,10 +310,14 @@ function Net:updateTCP() return end end - while true do + local budget = Net.MAX_RX_PER_FRAME + while budget > 0 do local data, err, partial = sock:receive(8192) local chunk = data or partial or "" - if #chunk > 0 then self.rxBuf = self.rxBuf .. chunk end + if #chunk > 0 then + self.rxBuf = self.rxBuf .. chunk + budget = budget - #chunk + end if err == "closed" then self.closed = true break @@ -350,7 +364,7 @@ function Net:update() for _, msg in ipairs(queued) do self:send(msg) end end elseif event.type == "receive" then - local msg = Json.decode(event.data) + local msg = Json.decode(event.data, Net.MAX_LINE) if msg ~= nil then table.insert(self.inbox, msg) else diff --git a/src/link/Protocol.lua b/src/link/Protocol.lua index bdf072a4..5de59257 100644 --- a/src/link/Protocol.lua +++ b/src/link/Protocol.lua @@ -17,6 +17,25 @@ local Runtime = require("src.mods.Runtime") local Protocol = {} +local MAX_WIRE_NAME = 40 + +local function num(v, default) + local n = tonumber(v) + if n == nil or n ~= n or n == math.huge or n == -math.huge then + return default + end + return n +end + +local function tbl(v) + return type(v) == "table" and v or {} +end + +local function text(v) + if type(v) ~= "string" then return nil end + return v:sub(1, MAX_WIRE_NAME) +end + Protocol.hello = Handshake.hello Protocol.checkCompat = Handshake.checkCompat @@ -78,6 +97,10 @@ function Protocol.unpackMon(data, packed, opts) local Stats = require("src.pokemon.Stats") local Growth = require("src.pokemon.Growth") local strict = opts and opts.strict + if type(packed) ~= "table" then + if strict then return nil, "unknown POKéMON" end + return nil + end -- forceLevel comes from an "auto-level" ruling. The picker's ANY choice -- ("use each mon's real level", Gen1's only mode) is a string sentinel on -- the LinkState/Tournament side (see levelForWire) that must mean "no @@ -91,7 +114,7 @@ function Protocol.unpackMon(data, packed, opts) if strict then return nil, "unknown POKéMON" end return nil end - local level = math.max(2, math.min(100, math.floor(packed.level or 5))) + local level = math.max(2, math.min(100, math.floor(num(packed.level, 5)))) -- "auto-level" tournaments/matches: every participant's real level is -- ignored and everyone rebuilds at the same fixed level instead, so a -- Lv12 and a Lv100 party can battle on equal footing. Both sides pass @@ -99,25 +122,27 @@ function Protocol.unpackMon(data, packed, opts) if forceLevel then level = math.max(2, math.min(100, math.floor(forceLevel))) end + local packedDvs, packedStatExp = tbl(packed.dvs), tbl(packed.statExp) local dvs = {} for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do - dvs[k] = math.max(0, math.min(15, math.floor((packed.dvs or {})[k] or 0))) + dvs[k] = math.max(0, math.min(15, math.floor(num(packedDvs[k], 0)))) end local statExp = {} for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do - statExp[k] = math.max(0, math.min(65535, math.floor((packed.statExp or {})[k] or 0))) + statExp[k] = math.max(0, math.min(65535, math.floor(num(packedStatExp[k], 0)))) end local stats = Stats.calc(def, level, dvs, statExp) local moves = {} - for _, mv in ipairs(packed.moves or {}) do + for _, entry in ipairs(tbl(packed.moves)) do + local mv = tbl(entry) local mdef = data.moves[mv.id] if mdef and #moves < 4 then - local ppUps = math.max(0, math.min(3, math.floor(mv.ppUps or 0))) + local ppUps = math.max(0, math.min(3, math.floor(num(mv.ppUps, 0)))) local maxPP = mdef.pp + ppUps * math.floor(mdef.pp / 5) - local entry = { id = mv.id, - pp = math.max(0, math.min(maxPP, math.floor(mv.pp or 0))) } - if mv.ppUps ~= nil then entry.ppUps = ppUps end - table.insert(moves, entry) + local move = { id = mv.id, + pp = math.max(0, math.min(maxPP, math.floor(num(mv.pp, 0)))) } + if mv.ppUps ~= nil then move.ppUps = ppUps end + table.insert(moves, move) end end if #moves == 0 then @@ -132,27 +157,29 @@ function Protocol.unpackMon(data, packed, opts) -- same as a standardized tournament format would local forced = forceLevel local hp = forced and stats.hp - or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp))) - local status = forced and nil or packed.status + or math.max(0, math.min(stats.hp, math.floor(num(packed.hp, stats.hp)))) + local status = forced and nil or text(packed.status) -- preserve the sender's original-trainer identity (party_struct MON_OTID + -- wPartyMonOT on a real cable), clamped/typed like every other field so a -- tampered packet can't inject a bad ID or a huge name. Left nil when the -- packet omits them (a v1/old peer) -- no worse than before for that legacy -- path, and once ot is set the load-time stampOT backfill (mon.ot or ...) -- becomes a no-op so the sender's identity survives save/reload (#215). - local otId = packed.otId - and math.max(0, math.min(65535, math.floor(packed.otId))) or nil + local packedOtId = num(packed.otId) + local otId = packedOtId + and math.max(0, math.min(65535, math.floor(packedOtId))) or nil local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil return { species = packed.species, level = level, - exp = math.max(0, math.floor(packed.exp or Growth.expForLevel(def.growthRate, level))), + exp = math.max(0, math.floor(num(packed.exp, + Growth.expForLevel(def.growthRate, level)))), dvs = dvs, statExp = statExp, stats = stats, hp = hp, status = status, - nickname = packed.nickname, + nickname = text(packed.nickname), ot = ot, otId = otId, moves = moves, @@ -246,6 +273,10 @@ end function Protocol.unpackMon2(data, packed, opts) local Mon = require("src.battle.gen2.Mon") local strict = opts and opts.strict + if type(packed) ~= "table" then + if strict then return nil, "unknown POKéMON" end + return nil + end local forceLevel = opts and tonumber(opts.forceLevel) or nil local def = data and data.pokemon and data.pokemon[packed.species] if not def then @@ -253,31 +284,33 @@ function Protocol.unpackMon2(data, packed, opts) return nil end local level = math.max(1, math.min(Mon.MAX_LEVEL, - math.floor(packed.level or 5))) + math.floor(num(packed.level, 5)))) if forceLevel then level = math.max(1, math.min(Mon.MAX_LEVEL, math.floor(forceLevel))) end + local packedDvs, packedStatExp = tbl(packed.dvs), tbl(packed.statExp) local dvs = {} for _, k in ipairs(GEN2_DVS) do dvs[k] = math.max(0, math.min(Mon.MAX_DV, - math.floor((packed.dvs or {})[k] or 0))) + math.floor(num(packedDvs[k], 0)))) end -- derived, never taken from the packet (see GEN2_DVS above) dvs.hp = Mon.hpDV(dvs) local statExp = {} for _, k in ipairs(GEN2_STAT_EXP) do statExp[k] = math.max(0, math.min(65535, - math.floor((packed.statExp or {})[k] or 0))) + math.floor(num(packedStatExp[k], 0)))) end local stats = Mon.stats(def.baseStats, dvs, level, statExp) local moves = {} - for _, mv in ipairs(packed.moves or {}) do + for _, packedMove in ipairs(tbl(packed.moves)) do + local mv = tbl(packedMove) local mdef = data.moves and data.moves[mv.id] if mdef and #moves < 4 then - local ppUps = math.max(0, math.min(3, math.floor(mv.ppUps or 0))) + local ppUps = math.max(0, math.min(3, math.floor(num(mv.ppUps, 0)))) local maxPp = (mdef.pp or 0) + ppUps * math.floor((mdef.pp or 0) / 5) local entry = { id = mv.id, maxPp = maxPp, - pp = math.max(0, math.min(maxPp, math.floor(mv.pp or 0))) } + pp = math.max(0, math.min(maxPp, math.floor(num(mv.pp, 0)))) } if mv.ppUps ~= nil then entry.ppUps = ppUps end table.insert(moves, entry) end @@ -296,26 +329,27 @@ function Protocol.unpackMon2(data, packed, opts) -- an item the peer cannot represent), and the Gen 2 arm of -- Protocol.eligibleParty is what keeps it from ever reaching here on a -- negotiated trade. - local item = packed.item + local item = type(packed.item) == "string" and packed.item or nil if item ~= nil and not (data.items and data.items[item]) then if strict then return nil, "unknown item" end item = nil end local forced = forceLevel local hp = forced and stats.hp - or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp))) - local status = forced and nil or packed.status - local otId = packed.otId - and math.max(0, math.min(65535, math.floor(packed.otId))) or nil + or math.max(0, math.min(stats.hp, math.floor(num(packed.hp, stats.hp)))) + local status = forced and nil or text(packed.status) + local packedOtId = num(packed.otId) + local otId = packedOtId + and math.max(0, math.min(65535, math.floor(packedOtId))) or nil local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil local growth = Mon.growthFor(data, def.growthRate) local mon = { species = packed.species, name = def.name or packed.species, - nickname = packed.nickname, + nickname = text(packed.nickname), level = level, - experience = math.max(0, math.floor(packed.experience - or Mon.experienceForLevel(growth, level))), + experience = math.max(0, math.floor(num(packed.experience, + Mon.experienceForLevel(growth, level)))), dvs = dvs, statExp = statExp, stats = stats, @@ -328,10 +362,10 @@ function Protocol.unpackMon2(data, packed, opts) -- GiveEgg starts a hatched mon at 120 and a caught one at 70; a traded mon -- keeps what it arrived with, clamped to the byte the cart stores it in happiness = math.max(0, math.min(255, - math.floor(packed.happiness or 70))), - pokerus = math.max(0, math.min(255, math.floor(packed.pokerus or 0))), + math.floor(num(packed.happiness, 70)))), + pokerus = math.max(0, math.min(255, math.floor(num(packed.pokerus, 0)))), caughtLevel = math.max(1, math.min(Mon.MAX_LEVEL, - math.floor(packed.caughtLevel or level))), + math.floor(num(packed.caughtLevel, level)))), ot = ot, otName = ot, otId = otId, @@ -339,7 +373,7 @@ function Protocol.unpackMon2(data, packed, opts) } if packed.isEgg then mon.isEgg = true - mon.eggSteps = math.max(0, math.floor(packed.eggSteps or 0)) + mon.eggSteps = math.max(0, math.floor(num(packed.eggSteps, 0))) end -- Derived from the DVs on the RECEIVING side, exactly as they were derived on -- the sending one: shininess, gender and an Unown's letter are all functions @@ -401,17 +435,19 @@ end -- which slots are in play and a pick can never land on a different mon. function Protocol.eligibleParty(party, myRecords, theirRecords) local eligible, reasons = {}, {} - theirRecords = theirRecords or {} - local theirSpecies = theirRecords.pokemon or {} - local theirMoves = theirRecords.moves or {} - local mySpecies = (myRecords or {}).pokemon or {} - local myMoves = (myRecords or {}).moves or {} + theirRecords = tbl(theirRecords) + myRecords = tbl(myRecords) + local theirSpecies = tbl(theirRecords.pokemon) + local theirMoves = tbl(theirRecords.moves) + local mySpecies = tbl(myRecords.pokemon) + local myMoves = tbl(myRecords.moves) -- Gen 2 only, and absent on both sides of a Gen 1 trade, which is what keeps -- the loop below unchanged for Red: a mon with no `item` never reaches the -- held-item arm at all. - local theirHeld = theirRecords.heldItems - local myHeld = (myRecords or {}).heldItems or {} - for i, mon in ipairs(party or {}) do + local theirHeld = theirRecords.heldItems ~= nil + and tbl(theirRecords.heldItems) or nil + local myHeld = tbl(myRecords.heldItems) + for i, mon in ipairs(tbl(party)) do local reason if not theirSpecies[mon.species] then reason = "not on the other game" @@ -551,7 +587,7 @@ function TradeSession:handle(msg) end if self.stage == "waitParty" then self.stage = "picking" end elseif msg.type == "pick" then - self.theirPick = msg.index + self.theirPick = num(msg.index) self:advance() elseif msg.type == "confirm" then self.theirConfirm = msg.ok @@ -583,7 +619,18 @@ function TradeSession:confirm(ok) return { type = "confirm", ok = ok } end +function TradeSession:pickResolves() + local index = self.theirPick + if type(index) ~= "number" then return false end + return self.theirParty ~= nil and self.theirParty[index] ~= nil +end + function TradeSession:advance() + if self.theirPick ~= nil and self.theirParty and not self:pickResolves() then + self.stage = "cancelled" + self.error = "the other game picked a POKéMON that isn't there" + return + end if self.stage == "picking" and self.myPick then self.stage = self.theirPick and "confirming" or "waitPick" elseif self.stage == "waitPick" and self.theirPick then diff --git a/src/link/Session.lua b/src/link/Session.lua index ecb4a64b..87fda6b6 100644 --- a/src/link/Session.lua +++ b/src/link/Session.lua @@ -1,3 +1,6 @@ +local Logger = require("src.core.Logger") +local Wire = require("src.link.Wire") + local Session = {} Session.__index = Session @@ -23,6 +26,7 @@ function Session.new(transport, options) _status = "connecting", _terminal = nil, _transportCloseCalled = false, + dropped = 0, paired = false, closed = false, error = nil, @@ -120,16 +124,15 @@ function Session:update() end else for index = 1, #messages do - local message = messages[index] - if type(message) ~= "table" or type(message.type) ~= "string" then - if not failureReason then - failureReason = "protocol_error" - failureDetail = ("message %d must be a table with string type") - :format(index) - end - break + local raw = messages[index] + local ok, message = pcall(Wire.sanitize, raw) + if ok and message then + self._inbox[#self._inbox + 1] = message + else + self.dropped = (self.dropped or 0) + 1 + local label = type(raw) == "table" and tostring(raw.type) or type(raw) + Logger.warn("link: dropped malformed message (%s)", label) end - self._inbox[#self._inbox + 1] = message end end diff --git a/src/link/Wire.lua b/src/link/Wire.lua new file mode 100644 index 00000000..6404a888 --- /dev/null +++ b/src/link/Wire.lua @@ -0,0 +1,344 @@ +local Wire = {} + +local MAX_INT = 2147483647 +local MAX_STRING = 64 +local MAX_NAME = 40 +local MAX_LIST = 64 +local MAX_PARTY = 32 +local MAX_MOVES = 8 +local MAX_MODS = 256 +local MAX_RECORDS = 4096 +local MAX_EXTRA_DEPTH = 8 +local MAX_ROUNDS = 16 +local MAX_MATCHES = 128 + +function Wire.num(v, default, min, max) + local n = tonumber(v) + if n == nil or n ~= n then return default end + min = min or -MAX_INT + max = max or MAX_INT + if n < min then return min end + if n > max then return max end + return math.floor(n) +end + +function Wire.str(v, default, maxLen) + if type(v) ~= "string" then return default end + maxLen = maxLen or MAX_STRING + if #v > maxLen then return v:sub(1, maxLen) end + return v +end + +function Wire.bool(v, default) + if type(v) == "boolean" then return v end + return default +end + +function Wire.list(v, maxN, fn) + local out = {} + if type(v) ~= "table" then return out end + local n = math.min(#v, maxN or MAX_LIST) + for i = 1, n do + local entry = fn(v[i]) + if entry ~= nil then out[#out + 1] = entry end + end + return out +end + +function Wire.records(v) + local out = {} + if type(v) ~= "table" then return out end + local n = 0 + for k, val in pairs(v) do + if type(k) == "string" then + out[k] = Wire.str(val, nil, MAX_STRING) or tostring(Wire.num(val, 0)) + n = n + 1 + if n >= MAX_RECORDS then break end + end + end + return out +end + +function Wire.plain(v, depth) + if type(v) ~= "table" then return nil end + depth = depth or 0 + if depth > MAX_EXTRA_DEPTH then return nil end + local out = {} + for k, val in pairs(v) do + local kt, vt = type(k), type(val) + if kt == "string" or kt == "number" then + if vt == "string" then out[k] = Wire.str(val, nil, MAX_STRING) + elseif vt == "number" or vt == "boolean" then out[k] = val + elseif vt == "table" then out[k] = Wire.plain(val, depth + 1) end + end + end + return out +end + +local STAT_KEYS = { "hp", "attack", "defense", "speed", "special" } + +local function statMap(v) + local out = {} + if type(v) ~= "table" then return out end + for _, k in ipairs(STAT_KEYS) do + out[k] = Wire.num(v[k], nil, 0, 65535) + end + return out +end + +local function move(v) + if type(v) ~= "table" then return { id = nil } end + return { + id = Wire.str(v.id, nil, MAX_STRING), + pp = Wire.num(v.pp, nil, 0, 255), + ppUps = Wire.num(v.ppUps, nil, 0, 255), + maxPp = Wire.num(v.maxPp, nil, 0, 255), + } +end + +local function mon(v) + if type(v) ~= "table" then return {} end + return { + species = Wire.str(v.species, nil, MAX_STRING), + level = Wire.num(v.level, nil, 0, 65535), + exp = Wire.num(v.exp, nil, 0, MAX_INT), + experience = Wire.num(v.experience, nil, 0, MAX_INT), + hp = Wire.num(v.hp, nil, 0, 65535), + status = Wire.str(v.status, nil, MAX_STRING), + nickname = Wire.str(v.nickname, nil, MAX_NAME), + dvs = statMap(v.dvs), + statExp = statMap(v.statExp), + moves = Wire.list(v.moves, MAX_MOVES, move), + ot = Wire.str(v.ot, nil, MAX_NAME), + otId = Wire.num(v.otId, nil, 0, MAX_INT), + item = Wire.str(v.item, nil, MAX_STRING), + happiness = Wire.num(v.happiness, nil, 0, 65535), + pokerus = Wire.num(v.pokerus, nil, 0, 65535), + caughtLevel = Wire.num(v.caughtLevel, nil, 0, 65535), + isEgg = Wire.bool(v.isEgg, nil), + eggSteps = Wire.num(v.eggSteps, nil, 0, MAX_INT), + extra = Wire.plain(v.extra), + } +end + +local function modEntry(v) + if type(v) ~= "table" then return nil end + return { + id = Wire.str(v.id, nil, MAX_NAME), + version = Wire.str(v.version, nil, MAX_NAME) + or Wire.num(v.version, nil, 0, MAX_INT), + affectsLink = Wire.bool(v.affectsLink, nil), + language = Wire.bool(v.language, nil), + } +end + +local function name(v) + return Wire.str(v, nil, MAX_NAME) +end + +local sanitize + +local SCHEMAS = {} + +SCHEMAS.hello = function(m) + return { + protocol = Wire.num(m.protocol, nil, 0, MAX_INT), + name = name(m.name), + mode = Wire.str(m.mode, nil, MAX_STRING), + engineVersion = Wire.str(m.engineVersion, nil, MAX_STRING), + apiVersion = Wire.str(m.apiVersion, nil, MAX_STRING), + generation = Wire.num(m.generation, nil, 0, 255), + fingerprint = Wire.str(m.fingerprint, nil, MAX_STRING), + linkModified = Wire.bool(m.linkModified, nil), + mods = Wire.list(m.mods, MAX_MODS, modEntry), + } +end + +SCHEMAS.records = function(m) + return { + pokemon = Wire.records(m.pokemon), + moves = Wire.records(m.moves), + heldItems = m.heldItems ~= nil and Wire.records(m.heldItems) or nil, + } +end + +SCHEMAS.party = function(m) + return { + mons = Wire.list(m.mons, MAX_PARTY, mon), + seed = Wire.num(m.seed, nil, 0, MAX_INT), + forceLevel = Wire.num(m.forceLevel, nil, 0, 65535), + } +end + +SCHEMAS.pick = function(m) + return { index = Wire.num(m.index, nil, -MAX_INT, MAX_INT) } +end + +SCHEMAS.confirm = function(m) + return { ok = Wire.bool(m.ok, false) } +end + +SCHEMAS.action = function(m) + return { + kind = Wire.str(m.kind, "", MAX_STRING), + slot = Wire.num(m.slot, nil, 1, MAX_MOVES), + index = Wire.num(m.index, nil, 1, MAX_PARTY), + } +end + +SCHEMAS.hash = function(m) + local parts + if type(m.parts) == "table" then + parts = { + actives = Wire.str(m.parts.actives, nil, MAX_STRING), + volatile = Wire.str(m.parts.volatile, nil, MAX_STRING), + bench = Wire.str(m.parts.bench, nil, MAX_STRING), + } + end + return { + turn = Wire.num(m.turn, 0, 0, MAX_INT), + value = Wire.str(m.value, nil, MAX_STRING), + parts = parts, + } +end + +SCHEMAS.replace = function(m) + return { index = Wire.num(m.index, 1, 1, MAX_PARTY) } +end + +SCHEMAS.bye = function() return {} end +SCHEMAS.forfeit = function() return {} end + +SCHEMAS.hosted = function(m) + return { code = Wire.str(m.code, nil, MAX_NAME) } +end +SCHEMAS.paired = function() return {} end +SCHEMAS.peer_gone = function() return {} end +SCHEMAS.join_error = function(m) + return { reason = Wire.str(m.reason, "", MAX_STRING) } +end + +local function rule(m) + return { + requiredPartySize = Wire.num(m.requiredPartySize, nil, 0, 255), + minLevel = Wire.num(m.minLevel, nil, 0, 65535), + maxLevel = Wire.num(m.maxLevel, nil, 0, 65535), + turnLimit = Wire.num(m.turnLimit, nil, 0, 65535), + forceLevel = Wire.num(m.forceLevel, nil, 0, 65535), + } +end + +SCHEMAS.tournament_hosted = function(m) + local out = rule(m) + out.code = Wire.str(m.code, nil, MAX_NAME) + out.participating = Wire.bool(m.participating, nil) + return out +end + +SCHEMAS.tournament_host_error = function(m) + local out = rule(m) + out.reason = Wire.str(m.reason, "", MAX_STRING) + return out +end + +SCHEMAS.tournament_join_error = SCHEMAS.tournament_host_error + +SCHEMAS.tournament_roster = function(m) + local out = rule(m) + out.players = Wire.list(m.players, MAX_MATCHES, name) + out.spectators = Wire.list(m.spectators, MAX_MATCHES, name) + return out +end + +local function match(v) + if type(v) ~= "table" then return nil end + return { + a = name(v.a), b = name(v.b), winner = name(v.winner), + bye = Wire.bool(v.bye, false), + state = Wire.str(v.state, nil, MAX_STRING), + } +end + +local function round(v) + if type(v) ~= "table" then return nil end + return { + round = Wire.num(v.round, 0, 0, MAX_ROUNDS), + matches = Wire.list(v.matches, MAX_MATCHES, match), + } +end + +SCHEMAS.bracket_update = function(m) + local t = type(m.tournament) == "table" and m.tournament or {} + local out = rule(t) + out.code = Wire.str(t.code, nil, MAX_NAME) + out.status = Wire.str(t.status, nil, MAX_STRING) + out.round = Wire.num(t.round, 0, 0, MAX_ROUNDS) + out.champion = name(t.champion) + out.rounds = Wire.list(t.rounds, MAX_ROUNDS, round) + return { tournament = out } +end + +SCHEMAS.match_start = function(m) + return { + opponent = name(m.opponent), + round = Wire.num(m.round, 0, 0, MAX_ROUNDS), + turnLimit = Wire.num(m.turnLimit, nil, 0, 65535), + role = Wire.str(m.role, "", MAX_STRING), + } +end + +SCHEMAS.match_start_spectate = function(m) + return { + round = Wire.num(m.round, 0, 0, MAX_ROUNDS), + playerHost = name(m.playerHost), + playerGuest = name(m.playerGuest), + } +end + +SCHEMAS.tournament_bye = function(m) + return { round = Wire.num(m.round, 0, 0, MAX_ROUNDS) } +end + +SCHEMAS.tournament_over = function(m) + return { champion = name(m.champion) } +end + +local SPECTATABLE = { + action = true, replace = true, bye = true, forfeit = true, + hello = true, party = true, hash = true, +} + +SCHEMAS.spectate = function(m) + if type(m.msg) ~= "table" or not SPECTATABLE[m.msg.type] then return nil end + local inner = sanitize(m.msg) + if not inner then return nil end + return { side = Wire.str(m.side, "", MAX_STRING), msg = inner } +end + +Wire.SCHEMAS = SCHEMAS + +local function passthrough(m) + local out = Wire.plain(m) or {} + out.type = nil + return out +end + +sanitize = function(msg) + if type(msg) ~= "table" then return nil end + local kind = msg.type + if type(kind) ~= "string" or #kind > MAX_STRING then return nil end + local schema = SCHEMAS[kind] + local out + if schema then + out = schema(msg) + if not out then return nil end + else + out = passthrough(msg) + end + out.type = kind + return out +end + +Wire.sanitize = sanitize + +return Wire diff --git a/tests/engine/link_session.lua b/tests/engine/link_session.lua index 19f80fcf..0c1ef8c8 100644 --- a/tests/engine/link_session.lua +++ b/tests/engine/link_session.lua @@ -70,12 +70,12 @@ end do local host, guest = sessionPair() guest:send({ type = "before", sequence = 1 }) - guest:send({ type = "hello", sequence = 2 }) + guest:send({ type = "greeting", sequence = 2 }) guest:send({ type = "after", sequence = 3 }) - guest:send({ type = "hello", sequence = 4 }) + guest:send({ type = "greeting", sequence = 4 }) host:update() - local hello = host:take("hello") + local hello = host:take("greeting") T.eq(hello.sequence, 2, "take removes the first matching packet") T.eq(host:pollOne().sequence, 1, "pollOne removes only the FIFO head") @@ -122,14 +122,14 @@ end do local transport = fakeTransport({ onUpdate = function(self) - self.inbox[#self.inbox + 1] = { type = "bye", final = true } + self.inbox[#self.inbox + 1] = { type = "bye" } self.closed = true end }) local session = Session.new(transport, { role = "host", kind = "link" }) session:update() T.eq(session:getStatus(), "draining", "normal close drains its final packet") T.eq(session.closed, false, "compatibility closed waits for the FIFO") - T.eq(session:take("bye").final, true, "final close packet remains observable") + T.check(session:take("bye") ~= nil, "final close packet remains observable") T.eq(session:getStatus(), "closed", "normal drain reaches closed") T.eq(transport.closeCount, 1, "transport cleanup runs once") end @@ -182,21 +182,22 @@ do } }) local session = Session.new(transport, { role = "host", kind = "link" }) session:update() - local reason = session:getFailure() - T.eq(reason, "protocol_error", "malformed packet fails as protocol_error") - T.eq(session:getStatus(), "draining", "malformed batch drains valid prefix") + T.eq(session:getFailure(), nil, "a malformed packet is not a terminal failure") + T.eq(session:getStatus(), "paired", "the session stays usable after a bad packet") local messages = session:poll() - T.eq(#messages, 1, "malformed value and untrusted tail are not exposed") - T.eq(messages[1].sequence, 1, "valid prefix survives malformed packet") - T.eq(session:getStatus(), "failed", "protocol drain reaches failed") + T.eq(#messages, 2, "the malformed value is dropped, the rest is delivered") + T.eq(messages[1].sequence, 1, "packets before the malformed one survive") + T.eq(messages[2].sequence, 3, "packets after the malformed one survive") + T.eq(session.dropped, 1, "the drop is counted") end do local transport = fakeTransport({ inbox = { { type = 7 } } }) local session = Session.new(transport, { role = "host", kind = "link" }) session:update() - T.eq(session:getFailure(), "protocol_error", - "table without string type is a protocol error") + T.eq(session:getFailure(), nil, + "a table without a string type is dropped, not a terminal failure") + T.eq(#session:poll(), 0, "...and never reaches the mode") end do @@ -268,8 +269,9 @@ do local receiver = Session.new(receiverNet, { role = "guest", kind = "link" }) senderNet:send(false) receiver:update() - T.eq(receiver:getFailure(), "protocol_error", - "loopback forwards decoded false to session validation") + T.eq(receiver:getFailure(), nil, + "a decoded scalar off the loopback is dropped, not fatal") + T.eq(#receiver:poll(), 0, "...and never reaches the mode") end do @@ -284,8 +286,9 @@ do } local session = Session.new(transport, { role = "guest", kind = "link" }) session:update() - T.eq(session:getFailure(), "protocol_error", - "ENet forwards decoded false to session validation") + T.eq(session:getFailure(), nil, + "a decoded scalar off ENet is dropped, not fatal") + T.eq(#session:poll(), 0, "...and never reaches the mode") end do @@ -294,8 +297,9 @@ do T.check(pcall(transport.handleTCPLine, transport, "42"), "TCP control handoff does not index a decoded scalar") session:update() - T.eq(session:getFailure(), "protocol_error", - "decoded TCP scalar reaches session validation") + T.eq(session:getFailure(), nil, + "a decoded TCP scalar is dropped, not fatal") + T.eq(#session:poll(), 0, "...and never reaches the mode") end do diff --git a/tests/link_desync_fuzz.lua b/tests/link_desync_fuzz.lua index c819a24f..fae7dbc2 100644 --- a/tests/link_desync_fuzz.lua +++ b/tests/link_desync_fuzz.lua @@ -66,13 +66,39 @@ table.sort(MOVES) -- in the peer's inbox, so one side is mid-queue when the other's action -- arrives (Net.loopbackPair on its own delivers instantly, which is the one -- thing the real relay never does) -local function laggyPair(delayA, delayB) +-- mutation mode: a peer whose messages arrive with a random field (or the +-- type itself) at the wrong Lua type. What is delivered is what Session +-- would deliver -- Wire.sanitize's output, or nothing at all -- so a run +-- exercises the real receive path rather than a hand-written stand-in. +local Wire = require("src.link.Wire") +local HOSTILE = { {}, { 1, 2, 3 }, 0, -1, 999, "s", "", true, false, math.huge } + +local function mutate(rnd, msg) + local keys = {} + for k in pairs(msg) do + if k ~= "type" then keys[#keys + 1] = k end + end + table.sort(keys) + if #keys == 0 or rnd(1, 100) <= 20 then + msg.type = HOSTILE[rnd(1, #HOSTILE)] + else + msg[keys[rnd(1, #keys)]] = HOSTILE[rnd(1, #HOSTILE)] + end + return msg +end + +local function laggyPair(delayA, delayB, rnd, mutateRate) local a, b = Net.loopbackPair() a.wire, b.wire = {}, {} a.delay, b.delay = delayA or 0, delayB or 0 + a.mutateRate, b.mutateRate = mutateRate or 0, mutateRate or 0 local function send(self, msg) if self.closed then return end local decoded = Json.decode(Json.encode(msg)) -- same round trip as the wire + if decoded and self.mutateRate > 0 then + if rnd(1, 100) <= self.mutateRate then decoded = mutate(rnd, decoded) end + decoded = Wire.sanitize(decoded) + end if decoded then table.insert(self.wire, { msg = decoded, at = self.delay }) end end local function update(self) @@ -143,7 +169,7 @@ local function firstMismatch(a, b) end -- Returns nil when the run agreed, or a description of how it split. -local function runOne(seed) +local function runOne(seed, mutateRate) local rnd = makeRandom(seed) -- the two sides are deliberately different clients local optsA = { animations = false, textSpeed = 1, battleStyle = "SET" } @@ -155,7 +181,7 @@ local function runOne(seed) gameA.save.party = randomParty(rnd, rnd(1, 4)) gameB.save.party = randomParty(rnd, rnd(1, 4)) - local netA, netB = laggyPair(lagA, lagB) + local netA, netB = laggyPair(lagA, lagB, rnd, mutateRate) local battleSeed = rnd(1, 2 ^ 30) local battleA = LinkBattle.newHost(gameA, netA, { myParty = Protocol.packParty(gameA.save.party), @@ -230,6 +256,12 @@ local function runOne(seed) end end local turn, part = firstMismatch(battleA, battleB) + if turn and mutateRate then + -- a corrupted action IS a divergence; what matters here is that the + -- match ends by its own rules (desync draw, forfeit, disconnect) + -- rather than throwing + return nil, battleA.turnCount or 0 + end if turn then return ("seed %d: turn %d %s split (lag %d/%d, steps %d/%d)"):format( seed, turn, part, lagA, lagB, stepsA, stepsB), battleA.turnCount or 0 @@ -237,7 +269,7 @@ local function runOne(seed) end -- a battle still running at the guard is a stalemate (two mons that cannot -- KO each other), not a split; only a finished one can be checked mirrored - if guard < 60000 + if not mutateRate and guard < 60000 and (battleA.player.mon.hp ~= battleB.enemy.mon.hp or battleA.enemy.mon.hp ~= battleB.player.mon.hp) then return ("seed %d: final HP not mirrored (%d/%d vs %d/%d)"):format( @@ -250,6 +282,8 @@ end local RUNS = tonumber(arg and arg[1]) or 40 local FIRST = tonumber(arg and arg[2]) or 1 +local MUTATION_RUNS = tonumber(arg and arg[3]) or math.max(4, math.floor(RUNS / 4)) + local failures, turns = 0, 0 for seed = FIRST, FIRST + RUNS - 1 do local ok, why, t = pcall(runOne, seed) @@ -263,5 +297,21 @@ for seed = FIRST, FIRST + RUNS - 1 do end end print(("link desync fuzz: %d runs, %d turns, %d failures"):format(RUNS, turns, failures)) + +local mutationFailures = 0 +for seed = FIRST, FIRST + MUTATION_RUNS - 1 do + local ok, why = pcall(runOne, seed, 15) + if not ok then + mutationFailures = mutationFailures + 1 + print("FAIL link mutation fuzz seed " .. seed .. ": " .. tostring(why)) + elseif why then + mutationFailures = mutationFailures + 1 + print("FAIL link mutation fuzz " .. why) + end +end +print(("link mutation fuzz: %d runs, %d failures"):format( + MUTATION_RUNS, mutationFailures)) + assert(failures == 0, failures .. " lockstep run(s) diverged") +assert(mutationFailures == 0, mutationFailures .. " mutated run(s) threw") return true diff --git a/tests/link_hostile.lua b/tests/link_hostile.lua new file mode 100644 index 00000000..8fd63bcb --- /dev/null +++ b/tests/link_hostile.lua @@ -0,0 +1,396 @@ +-- Hostile link traffic: every message type this build reads, with every +-- field replaced by every wrong Lua type, driven through the real Session +-- choke point and then into the real consumers (trade session, link battle, +-- spectator battle, tournament screen -- including its draw). +-- +-- The three payloads from the "How to Troll Pokemon Players" writeup are +-- rows in the table below: action.slot as a table, hash.parts as a number, +-- and pick.index out of range. +-- +-- Self-contained; run directly or via run_link_tests.lua: +-- luajit tests/link_hostile.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local Data = require("src.core.Data") +if not Data.pokemon then Data:load() end +local Font = require("src.render.Font") +Font.load(Data) + +local Input = require("src.core.Input") +Input:init() + +local Json = require("src.link.Json") +local LinkBattle = require("src.link.LinkBattle") +local Net = require("src.link.Net") +local Pokemon = require("src.pokemon.Pokemon") +local Protocol = require("src.link.Protocol") +local Session = require("src.link.Session") +local Tournament = require("src.link.Tournament") +local Wire = require("src.link.Wire") + +local failures = 0 +local function check(cond, msg) + if cond then + print("ok " .. msg) + else + failures = failures + 1 + print("FAIL " .. msg) + end +end + +-- ---------------------------------------------------------------- corpus + +local HOSTILE = { {}, { 1, 2, 3 }, { type = "x" }, 0, -1, 123, 1.5, + math.huge, "s", "", true, false } + +local function copy(v) + if type(v) ~= "table" then return v end + local out = {} + for k, val in pairs(v) do out[k] = copy(val) end + return out +end + +local packedMon = Protocol.packMon(Pokemon.new(Data, "PIKACHU", 12)) + +local TEMPLATES = { + { type = "hello", protocol = 2, name = "RED", mode = "battle", + engineVersion = "1.0.0", apiVersion = "1", generation = 1, + fingerprint = "abc123", linkModified = false, + mods = { { id = "demo", version = "1.0", affectsLink = true } } }, + { type = "records", pokemon = { PIKACHU = "a" }, moves = { TACKLE = "b" }, + heldItems = { BERRY = "c" } }, + { type = "party", mons = { copy(packedMon) }, seed = 1234, forceLevel = 50 }, + { type = "pick", index = 1 }, + { type = "confirm", ok = true }, + { type = "action", kind = "move", slot = 1, index = 1 }, + { type = "hash", turn = 1, value = "v", + parts = { actives = "a", volatile = "b", bench = "c" } }, + { type = "replace", index = 1 }, + { type = "bye" }, + { type = "forfeit" }, + { type = "spectate", side = "host", + msg = { type = "action", kind = "move", slot = 1 } }, + { type = "hosted", code = "ABCDEF" }, + { type = "paired" }, + { type = "peer_gone" }, + { type = "join_error", reason = "not_found" }, + { type = "tournament_hosted", code = "ABCDEF", turnLimit = 6, + requiredPartySize = 3, minLevel = 5, maxLevel = 50, forceLevel = 50, + participating = true }, + { type = "tournament_host_error", reason = "party_ineligible", + requiredPartySize = 3, minLevel = 5, maxLevel = 50 }, + { type = "tournament_join_error", reason = "party_ineligible", + requiredPartySize = 3, minLevel = 5, maxLevel = 50 }, + { type = "tournament_roster", players = { "RED", "BLUE" }, + spectators = { "GREEN" }, turnLimit = 6, requiredPartySize = 3, + minLevel = 5, maxLevel = 50, forceLevel = 50 }, + { type = "bracket_update", tournament = { code = "ABCDEF", turnLimit = 6, + requiredPartySize = 3, minLevel = 5, maxLevel = 50, status = "active", + round = 1, champion = "RED", + rounds = { { round = 1, matches = { { a = "RED", b = "BLUE", + winner = "RED", bye = false, state = "live" } } } } } }, + { type = "match_start", opponent = "BLUE", round = 1, turnLimit = 6, + role = "host" }, + { type = "match_start_spectate", round = 1, playerHost = "RED", + playerGuest = "BLUE" }, + { type = "tournament_bye", round = 1 }, + { type = "tournament_over", champion = "RED" }, + { type = "a_type_this_build_has_never_heard_of", payload = { n = 1 } }, +} + +local NESTED = { + { "hash", { "parts", "actives" } }, + { "party", { "mons", 1 } }, + { "party", { "mons", 1, "dvs" } }, + { "party", { "mons", 1, "moves" } }, + { "party", { "mons", 1, "moves", 1 } }, + { "party", { "mons", 1, "nickname" } }, + { "party", { "mons", 1, "level" } }, + { "party", { "mons", 1, "otId" } }, + { "hello", { "mods", 1 } }, + { "spectate", { "msg" } }, + { "spectate", { "msg", "slot" } }, + { "bracket_update", { "tournament", "rounds" } }, + { "bracket_update", { "tournament", "rounds", 1, "matches" } }, + { "bracket_update", { "tournament", "rounds", 1, "matches", 1, "a" } }, + { "tournament_roster", { "players", 1 } }, +} + +local function templateFor(kind) + for _, t in ipairs(TEMPLATES) do + if t.type == kind then return t end + end +end + +local function setPath(root, path, value) + local node = root + for i = 1, #path - 1 do + node = node[path[i]] + if type(node) ~= "table" then return false end + end + node[path[#path]] = value + return true +end + +local corpus = {} +local function add(msg) corpus[#corpus + 1] = msg end + +add(false); add(true); add(123); add("string"); add({}); add({ 1, 2, 3 }) +add({ type = 5 }); add({ type = {} }); add({ type = true }) +add({ type = ("x"):rep(4096) }) + +for _, template in ipairs(TEMPLATES) do + add(copy(template)) + for key in pairs(template) do + if key ~= "type" then + for _, bad in ipairs(HOSTILE) do + local m = copy(template) + m[key] = copy(bad) + add(m) + end + local missing = copy(template) + missing[key] = nil + add(missing) + end + end +end + +for _, row in ipairs(NESTED) do + local template = templateFor(row[1]) + for _, bad in ipairs(HOSTILE) do + local m = copy(template) + if setPath(m, row[2], copy(bad)) then add(m) end + end + local m = copy(template) + if setPath(m, row[2], nil) then add(m) end +end + +print(("hostile corpus: %d messages"):format(#corpus)) + +-- ---------------------------------------------------------------- session + +local function fakeTransport(inbox) + local transport = { paired = true, closed = false, error = nil, + inbox = inbox, sent = {} } + function transport:update() end + function transport:poll() + local messages = self.inbox + self.inbox = {} + return messages + end + function transport:send(m) table.insert(self.sent, m) end + function transport:close() self.closed = true end + return transport +end + +local transport = fakeTransport(copy(corpus)) +local session = Session.new(transport, { role = "guest", kind = "link" }) +local okUpdate, updateErr = pcall(session.update, session) +check(okUpdate, "the whole hostile corpus goes through Session without throwing" + .. (okUpdate and "" or (": " .. tostring(updateErr)))) +check(session:getFailure() == nil, + "a hostile peer cannot latch a terminal failure on the session") +check(session:getStatus() == "paired", "the session is still usable afterwards") +local survivors = session:poll() +check(#survivors > 0, "well-formed messages still get through") +check(session.dropped > 0, "malformed messages are counted as dropped") + +for _, msg in ipairs(survivors) do + if type(msg.type) ~= "string" then + check(false, "every delivered message has a string type") + break + end +end +check(true, "every delivered message has a string type") + +for _, msg in ipairs(survivors) do + local ok = pcall(Json.encode, msg) + if not ok then + check(false, "every delivered message is still encodable (" .. msg.type .. ")") + break + end +end +check(true, "every delivered message is still encodable") + +-- ---------------------------------------------------------------- consumers + +local function makeFakeGame(species, name) + local save = require("src.core.SaveData").newGame() + save.player.name = name or "RED" + table.insert(save.party, Pokemon.new(Data, species, 20)) + local stack = { list = {} } + function stack:push(s, ...) + table.insert(self.list, s) + if s.enter then s:enter(...) end + end + function stack:pop() return table.remove(self.list) end + function stack:top() return self.list[#self.list] end + function stack:update(dt) + local t = self:top() + if t and t.update then t:update(dt) end + end + return { data = Data, input = Input, stack = stack, save = save } +end + +do + local crashed + for _, msg in ipairs(survivors) do + local party = { Pokemon.new(Data, "KADABRA", 30) } + local t = Protocol.TradeSession.new(Data, party) + local ok, err = pcall(function() + t:handle({ type = "party", mons = Protocol.packParty({ + Pokemon.new(Data, "MACHOKE", 32) }) }) + t:handle(msg) + t:pick(1) + t:handle(msg) + t:confirm(true) + t:handle(msg) + if t.stage == "done" then t:apply(nil) end + end) + if not ok then crashed = ("%s: %s"):format(tostring(msg.type), tostring(err)) end + if crashed then break end + end + check(not crashed, "the trade session survives every hostile message" + .. (crashed and (": " .. crashed) or "")) +end + +do + local party = { Pokemon.new(Data, "KADABRA", 30) } + local t = Protocol.TradeSession.new(Data, party) + t:handle({ type = "party", + mons = Protocol.packParty({ Pokemon.new(Data, "MACHOKE", 32) }) }) + t:pick(1) + t:handle(Wire.sanitize({ type = "pick", index = 0 })) + t:confirm(true) + t:handle(Wire.sanitize({ type = "confirm", ok = true })) + check(t.stage ~= "done", "an out-of-range pick never reaches a committed trade") + check(t.stage == "cancelled", "...it cancels the trade instead") +end + +do + local gameA = makeFakeGame("CHARIZARD", "RED") + local gameB = makeFakeGame("BLASTOISE", "BLUE") + local netA, netB = Net.loopbackPair() + local battleA = LinkBattle.newHost(gameA, netA, { + myParty = Protocol.packParty(gameA.save.party), + theirParty = Protocol.packParty(gameB.save.party), + theirName = "BLUE", seed = 4242 }) + gameA.stack:push(battleA) + local crashed + for _, msg in ipairs(survivors) do + table.insert(netA.inbox, copy(msg)) + local ok, err = pcall(function() + Input.pressed = {} + gameA.stack:update(1 / 60) + end) + if not ok then + crashed = ("%s: %s"):format(tostring(msg.type), tostring(err)) + break + end + end + check(not crashed, "a link battle survives every hostile message" + .. (crashed and (": " .. crashed) or "")) +end + +do + local gameSpec = makeFakeGame("RATTATA", "WATCHER") + local specInbox = {} + local specNet = { + closed = false, + update = function() end, + poll = function() + local msgs = specInbox + specInbox = {} + return msgs + end, + send = function() end, + close = function() end, + } + local battle = LinkBattle.newSpectator(gameSpec, specNet, { + hostParty = Protocol.packParty(makeFakeGame("CHARIZARD").save.party), + guestParty = Protocol.packParty(makeFakeGame("BLASTOISE").save.party), + hostName = "RED", guestName = "BLUE", seed = 99 }) + gameSpec.stack:push(battle) + local crashed + for _, msg in ipairs(corpus) do + for _, side in ipairs({ "host", "guest", 5, {} }) do + local wrapped = Wire.sanitize({ type = "spectate", side = side, + msg = copy(msg) }) + if wrapped then table.insert(specInbox, wrapped) end + end + local ok, err = pcall(function() + Input.pressed = {} + gameSpec.stack:update(1 / 60) + end) + if not ok then + crashed = tostring(err) + break + end + end + check(not crashed, "a spectator battle survives every hostile envelope" + .. (crashed and (": " .. crashed) or "")) +end + +do + local game = makeFakeGame("PIKACHU", "RED") + local exits = 0 + local t = setmetatable({ + game = game, + stage = "bracket", + index = 1, + settingsIndex = 1, + settings = { turnLimit = 6, requiredPartySize = 3, minLevel = "ANY", + maxLevel = "ANY", forceLevel = "ANY", participating = true }, + roster = {}, + spectatorRoster = {}, + isCreator = false, + net = { send = function() end, close = function() end, + take = function() return nil end, + poll = function() return {} end, + hasPending = function() return false end }, + }, Tournament) + t.exitWith = function(self) exits = exits + 1 end + local crashed + for _, msg in ipairs(survivors) do + local ok, err = pcall(function() + t:handleMessage(msg) + t:draw() + end) + if not ok then + crashed = ("%s: %s"):format(tostring(msg.type), tostring(err)) + break + end + if type(t.roster) ~= "table" or type(t.spectatorRoster) ~= "table" then + crashed = ("%s left a non-table roster"):format(tostring(msg.type)) + break + end + end + check(not crashed, "the tournament screen survives every hostile message" + .. (crashed and (": " .. crashed) or "")) +end + +-- ---------------------------------------------------------------- json +do + local deep = ("["):rep(4096) .. ("]"):rep(4096) + local value, err = Json.decode(deep) + check(value == nil and err ~= nil, "a deeply nested document is refused") + local long = '{"type":"hello","name":"' .. ("x"):rep(1024) .. '"}' + check(Json.decode(long, 256) == nil, "a document past the caller's cap is refused") + check(Json.decode(long) ~= nil, "...and the cap is opt-in for other callers") +end + +-- ---------------------------------------------------------------- net caps +do + local n = Net.new() + n.rxBuf = ("x"):rep(Net.MAX_LINE + 1) + n:drainLines() + check(n.closed and n.error ~= nil, + "a peer that never sends a newline closes the connection") + check(#n.rxBuf == 0, "...and the buffer is released") +end + +print(("\nlink hostile: %d messages, %d failures"):format(#corpus, failures)) +assert(failures == 0, failures .. " hostile-input failure(s)") +return true diff --git a/tests/run_link_tests.lua b/tests/run_link_tests.lua index 2b1921b3..cf751311 100644 --- a/tests/run_link_tests.lua +++ b/tests/run_link_tests.lua @@ -645,6 +645,14 @@ check(fxAdvances("messages", "linkNext"), local fuzzOk, fuzzErr = pcall(dofile, "tests/link_desync_fuzz.lua") check(fuzzOk, "lockstep desync fuzz" .. (fuzzOk and "" or (": " .. tostring(fuzzErr)))) +-- ---------------------------------------------------------------- hostile wire +-- Every message type crossed with every wrong Lua type, through the real +-- Session choke point and into the real consumers. The regression net for +-- the remote-crash payloads; self-contained like the fuzz above. +local hostileOk, hostileErr = pcall(dofile, "tests/link_hostile.lua") +check(hostileOk, "hostile wire suite" + .. (hostileOk and "" or (": " .. tostring(hostileErr)))) + -- ---------------------------------------------------------------- mod link compat -- Self-contained like the tests/mod_*.lua suites: own bootstrap and -- assert-based checks, so it lands here as a single pass/fail line.