fix(auth): make device bootstrap tokens single-use to prevent scope escalation

Refs: GHSA-63f5-hhc7-cx6p
This commit is contained in:
Robin Waslander 2026-03-13 23:54:58 +01:00
parent ae1a1fccfe
commit 1803d16d5c
No known key found for this signature in database
GPG Key ID: 712657D6EA17B7E5
4 changed files with 67 additions and 39 deletions

View File

@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai
- Slack/probe: keep `auth.test()` bot and team metadata mapping stable while simplifying the probe result path. (#44775) Thanks @Cafexss. - Slack/probe: keep `auth.test()` bot and team metadata mapping stable while simplifying the probe result path. (#44775) Thanks @Cafexss.
- Dashboard/chat UI: restore the `chat-new-messages` class on the New messages scroll pill so the button uses its existing compact styling instead of rendering as a full-screen SVG overlay. (#44856) Thanks @Astro-Han. - Dashboard/chat UI: restore the `chat-new-messages` class on the New messages scroll pill so the button uses its existing compact styling instead of rendering as a full-screen SVG overlay. (#44856) Thanks @Astro-Han.
- Windows/gateway status: reuse the installed service command environment when reading runtime status, so startup-fallback gateways keep reporting the configured port and running state in `gateway status --json` instead of falling back to `gateway port unknown`. - Windows/gateway status: reuse the installed service command environment when reading runtime status, so startup-fallback gateways keep reporting the configured port and running state in `gateway status --json` instead of falling back to `gateway port unknown`.
- Security/device pairing: make bootstrap setup codes single-use so pending device pairing requests cannot be silently replayed and widened to admin before approval. Thanks @tdjackey.
## 2026.3.12 ## 2026.3.12

View File

@ -24,7 +24,23 @@ afterEach(async () => {
}); });
describe("device bootstrap tokens", () => { describe("device bootstrap tokens", () => {
it("binds the first successful verification to a device identity", async () => { it("accepts the first successful verification", async () => {
const baseDir = await createBaseDir();
const issued = await issueDeviceBootstrapToken({ baseDir });
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-1",
publicKey: "pub-1",
role: "node",
scopes: ["node.invoke"],
baseDir,
}),
).resolves.toEqual({ ok: true });
});
it("rejects replay after the first successful verification", async () => {
const baseDir = await createBaseDir(); const baseDir = await createBaseDir();
const issued = await issueDeviceBootstrapToken({ baseDir }); const issued = await issueDeviceBootstrapToken({ baseDir });
@ -48,10 +64,10 @@ describe("device bootstrap tokens", () => {
scopes: ["operator.read"], scopes: ["operator.read"],
baseDir, baseDir,
}), }),
).resolves.toEqual({ ok: true }); ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
}); });
it("rejects reuse from a different device after binding", async () => { it("rejects reuse from a different device after consumption", async () => {
const baseDir = await createBaseDir(); const baseDir = await createBaseDir();
const issued = await issueDeviceBootstrapToken({ baseDir }); const issued = await issueDeviceBootstrapToken({ baseDir });

View File

@ -25,29 +25,6 @@ type DeviceBootstrapStateFile = Record<string, DeviceBootstrapTokenRecord>;
const withLock = createAsyncLock(); const withLock = createAsyncLock();
function mergeRoles(existing: string[] | undefined, role: string): string[] {
const out = new Set<string>(existing ?? []);
const trimmed = role.trim();
if (trimmed) {
out.add(trimmed);
}
return [...out];
}
function mergeScopes(
existing: string[] | undefined,
scopes: readonly string[],
): string[] | undefined {
const out = new Set<string>(existing ?? []);
for (const scope of scopes) {
const trimmed = scope.trim();
if (trimmed) {
out.add(trimmed);
}
}
return out.size > 0 ? [...out] : undefined;
}
function resolveBootstrapPath(baseDir?: string): string { function resolveBootstrapPath(baseDir?: string): string {
return path.join(resolvePairingPaths(baseDir, "devices").dir, "bootstrap.json"); return path.join(resolvePairingPaths(baseDir, "devices").dir, "bootstrap.json");
} }
@ -116,19 +93,9 @@ export async function verifyDeviceBootstrapToken(params: {
return { ok: false, reason: "bootstrap_token_invalid" }; return { ok: false, reason: "bootstrap_token_invalid" };
} }
if (entry.deviceId && entry.deviceId !== deviceId) { // Bootstrap setup codes are single-use. Consume the record before returning
return { ok: false, reason: "bootstrap_token_invalid" }; // success so the same token cannot be replayed to mutate a pending request.
} delete state[entry.token];
if (entry.publicKey && entry.publicKey !== publicKey) {
return { ok: false, reason: "bootstrap_token_invalid" };
}
entry.deviceId = deviceId;
entry.publicKey = publicKey;
entry.roles = mergeRoles(entry.roles, role);
entry.scopes = mergeScopes(entry.scopes, params.scopes);
entry.lastUsedAtMs = Date.now();
state[entry.token] = entry;
await persistState(state, params.baseDir); await persistState(state, params.baseDir);
return { ok: true }; return { ok: true };
}); });

View File

@ -2,6 +2,7 @@ import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } from "./device-bootstrap.js";
import { import {
approveDevicePairing, approveDevicePairing,
clearDevicePairing, clearDevicePairing,
@ -146,6 +147,49 @@ describe("device pairing tokens", () => {
expect(paired?.scopes).toEqual(["operator.read", "operator.write"]); expect(paired?.scopes).toEqual(["operator.read", "operator.write"]);
}); });
test("rejects bootstrap token replay before pending scope escalation can be approved", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
const issued = await issueDeviceBootstrapToken({ baseDir });
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-1",
publicKey: "public-key-1",
role: "operator",
scopes: ["operator.read"],
baseDir,
}),
).resolves.toEqual({ ok: true });
const first = await requestDevicePairing(
{
deviceId: "device-1",
publicKey: "public-key-1",
role: "operator",
scopes: ["operator.read"],
},
baseDir,
);
await expect(
verifyDeviceBootstrapToken({
token: issued.token,
deviceId: "device-1",
publicKey: "public-key-1",
role: "operator",
scopes: ["operator.admin"],
baseDir,
}),
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
await approveDevicePairing(first.request.requestId, baseDir);
const paired = await getPairedDevice("device-1", baseDir);
expect(paired?.scopes).toEqual(["operator.read"]);
expect(paired?.approvedScopes).toEqual(["operator.read"]);
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]);
});
test("generates base64url device tokens with 256-bit entropy output length", async () => { test("generates base64url device tokens with 256-bit entropy output length", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
await setupPairedOperatorDevice(baseDir, ["operator.admin"]); await setupPairedOperatorDevice(baseDir, ["operator.admin"]);