mirror of https://github.com/openclaw/openclaw.git
fix(auth): make device bootstrap tokens single-use to prevent scope escalation
Refs: GHSA-63f5-hhc7-cx6p
This commit is contained in:
parent
ae1a1fccfe
commit
1803d16d5c
|
|
@ -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.
|
||||
- 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`.
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,23 @@ afterEach(async () => {
|
|||
});
|
||||
|
||||
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 issued = await issueDeviceBootstrapToken({ baseDir });
|
||||
|
||||
|
|
@ -48,10 +64,10 @@ describe("device bootstrap tokens", () => {
|
|||
scopes: ["operator.read"],
|
||||
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 issued = await issueDeviceBootstrapToken({ baseDir });
|
||||
|
||||
|
|
|
|||
|
|
@ -25,29 +25,6 @@ type DeviceBootstrapStateFile = Record<string, DeviceBootstrapTokenRecord>;
|
|||
|
||||
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 {
|
||||
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" };
|
||||
}
|
||||
|
||||
if (entry.deviceId && entry.deviceId !== deviceId) {
|
||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||
}
|
||||
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;
|
||||
// Bootstrap setup codes are single-use. Consume the record before returning
|
||||
// success so the same token cannot be replayed to mutate a pending request.
|
||||
delete state[entry.token];
|
||||
await persistState(state, params.baseDir);
|
||||
return { ok: true };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } from "./device-bootstrap.js";
|
||||
import {
|
||||
approveDevicePairing,
|
||||
clearDevicePairing,
|
||||
|
|
@ -146,6 +147,49 @@ describe("device pairing tokens", () => {
|
|||
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 () => {
|
||||
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
||||
await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue