fix: preserve loopback gateway scopes for local auth

This commit is contained in:
Peter Steinberger 2026-03-16 06:22:02 +00:00
parent 130b575c21
commit 4ab016a9bd
5 changed files with 72 additions and 13 deletions

View File

@ -209,7 +209,7 @@ describe("callGateway url resolution", () => {
expect(lastClientOptions?.token).toBe("explicit-token");
});
it("does not attach device identity for local loopback shared-token auth", async () => {
it("keeps device identity enabled for local loopback shared-token auth", async () => {
setLocalLoopbackGatewayConfig();
await callGateway({
@ -219,7 +219,7 @@ describe("callGateway url resolution", () => {
expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789");
expect(lastClientOptions?.token).toBe("explicit-token");
expect(lastClientOptions?.deviceIdentity).toBeUndefined();
expect(lastClientOptions?.deviceIdentity).toBeDefined();
});
it("uses OPENCLAW_GATEWAY_URL env override in remote mode when remote URL is missing", async () => {

View File

@ -86,15 +86,12 @@ function shouldAttachDeviceIdentityForGatewayCall(params: {
token?: string;
password?: string;
}): boolean {
if (!(params.token || params.password)) {
return true;
}
try {
const parsed = new URL(params.url);
return !["127.0.0.1", "::1", "localhost"].includes(parsed.hostname);
} catch {
return true;
}
void params;
// Shared-auth local calls used to skip device identity as an optimization, but
// device-less operator connects now have their self-declared scopes stripped.
// Keep identity enabled so local authenticated calls stay device-bound and
// retain their least-privilege scopes.
return true;
}
export type ExplicitGatewayAuth = {

View File

@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { installGatewayTestHooks, testState, withGatewayServer } from "./test-helpers.js";
installGatewayTestHooks();
const { callGateway } = await import("./call.js");
const { probeGateway } = await import("./probe.js");
describe("probeGateway auth integration", () => {
it("keeps direct local authenticated status RPCs device-bound", async () => {
const token =
typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string"
? ((testState.gatewayAuth as { token?: string }).token ?? "")
: "";
expect(token).toBeTruthy();
await withGatewayServer(async ({ port }) => {
const status = await callGateway({
url: `ws://127.0.0.1:${port}`,
token,
method: "status",
timeoutMs: 5_000,
});
expect(status).toBeTruthy();
});
});
it("keeps detail RPCs available for local authenticated probes", async () => {
const token =
typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string"
? ((testState.gatewayAuth as { token?: string }).token ?? "")
: "";
expect(token).toBeTruthy();
await withGatewayServer(async ({ port }) => {
const result = await probeGateway({
url: `ws://127.0.0.1:${port}`,
auth: { token },
timeoutMs: 5_000,
});
expect(result.ok).toBe(true);
expect(result.error).toBeNull();
expect(result.health).not.toBeNull();
expect(result.status).not.toBeNull();
expect(result.configSnapshot).not.toBeNull();
});
});
});

View File

@ -51,7 +51,7 @@ describe("probeGateway", () => {
});
expect(gatewayClientState.options?.scopes).toEqual(["operator.read"]);
expect(gatewayClientState.options?.deviceIdentity).toBeNull();
expect(gatewayClientState.options?.deviceIdentity).toBeUndefined();
expect(gatewayClientState.requests).toEqual([
"health",
"status",
@ -71,6 +71,15 @@ describe("probeGateway", () => {
expect(gatewayClientState.options?.deviceIdentity).toBeUndefined();
});
it("keeps device identity disabled for unauthenticated loopback probes", async () => {
await probeGateway({
url: "ws://127.0.0.1:18789",
timeoutMs: 1_000,
});
expect(gatewayClientState.options?.deviceIdentity).toBeNull();
});
it("skips detail RPCs for lightweight reachability probes", async () => {
const result = await probeGateway({
url: "ws://127.0.0.1:18789",

View File

@ -44,7 +44,10 @@ export async function probeGateway(opts: {
const disableDeviceIdentity = (() => {
try {
return isLoopbackHost(new URL(opts.url).hostname);
const hostname = new URL(opts.url).hostname;
// Local authenticated probes should stay device-bound so read/detail RPCs
// are not scope-limited by the shared-auth scope stripping hardening.
return isLoopbackHost(hostname) && !(opts.auth?.token || opts.auth?.password);
} catch {
return false;
}