From 42d1ca19ee1a9d6dd9593c8fc19597763cd66601 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 10 May 2026 03:00:39 +0700 Subject: [PATCH] feat(canvas): cookie+IP rate-limit identity and broadcast sequence numbers - resolveIdentity prefers an opaque rplace_id cookie; falls back to a cf-connecting-ip hash; in production a request with neither now returns 500 no_identity instead of bucketing all such traffic together - /api/canvas issues Set-Cookie when no cookie is present so subsequent requests escape NAT-shared IP buckets (mobile/CGNAT users) - DO maintains an in-memory monotonic broadcast counter; broadcast frames carry { seq } so the client can detect missed pixels and refetch - client tracks lastSeq, refetches on gap, resets on every (re)connect NAT/CGNAT users previously shared a single 1Hz bucket per egress IP. With cookie identity they each get their own bucket. Cookie is HttpOnly, Secure, SameSite=Lax, 1y Max-Age. Stripped/cleared cookies fall through to IP. The seq counter resets on DO hibernation rehydrate; client always refetches on reconnect, so a reset is indistinguishable from a fresh connect. Plan: plans/260510-0232-fix-do-migration-followups/phase-02-cookie-ip-identity.md --- src/client/App.svelte | 12 ++++ src/durable-objects/canvas-room.js | 10 +++- src/lib/cookie.js | 45 +++++++++++++++ src/lib/get-user-id.js | 61 ++++++++++++++++---- src/worker.js | 47 ++++++++++++++-- test/lib/cookie.test.js | 61 ++++++++++++++++++++ test/lib/get-user-id.test.js | 89 +++++++++++++++++++++--------- 7 files changed, 283 insertions(+), 42 deletions(-) create mode 100644 src/lib/cookie.js create mode 100644 test/lib/cookie.test.js diff --git a/src/client/App.svelte b/src/client/App.svelte index dcbf2c8..bf5c8d7 100644 --- a/src/client/App.svelte +++ b/src/client/App.svelte @@ -105,6 +105,9 @@ let wsRetryDelay = 1000; let isReconnect = false; let wsState = $state('connecting'); // 'connecting' | 'open' | 'reconnecting' | 'closed' + // Monotonic broadcast counter from the server. A gap means we missed at + // least one frame (DO hibernation, network glitch) — refetch to resync. + let lastSeq = null; function connectWebSocket() { const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; @@ -114,12 +117,21 @@ try { const data = JSON.parse(event.data); if (data.type === 'pixels' && canvasRenderer) { + if (data.seq != null) { + const expected = lastSeq == null ? data.seq : ((lastSeq + 1) >>> 0); + if (data.seq !== expected) { + canvasRenderer.refetchCanvas(); + } + lastSeq = data.seq; + } canvasRenderer.applyUpdates(data.pixels); } } catch { /* ignore parse errors */ } }; ws.onopen = () => { + // Reset on every (re)connect — fresh canvas fetch is the new baseline. + lastSeq = null; // Refetch canvas after a reconnect to recover any pixels missed while disconnected. if (isReconnect && canvasRenderer) { canvasRenderer.refetchCanvas(); diff --git a/src/durable-objects/canvas-room.js b/src/durable-objects/canvas-room.js index a751c6f..9edb5bd 100644 --- a/src/durable-objects/canvas-room.js +++ b/src/durable-objects/canvas-room.js @@ -13,6 +13,13 @@ import { tryAcquire, release } from './lib/cooldown-store.js'; * the four internal endpoints below. */ export class CanvasRoom { + /** + * Monotonic broadcast counter. Resets on hibernation rehydrate (in-memory + * only) — clients refetch the canvas on reconnect, so a reset after a gap + * is safe. Uint32 wraparound is handled by `>>> 0`. + */ + #seq = 0; + constructor(state, env) { this.state = state; this.env = env; @@ -104,7 +111,8 @@ export class CanvasRoom { } #broadcastPixels(pixels) { - const message = JSON.stringify({ type: 'pixels', pixels }); + this.#seq = (this.#seq + 1) >>> 0; + const message = JSON.stringify({ type: 'pixels', seq: this.#seq, pixels }); for (const ws of this.state.getWebSockets()) { try { ws.send(message); diff --git a/src/lib/cookie.js b/src/lib/cookie.js new file mode 100644 index 0000000..e65135d --- /dev/null +++ b/src/lib/cookie.js @@ -0,0 +1,45 @@ +/** + * Minimal cookie helpers — no external dependency. + * Used to issue and read the opaque rplace_id rate-limit identity cookie. + */ + +/** + * Parse a Cookie header into a Map. Tolerant of missing header, + * malformed pairs, and surrounding whitespace. + * @param {string|null|undefined} header + * @returns {Map} + */ +export function parseCookie(header) { + const out = new Map(); + if (!header) return out; + for (const part of header.split(';')) { + const eq = part.indexOf('='); + if (eq <= 0) continue; + const name = part.slice(0, eq).trim(); + const value = part.slice(eq + 1).trim(); + if (name) out.set(name, value); + } + return out; +} + +/** + * Format a Set-Cookie header value. + * @param {string} name + * @param {string} value + * @param {object} [opts] + * @param {boolean} [opts.httpOnly] + * @param {boolean} [opts.secure] + * @param {'Strict'|'Lax'|'None'} [opts.sameSite] + * @param {string} [opts.path] + * @param {number} [opts.maxAge] — seconds + * @returns {string} + */ +export function formatSetCookie(name, value, opts = {}) { + const parts = [`${name}=${value}`]; + if (opts.path) parts.push(`Path=${opts.path}`); + if (opts.maxAge != null) parts.push(`Max-Age=${opts.maxAge}`); + if (opts.httpOnly) parts.push('HttpOnly'); + if (opts.secure) parts.push('Secure'); + if (opts.sameSite) parts.push(`SameSite=${opts.sameSite}`); + return parts.join('; '); +} diff --git a/src/lib/get-user-id.js b/src/lib/get-user-id.js index f1a9635..f6fa7ba 100644 --- a/src/lib/get-user-id.js +++ b/src/lib/get-user-id.js @@ -1,18 +1,57 @@ +import { parseCookie } from './cookie.js'; + /** - * Extract a user identifier from the request. - * Uses CF-Connecting-IP header (provided by Cloudflare, cannot be spoofed). - * Returns SHA-256 hex (truncated to 16 chars) — collision-resistant for rate-limit buckets. + * Cookie-first / IP-fallback identity for rate-limit bucketing. Cookie unblocks + * NAT/CGNAT users who'd otherwise share a single IP bucket; IP is the legacy + * fallback when the browser doesn't yet have a cookie. + * + * Returns `{ id, mintCookieValue? }`. When `mintCookieValue` is set, the + * caller (worker) should attach a Set-Cookie header so subsequent requests + * use cookie identity instead of falling through to IP. + * + * In production (env.ENVIRONMENT === 'production') a request with neither + * cookie nor cf-connecting-ip throws NoIdentityError — the caller maps to + * 500 no_identity. In dev the same case falls back to a shared bucket so + * `wrangler dev` still works. + * * @param {Request} request - * @returns {Promise} user id prefixed with "anon:" + * @param {{ ENVIRONMENT?: string }} [env] + * @returns {Promise<{ id: string, mintCookieValue?: string }>} */ -export async function getUserId(request) { - const ip = request.headers.get('cf-connecting-ip'); - if (!ip) { - // No CF-Connecting-IP means dev/local or misconfig; bucket all such traffic together. - console.warn('cf-connecting-ip missing — falling back to shared dev bucket'); - return 'anon:dev'; +export async function resolveIdentity(request, env) { + const cookies = parseCookie(request.headers.get('cookie')); + const existing = cookies.get('rplace_id'); + if (existing && isValidCookieValue(existing)) { + return { id: `cookie:${existing}` }; } + const ip = request.headers.get('cf-connecting-ip'); + if (ip) { + const hash = await hashIp(ip); + return { id: `ip:${hash}`, mintCookieValue: crypto.randomUUID() }; + } + + if (env?.ENVIRONMENT === 'production') { + throw new NoIdentityError(); + } + + console.warn('cf-connecting-ip missing — falling back to shared dev bucket'); + return { id: 'dev:shared', mintCookieValue: crypto.randomUUID() }; +} + +export class NoIdentityError extends Error { + constructor() { + super('no_identity'); + this.name = 'NoIdentityError'; + } +} + +/** Accept only opaque UUID-shaped values; reject anything that smells injected. */ +function isValidCookieValue(v) { + return typeof v === 'string' && /^[0-9a-fA-F-]{32,40}$/.test(v); +} + +async function hashIp(ip) { const data = new TextEncoder().encode(ip); const hashBuffer = await crypto.subtle.digest('SHA-256', data); const bytes = new Uint8Array(hashBuffer); @@ -20,5 +59,5 @@ export async function getUserId(request) { for (let i = 0; i < 8; i++) { hex += bytes[i].toString(16).padStart(2, '0'); } - return `anon:${hex}`; + return hex; } diff --git a/src/worker.js b/src/worker.js index 0b91d31..67ce9ec 100644 --- a/src/worker.js +++ b/src/worker.js @@ -1,5 +1,6 @@ import { Hono } from 'hono'; -import { getUserId } from './lib/get-user-id.js'; +import { resolveIdentity, NoIdentityError } from './lib/get-user-id.js'; +import { formatSetCookie } from './lib/cookie.js'; import { CANVAS_WIDTH, CANVAS_HEIGHT, MAX_COLORS, MAX_BATCH_SIZE } from './lib/constants.js'; export { CanvasRoom } from './durable-objects/canvas-room.js'; @@ -9,14 +10,42 @@ const app = new Hono(); // ~64 bytes is generous per pixel JSON object {"x":2047,"y":2047,"color":31} const MAX_BODY_BYTES = MAX_BATCH_SIZE * 64; +const COOKIE_OPTS = { + httpOnly: true, + secure: true, + sameSite: 'Lax', + path: '/', + maxAge: 60 * 60 * 24 * 365, // 1 year +}; + /** Resolve the singleton CanvasRoom DO stub. */ function room(env) { return env.CANVAS_ROOM.get(env.CANVAS_ROOM.idFromName('main')); } -/** GET /api/canvas — full canvas binary, served by the DO directly. */ +/** GET /api/canvas — full canvas binary, served by the DO directly. Issues + * the rplace_id cookie on first request so future calls bypass NAT-shared + * IP buckets. */ app.get('/api/canvas', async (c) => { - return room(c.env).fetch('http://do/canvas'); + 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; + } + const upstream = await room(c.env).fetch('http://do/canvas'); + if (!identity.mintCookieValue) return upstream; + // Attach Set-Cookie without mutating the upstream Response (its body is a + // stream; new Response keeps it linked). + const out = new Response(upstream.body, upstream); + out.headers.append( + 'Set-Cookie', + formatSetCookie('rplace_id', identity.mintCookieValue, COOKIE_OPTS), + ); + return out; }); /** POST /api/place — validate at the edge, forward to the DO. */ @@ -57,13 +86,21 @@ app.post('/api/place', async (c) => { } } - const userId = await getUserId(c.req.raw); + 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; + } // DO does cooldown check + pixel write + broadcast in one atomic step. return room(c.env).fetch('http://do/place', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ userId, pixels }), + body: JSON.stringify({ userId: identity.id, pixels }), }); }); diff --git a/test/lib/cookie.test.js b/test/lib/cookie.test.js new file mode 100644 index 0000000..e3f1f7f --- /dev/null +++ b/test/lib/cookie.test.js @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { parseCookie, formatSetCookie } from '../../src/lib/cookie.js'; + +describe('parseCookie', () => { + it('returns empty Map for null/undefined header', () => { + expect(parseCookie(null).size).toBe(0); + expect(parseCookie(undefined).size).toBe(0); + expect(parseCookie('').size).toBe(0); + }); + + it('parses a single name=value pair', () => { + const m = parseCookie('rplace_id=abc'); + expect(m.get('rplace_id')).toBe('abc'); + expect(m.size).toBe(1); + }); + + it('parses multiple cookies separated by ;', () => { + const m = parseCookie('a=1; b=2; c=3'); + expect(m.get('a')).toBe('1'); + expect(m.get('b')).toBe('2'); + expect(m.get('c')).toBe('3'); + }); + + it('trims whitespace around names and values', () => { + const m = parseCookie(' a = 1 ; b=2'); + expect(m.get('a')).toBe('1'); + expect(m.get('b')).toBe('2'); + }); + + it('skips malformed entries (no equals)', () => { + const m = parseCookie('a; b=2'); + expect(m.has('a')).toBe(false); + expect(m.get('b')).toBe('2'); + }); + + it('handles values with embedded equals', () => { + const m = parseCookie('token=abc=def='); + expect(m.get('token')).toBe('abc=def='); + }); +}); + +describe('formatSetCookie', () => { + it('formats minimum required attributes', () => { + expect(formatSetCookie('a', '1')).toBe('a=1'); + }); + + it('emits Path, Max-Age, HttpOnly, Secure, SameSite in expected order', () => { + const s = formatSetCookie('rplace_id', 'uuid', { + httpOnly: true, + secure: true, + sameSite: 'Lax', + path: '/', + maxAge: 31536000, + }); + expect(s).toBe('rplace_id=uuid; Path=/; Max-Age=31536000; HttpOnly; Secure; SameSite=Lax'); + }); + + it('omits attributes that are not set', () => { + expect(formatSetCookie('a', '1', { secure: true })).toBe('a=1; Secure'); + }); +}); diff --git a/test/lib/get-user-id.test.js b/test/lib/get-user-id.test.js index 11f86fe..76b37de 100644 --- a/test/lib/get-user-id.test.js +++ b/test/lib/get-user-id.test.js @@ -1,40 +1,79 @@ import { describe, it, expect } from 'vitest'; -import { getUserId } from '../../src/lib/get-user-id.js'; +import { resolveIdentity, NoIdentityError } from '../../src/lib/get-user-id.js'; -/** Helper to create a mock Request with headers */ +/** Helper to build a mock Request with selected headers. */ function mockRequest(headers = {}) { - return new Request('http://localhost', { - headers: new Headers(headers), - }); + return new Request('http://localhost', { headers: new Headers(headers) }); } -describe('getUserId', () => { - it('returns anon: prefix', async () => { - const id = await getUserId(mockRequest({ 'cf-connecting-ip': '1.2.3.4' })); - expect(id).toMatch(/^anon:/); +describe('resolveIdentity — cookie-first', () => { + it('returns cookie: when rplace_id cookie is present', async () => { + const cookie = '12345678-1234-1234-1234-123456789abc'; + const r = await resolveIdentity(mockRequest({ cookie: `rplace_id=${cookie}` })); + expect(r.id).toBe(`cookie:${cookie}`); + expect(r.mintCookieValue).toBeUndefined(); }); - it('returns deterministic ID for same IP', async () => { - const id1 = await getUserId(mockRequest({ 'cf-connecting-ip': '192.168.1.1' })); - const id2 = await getUserId(mockRequest({ 'cf-connecting-ip': '192.168.1.1' })); - expect(id1).toBe(id2); + it('rejects malformed cookie values and falls back to IP', async () => { + const r = await resolveIdentity(mockRequest({ + cookie: 'rplace_id=" OR 1=1 --"', + 'cf-connecting-ip': '1.2.3.4', + })); + expect(r.id).toMatch(/^ip:[0-9a-f]{16}$/); + expect(r.mintCookieValue).toMatch(/^[0-9a-f-]{36}$/); }); - it('returns different IDs for different IPs', async () => { - const id1 = await getUserId(mockRequest({ 'cf-connecting-ip': '1.1.1.1' })); - const id2 = await getUserId(mockRequest({ 'cf-connecting-ip': '2.2.2.2' })); - expect(id1).not.toBe(id2); + it('cookie wins over IP when both present', async () => { + const cookie = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + const r = await resolveIdentity(mockRequest({ + cookie: `rplace_id=${cookie}`, + 'cf-connecting-ip': '1.2.3.4', + })); + expect(r.id).toBe(`cookie:${cookie}`); + expect(r.mintCookieValue).toBeUndefined(); }); +}); - it('falls back to a shared dev bucket when header is missing', async () => { - const id = await getUserId(mockRequest({})); - expect(id).toBe('anon:dev'); - // Deterministic for missing header - expect(await getUserId(mockRequest({}))).toBe(id); +describe('resolveIdentity — IP fallback', () => { + it('returns ip: with mintCookieValue when only IP is present', async () => { + const r = await resolveIdentity(mockRequest({ 'cf-connecting-ip': '1.2.3.4' })); + expect(r.id).toMatch(/^ip:[0-9a-f]{16}$/); + expect(r.mintCookieValue).toMatch(/^[0-9a-f-]{36}$/); }); - it('uses 16-hex-char (8-byte) suffix from SHA-256', async () => { - const id = await getUserId(mockRequest({ 'cf-connecting-ip': '203.0.113.45' })); - expect(id).toMatch(/^anon:[0-9a-f]{16}$/); + it('IP hash is deterministic per IP', async () => { + const a = await resolveIdentity(mockRequest({ 'cf-connecting-ip': '192.168.1.1' })); + const b = await resolveIdentity(mockRequest({ 'cf-connecting-ip': '192.168.1.1' })); + expect(a.id).toBe(b.id); + }); + + it('different IPs produce different hashes', async () => { + const a = await resolveIdentity(mockRequest({ 'cf-connecting-ip': '1.1.1.1' })); + const b = await resolveIdentity(mockRequest({ 'cf-connecting-ip': '2.2.2.2' })); + expect(a.id).not.toBe(b.id); + }); + + it('mintCookieValue is unique per call', async () => { + const a = await resolveIdentity(mockRequest({ 'cf-connecting-ip': '1.2.3.4' })); + const b = await resolveIdentity(mockRequest({ 'cf-connecting-ip': '1.2.3.4' })); + expect(a.mintCookieValue).not.toBe(b.mintCookieValue); + }); +}); + +describe('resolveIdentity — no cookie, no IP', () => { + it('falls back to dev:shared in non-production env', async () => { + const r = await resolveIdentity(mockRequest({}), { ENVIRONMENT: 'development' }); + expect(r.id).toBe('dev:shared'); + expect(r.mintCookieValue).toMatch(/^[0-9a-f-]{36}$/); + }); + + it('falls back to dev:shared when env is undefined', async () => { + const r = await resolveIdentity(mockRequest({})); + expect(r.id).toBe('dev:shared'); + }); + + it('throws NoIdentityError in production', async () => { + await expect(resolveIdentity(mockRequest({}), { ENVIRONMENT: 'production' })) + .rejects.toBeInstanceOf(NoIdentityError); }); });