Cross-Room-State-Leaks diagnostizieren

CloudflareBeginner
Jetzt üben

Einführung

Der Name eines Durable Object ist Bestandteil des Datenmodells einer Anwendung. Aufrufe mit demselben Namen erreichen dasselbe logische Objekt und dessen SQLite-Datenbank; unterschiedliche Namen wählen unterschiedliche Koordinationseinheiten aus. Eine Routing-Regression kann daher den Zustand eines Raums über die URL eines anderen Raums sichtbar machen, selbst wenn die Durable-Object-Klasse und ihr Storage-Code korrekt sind.

In diesem Lab stellen Sie ein kleines Raumjournal mit intakten Verläufen für planning und support bereit. Anschließend reproduzieren Sie eine fehlerhafte Version, die jeden Raum an das Objekt planning weiterleitet, und verwenden eine Routendiagnose, um die Abweichung zu finden. Sie korrigieren ausschließlich die Namenszuordnung, stellen die Anwendung erneut bereit und weisen nach, dass beide ursprünglichen Verläufe erhalten geblieben sind. Ein bereitgestellter WebSocket-Test führt danach gleichzeitige Aktualisierungen durch, trennt und verbindet Verbindungen erneut und bestätigt, dass neue Räume weiterhin isoliert bleiben.

Wenn Sie diesen Kurs direkt geöffnet haben, bearbeiten Sie zuerst LabEx mit Ihrem Cloudflare-Konto verbinden. Dort lernen Sie das Terminal der LabEx-VM, die Geräteautorisierung mit Wrangler, die Kontobestätigung und die explizite Konfiguration der Account-ID kennen, die hier verwendet werden. Diese frische VM muss noch separat autorisiert werden.

Die VM autorisieren und den Namespace für Räume deklarieren

In diesem Schritt autorisieren Sie diese frische VM, bestätigen das dedizierte Lernkonto und deklarieren einen SQLite-basierten Durable-Object-Namespace.

cd /home/labex/project/room-routing
npx wrangler --version
npx wrangler login --device --browser=false

Erwarten Sie die Wrangler-Version 4.132.0. Öffnen Sie die angezeigte Cloudflare-URL im Browser, geben Sie den kurzen Code ein, bestätigen Sie das vorgesehene Lernkonto und autorisieren Sie es. Die Geräteautorisierung gewährt dieser VM Zugriff, ohne Ihr Passwort an das Terminal zu übertragen.

Lesen Sie nur sichere Identitätsfelder aus, wählen Sie das bestätigte Konto anhand seines Namens aus und erstellen Sie einen kurzlebigen Worker-Namen:

WHOAMI="$(npx wrangler whoami --json)"
printf '%s\n' "$WHOAMI" | jq '{loggedIn, authType, accounts: [.accounts[] | {name}]}'
ACCOUNT_ID="$(printf '%s\n' "$WHOAMI" | jq -r '.accounts[] | select(.name == "LabEx Learning") | .id')"
test -n "$ACCOUNT_ID"
RUN="labex-c10-o07-$(openssl rand -hex 6)"
printf '%s\n' "$RUN" | tee .labex/run-name
cat > wrangler.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/index.js",
  "compatibility_date": "2026-09-18",
  "workers_dev": true,
  "preview_urls": false,
  "observability": { "enabled": true, "head_sampling_rate": 1 },
  "durable_objects": { "bindings": [
    { "name": "ROOMS", "class_name": "RoomJournal" }
  ] },
  "exports": {
    "RoomJournal": { "type": "durable-object", "storage": "sqlite" }
  }
}
JSON

ROOMS ist ein Namespace-Binding. Es kann viele RoomJournal-Objekte adressieren. Der von der Anwendung gewählte Name, der an getByName() übergeben wird, bestimmt, in welcher SQLite-Datenbank und bei welchen aktiven Verbindungen ein Aufruf ankommt.

Ein Journal mit expliziten Objektnamen erstellen

In diesem Schritt implementieren Sie die zustandsbehaftete Klasse und bündeln die Auswahl der Identität in einer kleinen Routing-Funktion. Diese Trennung ist für die Diagnose wichtig: Das Storage-Verhalten kann weiterhin korrekt sein, während der Aufrufer das falsche Objekt auswählt.

Erstellen Sie zunächst die korrekte Zuordnung. Ein validierter Raumname ist bereits ein stabiles, deterministisches Objektname:

cat > src/router.js <<'JS'
export function objectNameFor(room) {
  return room;
}
JS
cat > test/router.test.mjs <<'JS'
import test from "node:test";
import assert from "node:assert/strict";
import { objectNameFor } from "../src/router.js";

test("each validated room keeps its own object identity", () => {
  assert.equal(objectNameFor("planning"), "planning");
  assert.equal(objectNameFor("support"), "support");
  assert.notEqual(objectNameFor("planning"), objectNameFor("support"));
});
JS

Erstellen Sie den Worker und das Durable Object. ctx.id.name gibt den stabilen Namen zurück, über den dieses Objekt erreicht wird. Die Seite protokolliert nur angeforderte und ausgewählte synthetische Raumnamen; der Journaltext wird absichtlich nicht protokolliert.

cat > src/index.js <<'JS'
import { DurableObject } from "cloudflare:workers";
import { objectNameFor } from "./router.js";

const ROOM = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
const EVENT = /^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$/;
const json = (value, status = 200) => Response.json(value, { status });
const safeRoom = value => ROOM.test(value || "") ? value : null;

export class RoomJournal extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    ctx.blockConcurrencyWhile(async () => {
      ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS events (
        sequence INTEGER PRIMARY KEY AUTOINCREMENT,
        event_id TEXT NOT NULL UNIQUE,
        text TEXT NOT NULL
      )`);
    });
  }

  state() {
    return {
      objectName: this.ctx.id.name,
      events: this.ctx.storage.sql.exec(
        "SELECT sequence, event_id AS eventId, text FROM events ORDER BY sequence"
      ).toArray()
    };
  }

  append(eventId, text) {
    if (!EVENT.test(eventId || "") || typeof text !== "string" || text.length < 1 || text.length > 80) {
      throw new Error("invalid_event");
    }
    this.ctx.storage.sql.exec("INSERT OR IGNORE INTO events (event_id, text) VALUES (?, ?)", eventId, text);
    return this.state();
  }

  async fetch(request) {
    if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") return json({ error: "upgrade_required" }, 426);
    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);
    this.ctx.acceptWebSocket(server);
    server.send(JSON.stringify({ type: "ready", ...this.state() }));
    return new Response(null, { status: 101, webSocket: client });
  }

  async webSocketMessage(socket, raw) {
    try {
      const message = JSON.parse(raw);
      if (message.type !== "append") throw new Error("invalid_event");
      const state = this.append(message.eventId, message.text);
      const frame = JSON.stringify({ type: "event", ...state });
      for (const peer of this.ctx.getWebSockets()) peer.send(frame);
    } catch {
      socket.send(JSON.stringify({ type: "error", error: "invalid_event" }));
    }
  }
}

async function roomState(env, room) {
  return env.ROOMS.getByName(objectNameFor(room)).state();
}

function inspectPage(planning, support) {
  const rows = [planning, support].map(([requested, state]) => `<tr><td>${requested}</td><td>${state.objectName}</td><td>${state.events.map(x => x.eventId).join(", ")}</td></tr>`).join("");
  return `<!doctype html><html lang="en"><meta charset="utf-8"><title>Room routing inspector</title>
  <style>body{font:18px system-ui;max-width:900px;margin:48px auto;color:#17212b}h1{color:#5b8c00}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccd5df;padding:14px;text-align:left}th{background:#eef7dc}.ok{padding:12px;background:#eef7dc;border-left:5px solid #78aa00}</style>
  <h1>Room routing inspector</h1><p class="ok">Each requested room resolves to the matching Durable Object name.</p>
  <table><thead><tr><th>Requested room</th><th>Object name</th><th>Preserved event IDs</th></tr></thead><tbody>${rows}</tbody></table></html>`;
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname === "/inspect") {
      const states = await Promise.all(["planning", "support"].map(async room => [room, await roomState(env, room)]));
      return new Response(inspectPage(...states), { headers: { "content-type": "text/html; charset=utf-8" } });
    }
    const debug = url.pathname.match(/^\/debug\/route\/([^/]+)$/);
    if (debug) {
      const room = safeRoom(debug[1]);
      if (!room) return json({ error: "invalid_room" }, 400);
      return json({ requestedRoom: room, objectName: objectNameFor(room) });
    }
    const match = url.pathname.match(/^\/rooms\/([^/]+)\/(events|connect)$/);
    if (!match) return json({ error: "not_found" }, 404);
    const room = safeRoom(match[1]);
    if (!room) return json({ error: "invalid_room" }, 400);
    const objectName = objectNameFor(room);
    console.log(JSON.stringify({ event: "routing_decision", requestedRoom: room, objectName, operation: match[2] }));
    const stub = env.ROOMS.getByName(objectName);
    if (match[2] === "connect") return stub.fetch(request);
    if (request.method === "GET") return json(await stub.state());
    if (request.method === "POST") {
      try {
        const body = await request.json();
        return json(await stub.append(body.eventId, body.text), 201);
      } catch (error) {
        return json({ error: error.message === "invalid_event" ? "invalid_event" : "invalid_json" }, 400);
      }
    }
    return json({ error: "method_not_allowed" }, 405);
  }
};
JS
npm test

Der Test prüft die Identitätsgrenze direkt. Das Durable Object verwendet seinen vom Laufzeitsystem verwalteten Namen für Diagnosen und speichert die Journalzeilen in SQLite, bevor es sie zurückgibt.

Zwei intakte Raumverläufe bereitstellen

In diesem Schritt stellen Sie zuerst die intakte Version bereit und erstellen in jedem Raum ein eindeutiges Ereignis. Diese Zeilen dienen als Nachweis für die Erhaltung: Die spätere Reparatur ist nur dann erfolgreich, wenn beide Zeilen aus ihren ursprünglichen Objekten zurückgegeben werden.

rm -f .labex/deploy.log .labex/app-url .labex/baseline.json
npx wrangler deploy | tee .labex/deploy.log
APP_URL="$(grep -Eo 'https://[^ ]+\.workers\.dev' .labex/deploy.log | tail -1)"
test -n "$APP_URL"
printf '%s\n' "$APP_URL" | tee .labex/app-url
for attempt in $(seq 1 30); do READY="$(curl --silent "$APP_URL/debug/route/planning" || true)"; test "$(jq -r '.objectName // empty' <<<"$READY" 2>/dev/null)" = planning && break; sleep 2; done
test "$(jq -r .objectName <<<"$READY")" = planning
sleep 5
curl --silent --fail -X POST "$APP_URL/rooms/planning/events" -H 'content-type: application/json' --data '{"eventId":"plan-start","text":"Planning kickoff"}' >/dev/null
curl --silent --fail -X POST "$APP_URL/rooms/support/events" -H 'content-type: application/json' --data '{"eventId":"support-start","text":"Support handoff"}' >/dev/null
jq -n --argjson planning "$(curl --silent --fail "$APP_URL/rooms/planning/events")" --argjson support "$(curl --silent --fail "$APP_URL/rooms/support/events")" '{planning:$planning,support:$support}' | tee .labex/baseline.json

Die beiden Felder objectName müssen unterschiedlich sein. planning enthält nur plan-start, während support nur support-start enthält. Der Worker-Name ist kurzlebig, aber diese Objektverläufe müssen die Release-Regression und die anschließende Reparatur überstehen.

Die fehlerhafte Version reproduzieren und zurückverfolgen

In diesem Schritt simulieren Sie eine mit dem Lab bereitgestellte Release-Regression. Die fehlerhafte Funktion ignoriert ihr Argument und gibt immer planning zurück. Der Identitätstest muss erwartungsgemäß fehlschlagen. Wenn Sie diesen kontrollierten Fehler protokollieren, wird der Defekt vor der Bereitstellung sichtbar.

cp fixtures/router-bug.js src/router.js
rm -f .labex/bug-test.log .labex/bug.json
set -o pipefail
if npm test 2>&1 | tee .labex/bug-test.log; then TEST_STATUS=0; else TEST_STATUS=$?; fi
set +o pipefail
printf '%s\n' "$TEST_STATUS" > .labex/bug-test-status
test "$TEST_STATUS" -ne 0
npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
for attempt in $(seq 1 30); do
  BUG_ROUTE="$(curl --silent "$APP_URL/debug/route/support" || true)"
  BUG_READ="$(curl --silent "$APP_URL/rooms/support/events" || true)"
  test "$(jq -r '.objectName // empty' <<<"$BUG_ROUTE" 2>/dev/null)" = planning && test "$(jq -r '.objectName // empty' <<<"$BUG_READ" 2>/dev/null)" = planning && break
  sleep 2
done
test "$(jq -r .objectName <<<"$BUG_ROUTE")" = planning
test "$(jq -r .objectName <<<"$BUG_READ")" = planning
jq -n \
  --argjson planningRoute "$(curl --silent --fail "$APP_URL/debug/route/planning")" \
  --argjson supportRoute "$BUG_ROUTE" \
  --argjson supportRead "$BUG_READ" \
  '{planningRoute:$planningRoute,supportRoute:$supportRoute,supportRead:$supportRead}' | tee .labex/bug.json

Die Diagnose trennt angeforderten Raum und ausgewählten Objektnamen. Eine Anfrage für support meldet jetzt objectName: planning, und beim Lesen wird plan-start angezeigt. Sie haben das ursprüngliche Objekt support nicht gelöscht oder überschrieben. Die fehlerhafte Version hat lediglich aufgehört, dieses Objekt anzusprechen.

Den Mapper reparieren und die Isolation nach dem Reconnect nachweisen

In diesem Schritt reparieren Sie ausschließlich die Identitätszuordnung. Ein Zurücksetzen des Storages oder ein erneutes Einspielen der Daten ist nicht erforderlich, weil die ursprünglich benannten Objekte weiterhin existieren.

cat > src/router.js <<'JS'
export function objectNameFor(room) {
  return room;
}
JS
npm test
cat > tools/isolation.mjs <<'JS'
import WebSocket from "ws";
const [base, prefix] = process.argv.slice(2);
const wsBase = base.replace(/^http/, "ws");
const rooms = [`${prefix}-planning`, `${prefix}-support`];
const open = room => new Promise((resolve, reject) => {
  const ws = new WebSocket(`${wsBase}/rooms/${room}/connect`);
  const inbox = [];
  ws.on("message", raw => { const value = JSON.parse(raw); inbox.push(value); if (value.type === "ready") resolve({ ws, inbox, ready:value }); });
  ws.on("error", reject);
});
const waitFor = (client, eventId) => new Promise((resolve, reject) => {
  const timer = setTimeout(() => reject(new Error("event timeout")), 5000);
  const inspect = value => { if (value.type === "event" && value.events.some(x => x.eventId === eventId)) { clearTimeout(timer); client.ws.off("message", listener); resolve(value); } };
  const listener = raw => inspect(JSON.parse(raw));
  client.ws.on("message", listener); client.inbox.forEach(inspect);
});
const close = client => new Promise(resolve => { client.ws.once("close", resolve); client.ws.close(1000, "reconnect"); });
const [planning, support] = await Promise.all(rooms.map(open));
planning.ws.send(JSON.stringify({ type:"append", eventId:`${prefix}-plan`, text:"Plan update" }));
support.ws.send(JSON.stringify({ type:"append", eventId:`${prefix}-support`, text:"Support update" }));
await Promise.all([waitFor(planning, `${prefix}-plan`), waitFor(support, `${prefix}-support`)]);
await Promise.all([close(planning), close(support)]);
const [planningAgain, supportAgain] = await Promise.all(rooms.map(open));
const result = { planning:planningAgain.ready, support:supportAgain.ready };
console.log(JSON.stringify(result, null, 2));
await Promise.all([close(planningAgain), close(supportAgain)]);
JS
rm -f .labex/repaired.json .labex/reconnect.json
npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
for attempt in $(seq 1 30); do REPAIRED_READY="$(curl --silent "$APP_URL/debug/route/support" || true)"; test "$(jq -r '.objectName // empty' <<<"$REPAIRED_READY" 2>/dev/null)" = support && break; sleep 2; done
test "$(jq -r .objectName <<<"$REPAIRED_READY")" = support
sleep 5
jq -n --argjson planning "$(curl --silent --fail "$APP_URL/rooms/planning/events")" --argjson support "$(curl --silent --fail "$APP_URL/rooms/support/events")" '{planning:$planning,support:$support}' | tee .labex/repaired.json
node tools/isolation.mjs "$APP_URL" cloud | tee .labex/reconnect.json

Beim reparierten Lesen werden beide ursprünglichen Ereignis-IDs in ihren ursprünglichen Objekten gefunden. Der WebSocket-Test aktualisiert anschließend gleichzeitig zwei neue Objekte, schließt beide Verbindungen und verbindet sie erneut. Jeder ready-Frame enthält nur die Ereignisse seines eigenen Raums. Damit ist nachgewiesen, dass das Routing und nicht ein Zurücksetzen der Datenbank das Leck behoben hat.

Den reparierten Dienst prüfen und erneut bereitstellen

In diesem Schritt verknüpfen Sie die Laufzeitnachweise mit browserfreundlichen Ansichten und Dashboard-Ansichten für Einsteiger. Öffnen Sie die in .labex/app-url gespeicherte URL mit /inspect. Die grüne Meldung und die Tabelle sollten planning → planning, support → support sowie die beiden erhaltenen Ereignis-IDs anzeigen.

Der reparierte Inspector ordnet jeden Raum dem passenden Objekt und dem erhaltenen Verlauf zu

Öffnen Sie Workers & Pages, wählen Sie den genauen Worker-Namen aus .labex/run-name aus und öffnen Sie Bindings. ROOMS sollte mit RoomJournal verbunden sein.

Das ROOMS-Binding verweist auf das RoomJournal-Durable-Object

Öffnen Sie Durable Objects, wählen Sie <your-worker>_RoomJournal aus und bestätigen Sie Storage: SQL. Ein Namespace kann viele benannte Objekte enthalten. Der Name wählt das isolierte Objekt innerhalb dieses Namespace aus.

Der RoomJournal-Namespace verwendet SQL-Storage

Kehren Sie in Workers & Pages zum Worker zurück, öffnen Sie Observability und suchen Sie in den gespeicherten Ereignissen nach routing_decision. Klappen Sie ein Ereignis eines Raumvorgangs auf. Diese Entscheidung wird vom zustandslosen Worker aufgezeichnet, bevor er das Durable Object aufruft. Deshalb erscheint sie in den Logs des Workers und nicht in den Logs des Namespace. Die sicheren Felder sollten denselben angeforderten und ausgewählten synthetischen Raumnamen anzeigen, ohne Journaltext.

Eine strukturierte Routing-Entscheidung zeigt die reparierte Identitätszuordnung

Stellen Sie zum Schluss den unveränderten Code erneut bereit und lesen Sie die ursprünglichen Räume erneut aus:

npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
curl --silent --fail "$APP_URL/rooms/planning/events" | jq
curl --silent --fail "$APP_URL/rooms/support/events" | jq

Beide ursprünglichen Verläufe bleiben erhalten. Eine unveränderte Bereitstellung erzeugt keine neuen Objektidentitäten, weil dieselben validierten Namen weiterhin dieselben Einträge im Namespace auswählen.

Den Namespace des Raumjournals löschen

In diesem Schritt löschen Sie nur den Worker dieses Labs und den erzeugten Namespace, solange die VM noch autorisiert ist. Der deklarative Löschmarker entfernt den Klassen-Namespace, bevor Wrangler das Script löscht.

RUN="$(cat .labex/run-name)"
case "$RUN" in labex-c10-o07-*) ;; *) echo "Unexpected Worker name" >&2; exit 1;; esac
cat > src/cleanup.js <<'JS'
export default { fetch() { return Response.json({ status: "cleanup" }, { status: 410 }); } };
JS
ACCOUNT_ID="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).account_id)')"
cat > wrangler.cleanup.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/cleanup.js",
  "compatibility_date": "2026-09-18",
  "workers_dev": true,
  "preview_urls": false,
  "exports": { "RoomJournal": { "type": "durable-object", "state": "deleted" } }
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc

Bestätigen Sie, dass die Eingabeaufforderung Ihren exakten Wert von $RUN anzeigt, geben Sie y ein und erwarten Sie Successfully deleted. Lassen Sie die VM für die unabhängige Prüfung weiterhin autorisiert:

npx wrangler whoami --json | jq '{loggedIn, authType}'

Das JSON muss "loggedIn": true enthalten. Ein Netzwerk- oder Authentifizierungsfehler ist kein Nachweis für die Löschung.

Die Wrangler-Autorisierung dieser VM widerrufen

In diesem Schritt entfernen Sie nach der unabhängigen Überprüfung der Löschung die OAuth-Autorisierung nur für diese VM:

npx wrangler logout
npx wrangler whoami --json

Das abschließende JSON muss "loggedIn": false enthalten. Ihr Lernkonto bleibt im Browser angemeldet.

Zusammenfassung

Sie haben einen Fehler bei Durable Objects an der Grenze zwischen Identität und Routing diagnostiziert, anstatt den intakten Storage zurückzusetzen. Eine kontrollierte fehlerhafte Version zeigte, dass support das Objekt planning auswählte; die Routendiagnose machte den angeforderten und den ausgewählten Namen sichtbar. Durch die Wiederherstellung der direkten Namenszuordnung konnten beide ursprünglichen SQLite-Verläufe sofort wieder gelesen werden. Gleichzeitige WebSocket-Aktualisierungen, das Trennen und erneute Verbinden sowie eine unveränderte erneute Bereitstellung bestätigten anschließend, dass neue und vorhandene Räume isoliert blieben. Zum Schluss prüften Sie die reparierte Bereitstellung, entfernten die genau zugeordneten kurzlebigen Ressourcen und meldeten sich ab.