rook/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Gate a zone's position on line of sight, not just whose trip it is
Non-trip zones (smoke, wall, molly, flash) were sent to every player at full precision regardless of visibility, only trips were gated on side. Since abilities land at or near where the thrower stands, this handed every client a partial position oracle on enemies they could not see — the same shape as the shot leak, a non-body field carrying a position, unfiltered. A zone is now sent to a viewer only if they are standing in it (so your own smoke never disappears from under you) or, failing that, if no wall sits between them, using wallBetween directly rather than sightBlocked, which also counts zones and would let one zone hide another behind it. Added a generic regression test that stringifies a viewer's whole snapshot and checks for the id or rounded coordinate of a body they cannot see anywhere in the payload, not just in one named field — it would have caught this leak and the two before it in whatever field carried them, and will catch the next one wherever it lands. It fails against the pre-fix filter and passes after. Checked every other field for the same shape: orbs are fixed map furniture, spike.x/y is already gated on planted-or-attacker, and board/feed/events carry names, not places. Nothing else leaks a position. Co-Authored-By: Claude Sonnet 5 <[email protected]>
2 files
changed +68
−3
src/arena.ts +10 −3
| @@ -17,8 +17,9 @@ import { recordResult } from "./db.ts"; | ||
| 17 | 17 | import { makeArena, type Command, type Why } from "./engine/arena.ts"; |
| 18 | 18 | import { fallsBetween, marksOf, trimFeed, type Fall, type Mark } from "./killfeed.ts"; |
| 19 | 19 | import type { Point } from "./map.ts"; |
| 20 | import { buyAbility, buyShield, buyWeapon, newMatch, pickAgent, setInput, tick, visible } from "./match.ts"; | |
| 21 | import type { MatchState, Player } from "./state.ts"; | |
| 20 | import { wallBetween } from "./map.ts"; | |
| 21 | import { buyAbility, buyShield, buyWeapon, newMatch, pickAgent, PLAYER_R, setInput, tick, visible } from "./match.ts"; | |
| 22 | import type { MatchState, Player, Zone } from "./state.ts"; | |
| 22 | 23 | import { isWeaponId, WEAPONS } from "./weapons.ts"; |
| 23 | 24 | |
| 24 | 25 | export { broadcastEvery, TICK, type Command, type Seat, type Why } from "./engine/arena.ts"; |
| @@ -113,6 +114,12 @@ function spectatorShape(state: MatchState, _why: Why) { | ||
| 113 | 114 | function snapshotShape(state: MatchState, me: Player, seen: Player[]) { |
| 114 | 115 | const agent = agentById(me.agent); |
| 115 | 116 | const canSee = (point: Point): boolean => !sightBlocked(me, point, state.zones); |
| 117 | // A zone is known to the viewer if they are standing in it (so your own | |
| 118 | // smoke never vanishes from under you) or, failing that, if no wall sits | |
| 119 | // between the two — the wall test only, not `sightBlocked`, which also | |
| 120 | // counts zones and would let a smoke hide the wall behind it. | |
| 121 | const canSeeZone = (z: Zone): boolean => | |
| 122 | Math.hypot(me.x - z.x, me.y - z.y) <= z.r + PLAYER_R || !wallBetween(me, z); | |
| 116 | 123 | |
| 117 | 124 | return { |
| 118 | 125 | phase: state.phase, |
| @@ -167,7 +174,7 @@ function snapshotShape(state: MatchState, me: Player, seen: Player[]) { | ||
| 167 | 174 | revealed: p.revealed > 0, |
| 168 | 175 | })), |
| 169 | 176 | zones: state.zones |
| 170 | .filter((z) => (z.kind === "trip" ? z.side === me.side : true)) | |
| 177 | .filter((z) => (z.kind === "trip" ? z.side === me.side : canSeeZone(z))) | |
| 171 | 178 | .map((z) => ({ k: z.kind, x: round2(z.x), y: round2(z.y), r: z.r, f: round2(z.fuse), l: round2(z.life) })), |
| 172 | 179 | spike: { |
| 173 | 180 | planted: state.spike.planted, |
src/server.test.ts +58
| @@ -7,6 +7,7 @@ process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); | ||
| 7 | 7 | process.env.PORT = "0"; |
| 8 | 8 | const { server } = await import("./server.ts"); |
| 9 | 9 | const { match, snapshot, advance, takeSeat, release, releaseAddress } = await import("./arena.ts"); |
| 10 | const { visible } = await import("./match.ts"); | |
| 10 | 11 | const base = `http://${server.hostname}:${server.port}`; |
| 11 | 12 | |
| 12 | 13 | test("serves a live match on the first paint, with no script needed", async () => { |
| @@ -158,6 +159,63 @@ test("a snapshot only carries the enemies that viewer can see", () => { | ||
| 158 | 159 | expect(JSON.stringify(far.board)).not.toContain('"x"'); |
| 159 | 160 | }); |
| 160 | 161 | |
| 162 | test("the whole snapshot carries no id or position of a body the viewer cannot see", () => { | |
| 163 | // Generic on purpose: this scans the whole stringified payload rather | |
| 164 | // than one named field, so it would have caught each of the three leaks | |
| 165 | // of this shape — a hidden body's id or coordinate reaching the wire | |
| 166 | // through some field other than `players` — and would catch a fourth. | |
| 167 | const state = match(); | |
| 168 | const viewer = state.players.find((p) => p.side === "atk")!; | |
| 169 | viewer.x = 1200; | |
| 170 | viewer.y = 1320; | |
| 171 | viewer.blind = 0; | |
| 172 | viewer.alive = true; | |
| 173 | const enemy = state.players.find((p) => p.side === "def")!; | |
| 174 | enemy.alive = true; | |
| 175 | enemy.revealed = 0; | |
| 176 | // A distinctive decimal, not a round number, so it cannot be mistaken for | |
| 177 | // an unrelated field (hp, credits, damage) that legitimately holds 200. | |
| 178 | enemy.x = 217.3; | |
| 179 | enemy.y = 233.9; | |
| 180 | expect(visible(state, viewer, enemy)).toBe(false); | |
| 181 | ||
| 182 | // A molly lands at the thrower's own feet — the shape any thrown ability | |
| 183 | // takes — planted on the enemy this viewer cannot see. | |
| 184 | const zoneId = state.nextZone++; | |
| 185 | state.zones.push({ | |
| 186 | id: zoneId, | |
| 187 | kind: "molly", | |
| 188 | owner: enemy.id, | |
| 189 | side: enemy.side, | |
| 190 | x: enemy.x, | |
| 191 | y: enemy.y, | |
| 192 | r: 40, | |
| 193 | life: 5, | |
| 194 | fuse: 0, | |
| 195 | beat: 0, | |
| 196 | pulses: 0, | |
| 197 | damage: 0, | |
| 198 | blocks: false, | |
| 199 | solid: false, | |
| 200 | }); | |
| 201 | ||
| 202 | const text = snapshot(viewer.id); | |
| 203 | const round2 = (n: number) => Math.round(n * 10) / 10; | |
| 204 | // A number counts only bare, not as a substring of a larger one — "217.3" | |
| 205 | // inside "1217.3" would be a false alarm, "217.3" on its own is the leak. | |
| 206 | const bare = (n: number) => new RegExp(String.raw`(?<![\d.-])${n}(?![\d.])`); | |
| 207 | // The scoreboard is a named, reviewed exception: it lists every id on | |
| 208 | // purpose (kills, deaths, credits for the whole match), never a position. | |
| 209 | // The id check walks everything else; the position check still walks the | |
| 210 | // scoreboard too, so a coordinate added there would still be caught. | |
| 211 | const { board: _board, ...rest } = JSON.parse(text) as Record<string, unknown>; | |
| 212 | expect(JSON.stringify(rest)).not.toContain(JSON.stringify(enemy.id)); | |
| 213 | expect(bare(round2(enemy.x)).test(text)).toBe(false); | |
| 214 | expect(bare(round2(enemy.y)).test(text)).toBe(false); | |
| 215 | ||
| 216 | state.zones = state.zones.filter((z) => z.id !== zoneId); | |
| 217 | }); | |
| 218 | ||
| 161 | 219 | test("a command from a seat plants the spike, and nonsense from anyone else does nothing", async () => { |
| 162 | 220 | const { command } = await import("./arena.ts"); |
| 163 | 221 | const { siteCentre } = await import("./map.ts"); |