fix: preserve loopback gateway scopes for local auth
This commit is contained in:
parent
130b575c21
commit
4ab016a9bd
@ -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 () => {
|
||||
|
||||
@ -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 = {
|
||||
|
||||
50
src/gateway/probe.auth.integration.test.ts
Normal file
50
src/gateway/probe.auth.integration.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user