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.
|
- 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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"]);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue