fable/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]>
Pike committed defe010 Browse files
2 files changed +68 −3
src/arena.ts +10 −3
@@ -17,8 +17,9 @@ import { recordResult } from "./db.ts";
1717 import { makeArena, type Command, type Why } from "./engine/arena.ts";
1818 import { fallsBetween, marksOf, trimFeed, type Fall, type Mark } from "./killfeed.ts";
1919 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";
2223 import { isWeaponId, WEAPONS } from "./weapons.ts";
2324
2425 export { broadcastEvery, TICK, type Command, type Seat, type Why } from "./engine/arena.ts";
@@ -113,6 +114,12 @@ function spectatorShape(state: MatchState, _why: Why) {
113114 function snapshotShape(state: MatchState, me: Player, seen: Player[]) {
114115 const agent = agentById(me.agent);
115116 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);
116123
117124 return {
118125 phase: state.phase,
@@ -167,7 +174,7 @@ function snapshotShape(state: MatchState, me: Player, seen: Player[]) {
167174 revealed: p.revealed > 0,
168175 })),
169176 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)))
171178 .map((z) => ({ k: z.kind, x: round2(z.x), y: round2(z.y), r: z.r, f: round2(z.fuse), l: round2(z.life) })),
172179 spike: {
173180 planted: state.spike.planted,
src/server.test.ts +58
@@ -7,6 +7,7 @@ process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
77 process.env.PORT = "0";
88 const { server } = await import("./server.ts");
99 const { match, snapshot, advance, takeSeat, release, releaseAddress } = await import("./arena.ts");
10 const { visible } = await import("./match.ts");
1011 const base = `http://${server.hostname}:${server.port}`;
1112
1213 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", () => {
158159 expect(JSON.stringify(far.board)).not.toContain('"x"');
159160 });
160161
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
161219 test("a command from a seat plants the spike, and nonsense from anyone else does nothing", async () => {
162220 const { command } = await import("./arena.ts");
163221 const { siteCentre } = await import("./map.ts");
↑↓ navigate ↵ open esc close

Keyboard shortcuts