mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 03:13:48 +00:00
feat(canvas): WS hardening, client race fix, and ping/pong heartbeat
Server:
- Origin allowlist on /api/ws (env.ALLOWED_ORIGINS, comma-separated; empty
= allow all for dev/preview)
- per-identity WS connection cap (MAX_WS_PER_IDENTITY = 5) using
acceptWebSocket(socket, [identity]) tagging; 6th upgrade returns 429
- ws.send 'ping' triggers a {type:'pong'} reply so dead connections fire
onclose promptly instead of waiting on TCP keepalive
Client:
- buffer WS pixels arriving during the initial canvas fetch and replay
them after committedColors is replaced; previously the post-fetch
Uint8Array assignment silently overwrote any pixels broadcast in the
fetch window (the documented C2 race)
- 30s ping / 60s pong watchdog closes the socket if pong stops arriving,
routing through the existing exponential-backoff reconnect path
Tests:
- four /api/ws cases: missing upgrade, disallowed origin, allowed origin,
empty allowlist (dev default). Sentinel uses status 200 because undici
rejects 101 in Node-side Response constructors.
Plan: plans/260510-0232-fix-do-migration-followups/phase-03-ws-hardening-client-race.md
This commit is contained in:
1 parent
42d1ca19ee
commit
4f02d30393
7 files changed
+167
-15
No files matched your search
@@ -108,6 +108,33 @@
|
|||||||
// Monotonic broadcast counter from the server. A gap means we missed at
|
// Monotonic broadcast counter from the server. A gap means we missed at
|
||||||
// least one frame (DO hibernation, network glitch) — refetch to resync.
|
// least one frame (DO hibernation, network glitch) — refetch to resync.
|
||||||
let lastSeq = null;
|
let lastSeq = null;
|
||||||
|
// App-level heartbeat — if no pong arrives within PONG_TIMEOUT_MS we close
|
||||||
|
// the socket so onclose triggers reconnect (avoids zombie-connection wait
|
||||||
|
// for OS-level TCP keepalive).
|
||||||
|
const PING_INTERVAL_MS = 30_000;
|
||||||
|
const PONG_TIMEOUT_MS = 60_000;
|
||||||
|
let pingInterval = null;
|
||||||
|
let pongWatchdog = null;
|
||||||
|
let lastPongAt = 0;
|
||||||
|
|
||||||
|
function startHeartbeat(ws) {
|
||||||
|
lastPongAt = Date.now();
|
||||||
|
pingInterval = setInterval(() => {
|
||||||
|
if (ws.readyState === 1) {
|
||||||
|
try { ws.send('ping'); } catch { /* socket closing */ }
|
||||||
|
}
|
||||||
|
}, PING_INTERVAL_MS);
|
||||||
|
pongWatchdog = setInterval(() => {
|
||||||
|
if (Date.now() - lastPongAt > PONG_TIMEOUT_MS) {
|
||||||
|
try { ws.close(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}, PING_INTERVAL_MS / 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopHeartbeat() {
|
||||||
|
if (pingInterval) { clearInterval(pingInterval); pingInterval = null; }
|
||||||
|
if (pongWatchdog) { clearInterval(pongWatchdog); pongWatchdog = null; }
|
||||||
|
}
|
||||||
|
|
||||||
function connectWebSocket() {
|
function connectWebSocket() {
|
||||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
@@ -116,6 +143,10 @@
|
|||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'pong') {
|
||||||
|
lastPongAt = Date.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data.type === 'pixels' && canvasRenderer) {
|
if (data.type === 'pixels' && canvasRenderer) {
|
||||||
if (data.seq != null) {
|
if (data.seq != null) {
|
||||||
const expected = lastSeq == null ? data.seq : ((lastSeq + 1) >>> 0);
|
const expected = lastSeq == null ? data.seq : ((lastSeq + 1) >>> 0);
|
||||||
@@ -139,8 +170,10 @@
|
|||||||
wsRetryDelay = 1000;
|
wsRetryDelay = 1000;
|
||||||
isReconnect = true;
|
isReconnect = true;
|
||||||
wsState = 'open';
|
wsState = 'open';
|
||||||
|
startHeartbeat(ws);
|
||||||
};
|
};
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
|
stopHeartbeat();
|
||||||
wsState = 'reconnecting';
|
wsState = 'reconnecting';
|
||||||
setTimeout(connectWebSocket, wsRetryDelay);
|
setTimeout(connectWebSocket, wsRetryDelay);
|
||||||
wsRetryDelay = Math.min(wsRetryDelay * 2, 30000);
|
wsRetryDelay = Math.min(wsRetryDelay * 2, 30000);
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
/** Committed color index per pixel (server-confirmed state). Allocated upfront so WS
|
/** Committed color index per pixel (server-confirmed state). Allocated upfront so WS
|
||||||
* updates that arrive during the initial canvas fetch don't null-deref. */
|
* updates that arrive during the initial canvas fetch don't null-deref. */
|
||||||
let committedColors = new Uint8Array(CANVAS_WIDTH * CANVAS_HEIGHT);
|
let committedColors = new Uint8Array(CANVAS_WIDTH * CANVAS_HEIGHT);
|
||||||
|
/** WS pixel updates that arrive while loadCanvas is in flight. Buffered
|
||||||
|
* here and replayed after committedColors is replaced — without this, the
|
||||||
|
* fetch's `new Uint8Array(indices)` assignment would silently overwrite
|
||||||
|
* any pixels broadcast during the fetch window. */
|
||||||
|
let pendingWsEdits = [];
|
||||||
|
let isLoadComplete = false;
|
||||||
let pan = { x: 0, y: 0 };
|
let pan = { x: 0, y: 0 };
|
||||||
let dragging = $state(false);
|
let dragging = $state(false);
|
||||||
let lastMouse = { x: 0, y: 0 };
|
let lastMouse = { x: 0, y: 0 };
|
||||||
@@ -166,16 +172,24 @@
|
|||||||
// --- Public API (called by App.svelte) ---
|
// --- Public API (called by App.svelte) ---
|
||||||
|
|
||||||
export function applyUpdates(pixels) {
|
export function applyUpdates(pixels) {
|
||||||
for (const { x, y, color } of pixels) {
|
if (!isLoadComplete) {
|
||||||
committedColors[y * CANVAS_WIDTH + x] = color;
|
// Buffer until loadCanvas resolves; replay against the freshly-fetched
|
||||||
// Only update display if no pending or active stroke covers this pixel
|
// committedColors there. Without this, the fetch overwrites these edits.
|
||||||
if (buffer.getColorAt(x, y) < 0 && !currentStrokeKeys.has(y * 65536 + x)) {
|
pendingWsEdits.push(...pixels);
|
||||||
setPixelRgba(x, y, color);
|
return;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
for (const { x, y, color } of pixels) applySingleUpdate(x, y, color);
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applySingleUpdate(x, y, color) {
|
||||||
|
committedColors[y * CANVAS_WIDTH + x] = color;
|
||||||
|
// Only update display if no pending or active stroke covers this pixel.
|
||||||
|
if (buffer.getColorAt(x, y) < 0 && !currentStrokeKeys.has(y * 65536 + x)) {
|
||||||
|
setPixelRgba(x, y, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function undo() {
|
export function undo() {
|
||||||
const stroke = buffer.undo();
|
const stroke = buffer.undo();
|
||||||
if (!stroke) return;
|
if (!stroke) return;
|
||||||
@@ -465,6 +479,7 @@
|
|||||||
async function loadCanvas() {
|
async function loadCanvas() {
|
||||||
loading = true;
|
loading = true;
|
||||||
loadError = null;
|
loadError = null;
|
||||||
|
isLoadComplete = false;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/canvas');
|
const res = await fetch('/api/canvas');
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
@@ -473,7 +488,14 @@
|
|||||||
committedColors = new Uint8Array(indices); // replace pre-allocated zero array
|
committedColors = new Uint8Array(indices); // replace pre-allocated zero array
|
||||||
const rgba = indicesToRgba(indices);
|
const rgba = indicesToRgba(indices);
|
||||||
imageData = new ImageData(rgba, CANVAS_WIDTH, CANVAS_HEIGHT);
|
imageData = new ImageData(rgba, CANVAS_WIDTH, CANVAS_HEIGHT);
|
||||||
|
// Replay any WS pixels that arrived during the fetch — they raced the
|
||||||
|
// assignment above and would otherwise be lost.
|
||||||
|
if (pendingWsEdits.length > 0) {
|
||||||
|
for (const { x, y, color } of pendingWsEdits) applySingleUpdate(x, y, color);
|
||||||
|
pendingWsEdits = [];
|
||||||
|
}
|
||||||
imageDataDirty = true;
|
imageDataDirty = true;
|
||||||
|
isLoadComplete = true;
|
||||||
render();
|
render();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load canvas:', err);
|
console.error('Failed to load canvas:', err);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CANVAS_WIDTH, CANVAS_HEIGHT, MAX_COLORS, MAX_BATCH_SIZE } from '../lib/constants.js';
|
import { CANVAS_WIDTH, CANVAS_HEIGHT, MAX_COLORS, MAX_BATCH_SIZE, MAX_WS_PER_IDENTITY } from '../lib/constants.js';
|
||||||
import { init as initSchema } from './lib/schema.js';
|
import { init as initSchema } from './lib/schema.js';
|
||||||
import { readAllChunks, writePixels } from './lib/chunk-storage.js';
|
import { readAllChunks, writePixels } from './lib/chunk-storage.js';
|
||||||
import { tryAcquire, release } from './lib/cooldown-store.js';
|
import { tryAcquire, release } from './lib/cooldown-store.js';
|
||||||
@@ -32,7 +32,7 @@ export class CanvasRoom {
|
|||||||
switch (url.pathname) {
|
switch (url.pathname) {
|
||||||
case '/canvas': return this.#handleGetCanvas();
|
case '/canvas': return this.#handleGetCanvas();
|
||||||
case '/place': return this.#handlePlace(request);
|
case '/place': return this.#handlePlace(request);
|
||||||
case '/ws': return this.#handleWsUpgrade();
|
case '/ws': return this.#handleWsUpgrade(url);
|
||||||
default: return new Response('not found', { status: 404 });
|
default: return new Response('not found', { status: 404 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,10 +103,17 @@ export class CanvasRoom {
|
|||||||
return Response.json({ ok: true });
|
return Response.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
#handleWsUpgrade() {
|
#handleWsUpgrade(url) {
|
||||||
|
const identity = url.searchParams.get('identity') || 'anon:unknown';
|
||||||
|
const existing = this.state.getWebSockets(identity);
|
||||||
|
if (existing.length >= MAX_WS_PER_IDENTITY) {
|
||||||
|
return new Response('too_many_sockets', { status: 429 });
|
||||||
|
}
|
||||||
const pair = new WebSocketPair();
|
const pair = new WebSocketPair();
|
||||||
const [client, server] = Object.values(pair);
|
const [client, server] = Object.values(pair);
|
||||||
this.state.acceptWebSocket(server);
|
// Tag the socket with identity so getWebSockets(identity) can count it
|
||||||
|
// toward the per-identity cap on subsequent upgrades.
|
||||||
|
this.state.acceptWebSocket(server, [identity]);
|
||||||
return new Response(null, { status: 101, webSocket: client });
|
return new Response(null, { status: 101, webSocket: client });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,8 +132,15 @@ export class CanvasRoom {
|
|||||||
|
|
||||||
/** Hibernation-API callbacks. */
|
/** Hibernation-API callbacks. */
|
||||||
|
|
||||||
webSocketMessage(ws) {
|
webSocketMessage(ws, message) {
|
||||||
// Protocol is broadcast-only; reject any inbound payload.
|
// Heartbeat: client sends 'ping', server replies with {type:'pong'} so
|
||||||
|
// dead connections fire onclose promptly instead of waiting for OS-level
|
||||||
|
// TCP keepalive (potentially minutes).
|
||||||
|
if (typeof message === 'string' && message === 'ping') {
|
||||||
|
try { ws.send(JSON.stringify({ type: 'pong' })); } catch { /* socket closing */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Anything else is unexpected — close the socket.
|
||||||
ws.close(1003, 'unexpected client message');
|
ws.close(1003, 'unexpected client message');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export const MAX_COLORS = 256;
|
|||||||
export const REQUEST_COOLDOWN_SEC = 1;
|
export const REQUEST_COOLDOWN_SEC = 1;
|
||||||
export const MAX_BATCH_SIZE = 2048;
|
export const MAX_BATCH_SIZE = 2048;
|
||||||
|
|
||||||
|
/** Per-identity concurrent WebSocket cap. Prevents broadcast amplification
|
||||||
|
* from a single client opening thousands of sockets. */
|
||||||
|
export const MAX_WS_PER_IDENTITY = 5;
|
||||||
|
|
||||||
/** Canvas chunked storage layout (DO SQLite). 64 KB chunks → 256 chunks for
|
/** Canvas chunked storage layout (DO SQLite). 64 KB chunks → 256 chunks for
|
||||||
* the 16 MB / 4096² canvas. CHUNK_COUNT is derived: bumping CANVAS_WIDTH or
|
* the 16 MB / 4096² canvas. CHUNK_COUNT is derived: bumping CANVAS_WIDTH or
|
||||||
* CANVAS_HEIGHT and redeploying transparently allocates more chunks (lazy-
|
* CANVAS_HEIGHT and redeploying transparently allocates more chunks (lazy-
|
||||||
|
|||||||
+31
-3
@@ -105,14 +105,42 @@ app.post('/api/place', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** GET /api/ws — WebSocket upgrade routed to the DO.
|
/** GET /api/ws — WebSocket upgrade routed to the DO.
|
||||||
* The DO routes by url.pathname, so we rewrite the URL to `/ws` while
|
* Origin allowlist + identity resolution + per-identity cap (in DO). */
|
||||||
* preserving the original headers (including Upgrade) via the request init. */
|
|
||||||
app.get('/api/ws', async (c) => {
|
app.get('/api/ws', async (c) => {
|
||||||
const upgradeHeader = c.req.header('Upgrade');
|
const upgradeHeader = c.req.header('Upgrade');
|
||||||
if (upgradeHeader !== 'websocket') {
|
if (upgradeHeader !== 'websocket') {
|
||||||
return c.text('Expected WebSocket', 426);
|
return c.text('Expected WebSocket', 426);
|
||||||
}
|
}
|
||||||
return room(c.env).fetch('http://do/ws', c.req.raw);
|
// Origin allowlist. Empty ALLOWED_ORIGINS ⇒ allow all (dev / preview).
|
||||||
|
const origin = c.req.header('Origin');
|
||||||
|
const allowed = parseAllowedOrigins(c.env?.ALLOWED_ORIGINS);
|
||||||
|
if (origin && allowed.size > 0 && !allowed.has(origin)) {
|
||||||
|
return c.text('forbidden_origin', 403);
|
||||||
|
}
|
||||||
|
let identity;
|
||||||
|
try {
|
||||||
|
identity = await resolveIdentity(c.req.raw, c.env);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NoIdentityError) {
|
||||||
|
return c.json({ error: 'no_identity' }, 500);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// Identity goes via URL query so the original request (with Upgrade header)
|
||||||
|
// forwards unchanged. CF DO routing reads it from request.url.
|
||||||
|
const url = `http://do/ws?identity=${encodeURIComponent(identity.id)}`;
|
||||||
|
return room(c.env).fetch(url, c.req.raw);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Parse the comma-separated ALLOWED_ORIGINS env var into a Set. */
|
||||||
|
function parseAllowedOrigins(raw) {
|
||||||
|
const out = new Set();
|
||||||
|
if (!raw) return out;
|
||||||
|
for (const o of String(raw).split(',')) {
|
||||||
|
const trimmed = o.trim();
|
||||||
|
if (trimmed) out.add(trimmed);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
@@ -189,4 +189,51 @@ describe('GET /api/ws', () => {
|
|||||||
const res = await app.fetch(req, env);
|
const res = await app.fetch(req, env);
|
||||||
expect(res.status).toBe(426);
|
expect(res.status).toBe(426);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects WS upgrade from a disallowed Origin', async () => {
|
||||||
|
// undici disallows constructing a Response with status 101; use a 2xx
|
||||||
|
// sentinel to assert the upstream DO was reached.
|
||||||
|
doResponse = () => new Response(null, { status: 200 });
|
||||||
|
const req = new Request('http://localhost/api/ws', {
|
||||||
|
headers: {
|
||||||
|
Upgrade: 'websocket',
|
||||||
|
Origin: 'https://evil.example',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const restrictedEnv = {
|
||||||
|
...env,
|
||||||
|
ALLOWED_ORIGINS: 'https://rplace.miti99.workers.dev',
|
||||||
|
};
|
||||||
|
const res = await app.fetch(req, restrictedEnv);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards WS upgrade when Origin is in the allowlist', async () => {
|
||||||
|
// undici disallows constructing a Response with status 101; use a 2xx
|
||||||
|
// sentinel to assert the upstream DO was reached.
|
||||||
|
doResponse = () => new Response(null, { status: 200 });
|
||||||
|
const req = new Request('http://localhost/api/ws', {
|
||||||
|
headers: {
|
||||||
|
Upgrade: 'websocket',
|
||||||
|
Origin: 'https://rplace.miti99.workers.dev',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const restrictedEnv = {
|
||||||
|
...env,
|
||||||
|
ALLOWED_ORIGINS: 'https://rplace.miti99.workers.dev',
|
||||||
|
};
|
||||||
|
const res = await app.fetch(req, restrictedEnv);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards WS upgrade when ALLOWED_ORIGINS is empty (dev default)', async () => {
|
||||||
|
// undici disallows constructing a Response with status 101; use a 2xx
|
||||||
|
// sentinel to assert the upstream DO was reached.
|
||||||
|
doResponse = () => new Response(null, { status: 200 });
|
||||||
|
const req = new Request('http://localhost/api/ws', {
|
||||||
|
headers: { Upgrade: 'websocket', Origin: 'https://anything.example' },
|
||||||
|
});
|
||||||
|
const res = await app.fetch(req, env);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -27,5 +27,9 @@
|
|||||||
"traces": {
|
"traces": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"vars": {
|
||||||
|
"ALLOWED_ORIGINS": "",
|
||||||
|
"ENVIRONMENT": "development"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user