Limit connect snapshot metadata to admin-scoped clients (#58469)

* fix(gateway): gate connect snapshot metadata by scope

* fix(gateway): clarify connect snapshot trust boundary

* fix(gateway): note connect snapshot change in changelog

* fix(gateway): remove changelog changes from PR

* chore: add changelog for scoped gateway snapshot metadata

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
This commit is contained in:
Agustin Rivera 2026-04-02 10:41:47 -07:00 committed by GitHub
parent a4a372825e
commit 676b748056
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 63 additions and 10 deletions

View File

@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
- Exec/Windows: include Windows-compatible env override keys like `ProgramFiles(x86)` in system-run approval binding so changed approved values are rejected instead of silently passing unbound. (#59182) Thanks @pgondhi987.
- ACP/Windows spawn: fail closed on unresolved `.cmd` and `.bat` OpenClaw wrappers unless a caller explicitly opts into shell fallback, so Windows ACP launches do not silently drop into shell-mediated execution when wrapper unwrapping fails. (#58436) Thanks @eleqtrizit.
- Exec/Windows: prefer strict-inline-eval denial over generic allowlist prompts for interpreter carriers, while keeping persisted Windows allow-always approvals argv-bound. (#59780) Thanks @luoyanglang.
- Gateway/connect: omit admin-scoped config and auth metadata from lower-privilege `hello-ok` snapshots while preserving those fields for admin reconnects. (#58469) Thanks @eleqtrizit.
## 2026.4.2-beta.1

View File

@ -277,6 +277,38 @@ describe("gateway auth browser hardening", () => {
});
});
test("omits sensitive gateway paths from low-privilege hello-ok snapshots", async () => {
testState.gatewayAuth = { mode: "token", token: "secret" };
await withGatewayServer(async ({ port }) => {
const ws = await openWs(port, { origin: originForPort(port) });
try {
const payload = (await connectOk(ws, {
token: "secret",
scopes: ["operator.read"],
device: null,
})) as {
type: "hello-ok";
snapshot?: {
configPath?: unknown;
stateDir?: unknown;
authMode?: unknown;
};
};
// connectReq scopes are evaluated after auth and unbound-scope clearing, so this assertion
// verifies the effective low-privilege session view rather than self-declared client scopes.
const snapshot = payload.snapshot as
| { configPath?: unknown; stateDir?: unknown; authMode?: unknown }
| undefined;
expect(snapshot).toBeDefined();
expect(snapshot?.configPath).toBeUndefined();
expect(snapshot?.stateDir).toBeUndefined();
expect(snapshot?.authMode).toBeUndefined();
} finally {
ws.close();
}
});
});
test("does not silently auto-pair non-control-ui browser clients on loopback", async () => {
const { listDevicePairing } = await import("../infra/device-pairing.js");
testState.gatewayAuth = { mode: "token", token: "secret" };

View File

@ -190,7 +190,22 @@ describe("gateway auth compatibility baseline", () => {
scopes: ["operator.admin"],
});
expect(res.ok).toBe(true);
expect((res.payload as { type?: string } | undefined)?.type).toBe("hello-ok");
const payload = res.payload as
| {
type?: string;
snapshot?: {
configPath?: string;
stateDir?: string;
authMode?: string;
};
}
| undefined;
expect(payload?.type).toBe("hello-ok");
expect(typeof payload?.snapshot?.configPath).toBe("string");
expect((payload?.snapshot?.configPath ?? "").length).toBeGreaterThan(0);
expect(typeof payload?.snapshot?.stateDir).toBe("string");
expect((payload?.snapshot?.stateDir ?? "").length).toBeGreaterThan(0);
expect(payload?.snapshot?.authMode).toBe("token");
} finally {
ws.close();
}

View File

@ -14,36 +14,38 @@ let healthCache: HealthSummary | null = null;
let healthRefresh: Promise<HealthSummary> | null = null;
let broadcastHealthUpdate: ((snap: HealthSummary) => void) | null = null;
export function buildGatewaySnapshot(): Snapshot {
export function buildGatewaySnapshot(opts?: { includeSensitive?: boolean }): Snapshot {
const cfg = loadConfig();
const configPath = createConfigIO().configPath;
const defaultAgentId = resolveDefaultAgentId(cfg);
const mainKey = normalizeMainKey(cfg.session?.mainKey);
const mainSessionKey = resolveMainSessionKey(cfg);
const scope = cfg.session?.scope ?? "per-sender";
const presence = listSystemPresence();
const uptimeMs = Math.round(process.uptime() * 1000);
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env });
const updateAvailable = getUpdateAvailable() ?? undefined;
// Health is async; caller should await getHealthSnapshot and replace later if needed.
const emptyHealth: unknown = {};
return {
const snapshot: Snapshot = {
presence,
health: emptyHealth,
stateVersion: { presence: presenceVersion, health: healthVersion },
uptimeMs,
// Surface resolved paths so UIs can display the true config location.
configPath,
stateDir: STATE_DIR,
sessionDefaults: {
defaultAgentId,
mainKey,
mainSessionKey,
scope,
},
authMode: auth.mode,
updateAvailable,
};
if (opts?.includeSensitive === true) {
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env });
// Surface resolved paths only to admin callers that already have broader gateway access.
snapshot.configPath = createConfigIO().configPath;
snapshot.stateDir = STATE_DIR;
snapshot.authMode = auth.mode;
}
return snapshot;
}
export function getHealthCache(): HealthSummary | null {

View File

@ -48,6 +48,7 @@ import {
mintCanvasCapabilityToken,
} from "../../canvas-capability.js";
import { normalizeDeviceMetadataForAuth } from "../../device-auth.js";
import { ADMIN_SCOPE } from "../../method-scopes.js";
import {
isLocalishHost,
isLoopbackAddress,
@ -1048,7 +1049,9 @@ export function attachGatewayWsMessageHandler(params: {
incrementPresenceVersion();
}
const snapshot = buildGatewaySnapshot();
const snapshot = buildGatewaySnapshot({
includeSensitive: scopes.includes(ADMIN_SCOPE),
});
const cachedHealth = getHealthCache();
if (cachedHealth) {
snapshot.health = cachedHealth;