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
This commit is contained in:
2026-05-10 03:00:39 +07:00
parent 3c1263add6
commit 42d1ca19ee
7 changed files with 283 additions and 42 deletions
+12
View File
@@ -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();
+9 -1
View File
@@ -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);
+45
View File
@@ -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<name, value>. Tolerant of missing header,
* malformed pairs, and surrounding whitespace.
* @param {string|null|undefined} header
* @returns {Map<string, string>}
*/
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('; ');
}
+50 -11
View File
@@ -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<string>} 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;
}
+42 -5
View File
@@ -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 }),
});
});
+61
View File
@@ -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');
});
});
+64 -25
View File
@@ -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:<uuid> 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:<hash> 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);
});
});