VYPR
Medium severity6.5NVD Advisory· Published Apr 10, 2026· Updated Apr 13, 2026

CVE-2026-35656

CVE-2026-35656

Description

OpenClaw before 2026.3.22 contains an authentication bypass vulnerability in the X-Forwarded-For header processing when trustedProxies is configured, allowing attackers to spoof loopback hops. Remote attackers can inject forged forwarding headers to bypass canvas authentication and rate-limiting protections by masquerading as loopback clients.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.222026.3.22

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.3.22

Patches

2
630f1479c44f

build: prepare 2026.3.23-2

https://github.com/openclaw/openclawPeter SteinbergerMar 24, 2026via ghsa
3 files changed · +26 24
  • CHANGELOG.md+24 22 modified
    @@ -8,45 +8,47 @@ Docs: https://docs.openclaw.ai
     
     ### Changes
     
    -- ModelStudio/Qwen: add standard (pay-as-you-go) DashScope endpoints for China and global Qwen API keys alongside the existing Coding Plan endpoints, and relabel the provider group to `Qwen (Alibaba Cloud Model Studio)`. (#43878)
    -- UI/clarity: consolidate button primitives (`btn--icon`, `btn--ghost`, `btn--xs`), refine the Knot theme to a black-and-red palette with WCAG 2.1 AA contrast, add config icons for Diagnostics/CLI/Secrets/ACP/MCP sections, replace the roundness slider with discrete stops, and improve accessibility with aria-labels across usage filters. (#53272) Thanks @BunsDev.
    -- CSP/Control UI: compute SHA-256 hashes for inline `<script>` blocks in the served `index.html` and include them in the `script-src` CSP directive, keeping inline scripts blocked by default while allowing explicitly hashed bootstrap code. (#53307) Thanks @BunsDev.
    -
     ### Fixes
     
    -- Plugins/ClawHub: resolve plugin API compatibility against the active runtime version at install time, and add regression coverage for current `>=2026.3.22` ClawHub package checks so installs no longer fail behind the stale `1.2.0` constant. (#53157) Thanks @futhgar.
    -- CLI/channel auth: auto-select the single login-capable configured channel for `channels login`/`logout` instead of relying on the outbound message-channel resolver, so env-only or non-auth channels no longer cause false ambiguity errors. (#53254) Thanks @BunsDev.
    -- Control UI/auth: preserve operator scopes through the device-auth bypass path, ignore cached under-scoped operator tokens, and show a clear `operator.read` fallback message when a connection really lacks read scope, so operator sessions stop failing or blanking on read-backed pages. (#53110) Thanks @BunsDev.
    -- Plugins/uninstall: accept installed `clawhub:` specs and versionless ClawHub package names as uninstall targets, so `openclaw plugins uninstall clawhub:<package>` works again even when the recorded install was pinned to a version.
    -- Auth/OpenAI tokens: stop live gateway auth-profile writes from reverting freshly saved credentials back to stale in-memory values, and make `models auth paste-token` write to the resolved agent store, so Configure, Onboard, and token-paste flows stop snapping back to expired OpenAI tokens. Fixes #53207. Related to #45516.
    -- Agents/failover: classify generic `api_error` payloads as retryable only when they include transient failure signals, so MiniMax-style backend failures still trigger model fallback without misclassifying billing, auth, or format/context errors. (#49611) Thanks @ayushozha.
    -- Diagnostics/cache trace: strip credential fields from cache-trace JSONL output while preserving non-sensitive diagnostic fields and image redaction metadata.
    -- Docs/Feishu: replace `botName` with `name` in the channel config examples so the docs match the strict account schema for per-account display names. (#52753) Thanks @haroldfabla2-hue.
    -- Doctor/plugins: make `openclaw doctor --fix` remove stale `plugins.allow` and `plugins.entries` refs left behind after plugin removal. Thanks @sallyom
    -- Agents/replay: canonicalize malformed assistant transcript content before session-history sanitization so legacy or corrupted assistant turns stop crashing Pi replay and subagent recovery paths.
    -- ClawHub/skills: keep updating already-tracked legacy Unicode slugs after the ASCII-only slug hardening, so older installs do not get stuck behind `Invalid skill slug` errors during `openclaw skills update`. (#53206) Thanks @drobison00.
    -- Infra/exec trust: preserve shell-multiplexer wrapper binaries for policy checks without breaking approved-command reconstruction, so BusyBox/ToyBox allowlist and audit flows bind to the real wrapper while execution plans stay coherent. (#53134) Thanks @vincentkoc.
    -- LINE/runtime-api: pre-export overlapping runtime symbols before the `line-runtime` star export so jiti no longer throws `TypeError: Cannot redefine property` on startup. (#53221) Thanks @Drickon.
    -- CLI/cron: make `openclaw cron add|edit --at ... --tz <iana>` honor the requested local wall-clock time for offset-less one-shot datetimes, including DST boundaries, and keep `--tz` rejected for `--every`. (#53224) Thanks @RolfHegr.
    -- Commands/auth: stop slash-command authorization from crashing or dropping valid allowlists when channel `allowFrom` resolution hits unresolved SecretRef-backed accounts, and fail closed only for the affected provider inference path. (#52791) Thanks @Lukavyi.
    -
     ## 2026.3.23
     
     ### Breaking
     
     ### Changes
     
    +- ModelStudio/Qwen: add standard (pay-as-you-go) DashScope endpoints for China and global Qwen API keys alongside the existing Coding Plan endpoints, and relabel the provider group to `Qwen (Alibaba Cloud Model Studio)`. (#43878)
    +- UI/clarity: consolidate button primitives (`btn--icon`, `btn--ghost`, `btn--xs`), refine the Knot theme to a black-and-red palette with WCAG 2.1 AA contrast, add config icons for Diagnostics/CLI/Secrets/ACP/MCP sections, replace the roundness slider with discrete stops, and improve accessibility with aria-labels across usage filters. (#53272) Thanks @BunsDev.
    +- CSP/Control UI: compute SHA-256 hashes for inline `<script>` blocks in the served `index.html` and include them in the `script-src` CSP directive, keeping inline scripts blocked by default while allowing explicitly hashed bootstrap code. (#53307) Thanks @BunsDev.
    +
     ### Fixes
     
    +- Plugins/bundled runtimes: ship bundled plugin runtime sidecars like WhatsApp `light-runtime-api.js`, Matrix `runtime-api.js`, and other plugin runtime entry files in the npm package again, so global installs stop failing on missing bundled plugin runtime surfaces.
    +- CLI/channel auth: auto-select the single configured login-capable channel for `channels login`/`logout`, harden channel ids against prototype-chain and control-character abuse, and fall back cleanly to catalog-backed channel installs, so channel auth works again for single-channel setups and on-demand channel installs. (#53254) Thanks @BunsDev.
    +- Auth/OpenAI tokens: stop live gateway auth-profile writes from reverting freshly saved credentials back to stale in-memory values, and make `models auth paste-token` write to the resolved agent store, so Configure, Onboard, and token-paste flows stop snapping back to expired OpenAI tokens. Fixes #53207. Related to #45516.
    +- Control UI/auth: preserve operator scopes through the device-auth bypass path, ignore cached under-scoped operator tokens, and show a clear `operator.read` fallback message when a connection really lacks read scope, so operator sessions stop failing or blanking on read-backed pages. (#53110) Thanks @BunsDev.
    +- Plugins/ClawHub: resolve plugin API compatibility against the active runtime version at install time, and add regression coverage for current `>=2026.3.22` ClawHub package checks so installs no longer fail behind the stale `1.2.0` constant. (#53157) Thanks @futhgar.
    +- Plugins/uninstall: accept installed `clawhub:` specs and versionless ClawHub package names as uninstall targets, so `openclaw plugins uninstall clawhub:<package>` works again even when the recorded install was pinned to a version.
     - Browser/Chrome MCP: wait for existing-session browser tabs to become usable after attach instead of treating the initial Chrome MCP handshake as ready, which reduces user-profile timeouts and repeated consent churn on macOS Chrome attach flows. Fixes #52930. Thanks @vincentkoc.
     - Browser/CDP: reuse an already-running loopback browser after a short initial reachability miss instead of immediately falling back to relaunch detection, which fixes second-run browser start/open regressions on slower headless Linux setups. Fixes #53004. Thanks @vincentkoc.
    +- Agents/web_search: use the active runtime `web_search` provider instead of stale/default selection, so agent turns keep hitting the provider you actually configured. Fixes #53020. Thanks @jzakirov.
    +- Mistral/models: lower bundled Mistral max-token defaults to safe output budgets and teach `openclaw doctor --fix` to repair old persisted Mistral provider configs that still carry context-sized output limits, avoiding deterministic Mistral 422 rejects on fresh and existing setups. Fixes #52599. Thanks @vincentkoc.
     - ClawHub/macOS auth: honor macOS auth config and XDG auth paths for saved ClawHub credentials, so `openclaw skills ...` and gateway skill browsing keep using the signed-in auth state instead of silently falling back to unauthenticated mode. Fixes #53034.
     - ClawHub/macOS: read the local ClawHub login from the macOS Application Support path and still honor XDG config on macOS, so skill browsing uses the logged-in token on both default and XDG-style setups. Fixes #52949. Thanks @scoootscooob.
     - ClawHub/skills: resolve the local ClawHub auth token for gateway skill browsing and switch browse-all requests to search so ClawControl stops falling into unauthenticated 429s and empty authenticated skill lists. Fixes #52949. Thanks @vincentkoc.
    +- Config/warnings: suppress the confusing “newer OpenClaw” warning when a config written by a same-base correction release like `2026.3.23-2` is read by `2026.3.23`, while still warning for truly newer or incompatible versions.
    +- CLI/cron: make `openclaw cron add|edit --at ... --tz <iana>` honor the requested local wall-clock time for offset-less one-shot datetimes, including DST boundaries, and keep `--tz` rejected for `--every`. (#53224) Thanks @RolfHegr.
    +- Commands/auth: stop slash-command authorization from crashing or dropping valid allowlists when channel `allowFrom` resolution hits unresolved SecretRef-backed accounts, and fail closed only for the affected provider inference path. (#52791) Thanks @Lukavyi.
    +- Agents/failover: classify generic `api_error` payloads as retryable only when they include transient failure signals, so MiniMax-style backend failures still trigger model fallback without misclassifying billing, auth, or format/context errors. (#49611) Thanks @ayushozha.
    +- LINE/runtime-api: pre-export overlapping runtime symbols before the `line-runtime` star export so jiti no longer throws `TypeError: Cannot redefine property` on startup. (#53221) Thanks @Drickon.
    +- Telegram/threading: populate `currentThreadTs` in the threading tool-context fallback for Telegram DM topics so thread-aware tools still receive the active topic context when the main thread metadata is missing. (#52217)
    +- Diagnostics/cache trace: strip credential fields from cache-trace JSONL output while preserving non-sensitive diagnostic fields and image redaction metadata.
    +- Docs/Feishu: replace `botName` with `name` in the channel config examples so the docs match the strict account schema for per-account display names. (#52753) Thanks @haroldfabla2-hue.
    +- Doctor/plugins: make `openclaw doctor --fix` remove stale `plugins.allow` and `plugins.entries` refs left behind after plugin removal. Thanks @sallyom
    +- Agents/replay: canonicalize malformed assistant transcript content before session-history sanitization so legacy or corrupted assistant turns stop crashing Pi replay and subagent recovery paths.
    +- ClawHub/skills: keep updating already-tracked legacy Unicode slugs after the ASCII-only slug hardening, so older installs do not get stuck behind `Invalid skill slug` errors during `openclaw skills update`. (#53206) Thanks @drobison00.
    +- Infra/exec trust: preserve shell-multiplexer wrapper binaries for policy checks without breaking approved-command reconstruction, so BusyBox/ToyBox allowlist and audit flows bind to the real wrapper while execution plans stay coherent. (#53134) Thanks @vincentkoc.
     - Plugins/message tool: make Discord `components` and Slack `blocks` optional again, and route Feishu `message(..., media=...)` sends through the outbound media path, so pin/unpin/react flows stop failing schema validation and Feishu file/image attachments actually send. Fixes #52970 and #52962. Thanks @vincentkoc.
     - Gateway/model pricing: stop `openrouter/auto` pricing refresh from recursing indefinitely during bootstrap, so OpenRouter auto routes can populate cached pricing and `usage.cost` again. Fixes #53035. Thanks @vincentkoc.
    -- Mistral/models: lower bundled Mistral max-token defaults to safe output budgets and teach `openclaw doctor --fix` to repair old persisted Mistral provider configs that still carry context-sized output limits, avoiding deterministic Mistral 422 rejects on fresh and existing setups. Fixes #52599. Thanks @vincentkoc.
    -- Agents/web_search: use the active runtime `web_search` provider instead of stale/default selection, so agent turns keep hitting the provider you actually configured. Fixes #53020. Thanks @jzakirov.
     - Models/OpenAI Codex OAuth: bootstrap the env-configured HTTP/HTTPS proxy dispatcher on the stored-credential refresh path before token renewal runs, so expired Codex OAuth profiles can refresh successfully in proxy-required environments instead of locking users out after the first token expiry.
     - Models/OpenAI Codex OAuth and Plugins/MiniMax OAuth: ensure env-configured HTTP/HTTPS proxy dispatchers are initialized before OAuth preflight and token exchange requests so proxy-required environments can complete MiniMax and OpenAI Codex sign-in flows again. (#52228; fixes #51619, #51569) Thanks @openperf.
     - Plugins/memory-lancedb: bootstrap LanceDB into plugin runtime state on first use when the bundled npm install does not already have it, so `plugins.slots.memory="memory-lancedb"` works again after global npm installs without moving LanceDB into OpenClaw core dependencies. Fixes #26100.
    
  • docs/.generated/plugin-sdk-api-baseline.json+1 1 modified
    @@ -2620,7 +2620,7 @@
               "exportName": "resolveCommandAuthorization",
               "kind": "function",
               "source": {
    -            "line": 303,
    +            "line": 440,
                 "path": "src/auto-reply/command-auth.ts"
               }
             },
    
  • docs/.generated/plugin-sdk-api-baseline.jsonl+1 1 modified
    @@ -287,7 +287,7 @@
     {"declaration":"export function parseCommandArgs(command: ChatCommandDefinition, raw?: string | undefined): CommandArgs | undefined;","entrypoint":"command-auth","exportName":"parseCommandArgs","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":254,"sourcePath":"src/auto-reply/commands-registry.ts"}
     {"declaration":"export function resolveCommandArgChoices(params: { command: ChatCommandDefinition; arg: CommandArgDefinition; cfg?: OpenClawConfig | undefined; provider?: string | undefined; model?: string | undefined; }): ResolvedCommandArgChoice[];","entrypoint":"command-auth","exportName":"resolveCommandArgChoices","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":316,"sourcePath":"src/auto-reply/commands-registry.ts"}
     {"declaration":"export function resolveCommandArgMenu(params: { command: ChatCommandDefinition; args?: CommandArgs | undefined; cfg?: OpenClawConfig | undefined; }): { arg: CommandArgDefinition; choices: ResolvedCommandArgChoice[]; title?: string | undefined; } | null;","entrypoint":"command-auth","exportName":"resolveCommandArgMenu","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":346,"sourcePath":"src/auto-reply/commands-registry.ts"}
    -{"declaration":"export function resolveCommandAuthorization(params: { ctx: MsgContext; cfg: OpenClawConfig; commandAuthorized: boolean; }): CommandAuthorization;","entrypoint":"command-auth","exportName":"resolveCommandAuthorization","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":303,"sourcePath":"src/auto-reply/command-auth.ts"}
    +{"declaration":"export function resolveCommandAuthorization(params: { ctx: MsgContext; cfg: OpenClawConfig; commandAuthorized: boolean; }): CommandAuthorization;","entrypoint":"command-auth","exportName":"resolveCommandAuthorization","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":440,"sourcePath":"src/auto-reply/command-auth.ts"}
     {"declaration":"export function resolveCommandAuthorizedFromAuthorizers(params: { useAccessGroups: boolean; authorizers: CommandAuthorizer[]; modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff | undefined; }): boolean;","entrypoint":"command-auth","exportName":"resolveCommandAuthorizedFromAuthorizers","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":8,"sourcePath":"src/channels/command-gating.ts"}
     {"declaration":"export function resolveControlCommandGate(params: { useAccessGroups: boolean; authorizers: CommandAuthorizer[]; allowTextCommands: boolean; hasControlCommand: boolean; modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff | undefined; }): { ...; };","entrypoint":"command-auth","exportName":"resolveControlCommandGate","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":31,"sourcePath":"src/channels/command-gating.ts"}
     {"declaration":"export function resolveDirectDmAuthorizationOutcome(params: { isGroup: boolean; dmPolicy: string; senderAllowedForCommands: boolean; }): \"disabled\" | \"unauthorized\" | \"allowed\";","entrypoint":"command-auth","exportName":"resolveDirectDmAuthorizationOutcome","importSpecifier":"openclaw/plugin-sdk/command-auth","kind":"function","recordType":"export","sourceLine":120,"sourcePath":"src/plugin-sdk/command-auth.ts"}
    
fc2d29ea926f

Gateway: tighten forwarded client and pairing guards (#46800)

https://github.com/openclaw/openclawVincent KocMar 15, 2026via ghsa
7 files changed · +252 6
  • CHANGELOG.md+1 0 modified
    @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
     - Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
     - Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
     - Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
    +- Gateway/auth: ignore spoofed loopback hops in trusted forwarding chains and block device approvals that request scopes above the caller session. Thanks @vincentkoc.
     - Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. Thanks @vincentkoc.
     - Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. Thanks @vincentkoc.
     - Webhooks/runtime: move auth earlier and tighten pre-auth body limits and timeouts across bundled webhook handlers, including slow-body handling for Mattermost slash commands. Thanks @vincentkoc.
    
  • src/gateway/net.test.ts+14 0 modified
    @@ -209,13 +209,27 @@ describe("resolveClientIp", () => {
           trustedProxies: ["127.0.0.1"],
           expected: "10.0.0.9",
         },
    +    {
    +      name: "ignores spoofed loopback X-Forwarded-For hops from trusted proxies",
    +      remoteAddr: "10.0.0.50",
    +      forwardedFor: "127.0.0.1",
    +      trustedProxies: ["10.0.0.0/8"],
    +      expected: undefined,
    +    },
         {
           name: "fails closed when all X-Forwarded-For hops are trusted proxies",
           remoteAddr: "127.0.0.1",
           forwardedFor: "127.0.0.1, ::1",
           trustedProxies: ["127.0.0.1", "::1"],
           expected: undefined,
         },
    +    {
    +      name: "fails closed when all non-loopback X-Forwarded-For hops are trusted proxies",
    +      remoteAddr: "10.0.0.50",
    +      forwardedFor: "10.0.0.2, 10.0.0.1",
    +      trustedProxies: ["10.0.0.0/8"],
    +      expected: undefined,
    +    },
         {
           name: "fails closed when trusted proxy omits forwarding headers",
           remoteAddr: "127.0.0.1",
    
  • src/gateway/net.ts+3 0 modified
    @@ -132,6 +132,9 @@ function resolveForwardedClientIp(params: {
       // Walk right-to-left and return the first untrusted hop.
       for (let index = forwardedChain.length - 1; index >= 0; index -= 1) {
         const hop = forwardedChain[index];
    +    if (isLoopbackAddress(hop)) {
    +      continue;
    +    }
         if (!isTrustedProxyAddress(hop, trustedProxies)) {
           return hop;
         }
    
  • src/gateway/server.canvas-auth.test.ts+41 1 modified
    @@ -263,7 +263,7 @@ describe("gateway canvas host auth", () => {
               const scopedA2ui = await fetch(
                 `http://${host}:${listener.port}${scopedCanvasPath(activeNodeCapability, `${A2UI_PATH}/`)}`,
               );
    -          expect(scopedA2ui.status).toBe(200);
    +          expect(scopedA2ui.status).toBe(503);
     
               await expectWsConnected(`ws://${host}:${listener.port}${activeWsPath}`);
     
    @@ -383,4 +383,44 @@ describe("gateway canvas host auth", () => {
           });
         });
       }, 60_000);
    +
    +  test("rejects spoofed loopback forwarding headers from trusted proxies", async () => {
    +    await withTempConfig({
    +      cfg: {
    +        gateway: {
    +          trustedProxies: ["127.0.0.1"],
    +        },
    +      },
    +      run: async () => {
    +        const rateLimiter = createAuthRateLimiter({
    +          maxAttempts: 1,
    +          windowMs: 60_000,
    +          lockoutMs: 60_000,
    +          exemptLoopback: true,
    +        });
    +        await withCanvasGatewayHarness({
    +          resolvedAuth: tokenResolvedAuth,
    +          listenHost: "0.0.0.0",
    +          rateLimiter,
    +          handleHttpRequest: async () => false,
    +          run: async ({ listener }) => {
    +            const headers = {
    +              authorization: "Bearer wrong",
    +              host: "localhost",
    +              "x-forwarded-for": "127.0.0.1, 203.0.113.24",
    +            };
    +            const first = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, {
    +              headers,
    +            });
    +            expect(first.status).toBe(401);
    +
    +            const second = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, {
    +              headers,
    +            });
    +            expect(second.status).toBe(429);
    +          },
    +        });
    +      },
    +    });
    +  }, 60_000);
     });
    
  • src/gateway/server.device-pair-approve-authz.test.ts+131 0 added
    @@ -0,0 +1,131 @@
    +import os from "node:os";
    +import path from "node:path";
    +import { describe, expect, test } from "vitest";
    +import { WebSocket } from "ws";
    +import {
    +  loadOrCreateDeviceIdentity,
    +  publicKeyRawBase64UrlFromPem,
    +  type DeviceIdentity,
    +} from "../infra/device-identity.js";
    +import {
    +  approveDevicePairing,
    +  getPairedDevice,
    +  requestDevicePairing,
    +  rotateDeviceToken,
    +} from "../infra/device-pairing.js";
    +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
    +import {
    +  connectOk,
    +  installGatewayTestHooks,
    +  rpcReq,
    +  startServerWithClient,
    +  trackConnectChallengeNonce,
    +} from "./test-helpers.js";
    +
    +installGatewayTestHooks({ scope: "suite" });
    +
    +function resolveDeviceIdentityPath(name: string): string {
    +  const root = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir();
    +  return path.join(root, "test-device-identities", `${name}.json`);
    +}
    +
    +function loadDeviceIdentity(name: string): {
    +  identityPath: string;
    +  identity: DeviceIdentity;
    +  publicKey: string;
    +} {
    +  const identityPath = resolveDeviceIdentityPath(name);
    +  const identity = loadOrCreateDeviceIdentity(identityPath);
    +  return {
    +    identityPath,
    +    identity,
    +    publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
    +  };
    +}
    +
    +async function issuePairingScopedOperator(name: string): Promise<{
    +  identityPath: string;
    +  deviceId: string;
    +  token: string;
    +}> {
    +  const loaded = loadDeviceIdentity(name);
    +  const request = await requestDevicePairing({
    +    deviceId: loaded.identity.deviceId,
    +    publicKey: loaded.publicKey,
    +    role: "operator",
    +    scopes: ["operator.admin"],
    +    clientId: GATEWAY_CLIENT_NAMES.TEST,
    +    clientMode: GATEWAY_CLIENT_MODES.TEST,
    +  });
    +  await approveDevicePairing(request.request.requestId);
    +  const rotated = await rotateDeviceToken({
    +    deviceId: loaded.identity.deviceId,
    +    role: "operator",
    +    scopes: ["operator.pairing"],
    +  });
    +  expect(rotated?.token).toBeTruthy();
    +  return {
    +    identityPath: loaded.identityPath,
    +    deviceId: loaded.identity.deviceId,
    +    token: String(rotated?.token ?? ""),
    +  };
    +}
    +
    +async function openTrackedWs(port: number): Promise<WebSocket> {
    +  const ws = new WebSocket(`ws://127.0.0.1:${port}`);
    +  trackConnectChallengeNonce(ws);
    +  await new Promise<void>((resolve, reject) => {
    +    const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 5_000);
    +    ws.once("open", () => {
    +      clearTimeout(timer);
    +      resolve();
    +    });
    +    ws.once("error", (error) => {
    +      clearTimeout(timer);
    +      reject(error);
    +    });
    +  });
    +  return ws;
    +}
    +
    +describe("gateway device.pair.approve caller scope guard", () => {
    +  test("rejects approving device scopes above the caller session scopes", async () => {
    +    const started = await startServerWithClient("secret");
    +    const approver = await issuePairingScopedOperator("approve-attacker");
    +    const pending = loadDeviceIdentity("approve-target");
    +
    +    let pairingWs: WebSocket | undefined;
    +    try {
    +      const request = await requestDevicePairing({
    +        deviceId: pending.identity.deviceId,
    +        publicKey: pending.publicKey,
    +        role: "operator",
    +        scopes: ["operator.admin"],
    +        clientId: GATEWAY_CLIENT_NAMES.TEST,
    +        clientMode: GATEWAY_CLIENT_MODES.TEST,
    +      });
    +
    +      pairingWs = await openTrackedWs(started.port);
    +      await connectOk(pairingWs, {
    +        skipDefaultAuth: true,
    +        deviceToken: approver.token,
    +        deviceIdentityPath: approver.identityPath,
    +        scopes: ["operator.pairing"],
    +      });
    +
    +      const approve = await rpcReq(pairingWs, "device.pair.approve", {
    +        requestId: request.request.requestId,
    +      });
    +      expect(approve.ok).toBe(false);
    +      expect(approve.error?.message).toBe("missing scope: operator.admin");
    +
    +      const paired = await getPairedDevice(pending.identity.deviceId);
    +      expect(paired).toBeNull();
    +    } finally {
    +      pairingWs?.close();
    +      started.ws.close();
    +      await started.server.close();
    +      started.envSnapshot.restore();
    +    }
    +  });
    +});
    
  • src/gateway/server-methods/devices.ts+11 2 modified
    @@ -94,7 +94,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
           undefined,
         );
       },
    -  "device.pair.approve": async ({ params, respond, context }) => {
    +  "device.pair.approve": async ({ params, respond, context, client }) => {
         if (!validateDevicePairApproveParams(params)) {
           respond(
             false,
    @@ -109,11 +109,20 @@ export const deviceHandlers: GatewayRequestHandlers = {
           return;
         }
         const { requestId } = params as { requestId: string };
    -    const approved = await approveDevicePairing(requestId);
    +    const callerScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
    +    const approved = await approveDevicePairing(requestId, { callerScopes });
         if (!approved) {
           respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
           return;
         }
    +    if (approved.status === "forbidden") {
    +      respond(
    +        false,
    +        undefined,
    +        errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${approved.missingScope}`),
    +      );
    +      return;
    +    }
         context.logGateway.info(
           `device pairing approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
         );
    
  • src/infra/device-pairing.ts+51 3 modified
    @@ -80,6 +80,11 @@ export type DevicePairingList = {
       paired: PairedDevice[];
     };
     
    +export type ApproveDevicePairingResult =
    +  | { status: "approved"; requestId: string; device: PairedDevice }
    +  | { status: "forbidden"; missingScope: string }
    +  | null;
    +
     type DevicePairingStateFile = {
       pendingById: Record<string, DevicePairingPendingRequest>;
       pairedByDeviceId: Record<string, PairedDevice>;
    @@ -246,6 +251,25 @@ function scopesWithinApprovedDeviceBaseline(params: {
       });
     }
     
    +function resolveMissingRequestedScope(params: {
    +  role: string;
    +  requestedScopes: readonly string[];
    +  callerScopes: readonly string[];
    +}): string | null {
    +  for (const scope of params.requestedScopes) {
    +    if (
    +      !roleScopesAllow({
    +        role: params.role,
    +        requestedScopes: [scope],
    +        allowedScopes: params.callerScopes,
    +      })
    +    ) {
    +      return scope;
    +    }
    +  }
    +  return null;
    +}
    +
     export async function listDevicePairing(baseDir?: string): Promise<DevicePairingList> {
       const state = await loadState(baseDir);
       const pending = Object.values(state.pendingById).toSorted((a, b) => b.ts - a.ts);
    @@ -263,6 +287,14 @@ export async function getPairedDevice(
       return state.pairedByDeviceId[normalizeDeviceId(deviceId)] ?? null;
     }
     
    +export async function getPendingDevicePairing(
    +  requestId: string,
    +  baseDir?: string,
    +): Promise<DevicePairingPendingRequest | null> {
    +  const state = await loadState(baseDir);
    +  return state.pendingById[requestId] ?? null;
    +}
    +
     export async function requestDevicePairing(
       req: Omit<DevicePairingPendingRequest, "requestId" | "ts" | "isRepair">,
       baseDir?: string,
    @@ -313,14 +345,30 @@ export async function requestDevicePairing(
     
     export async function approveDevicePairing(
       requestId: string,
    -  baseDir?: string,
    -): Promise<{ requestId: string; device: PairedDevice } | null> {
    +  optionsOrBaseDir?: { callerScopes?: readonly string[] } | string,
    +  maybeBaseDir?: string,
    +): Promise<ApproveDevicePairingResult> {
    +  const options =
    +    typeof optionsOrBaseDir === "string" || optionsOrBaseDir === undefined
    +      ? undefined
    +      : optionsOrBaseDir;
    +  const baseDir = typeof optionsOrBaseDir === "string" ? optionsOrBaseDir : maybeBaseDir;
       return await withLock(async () => {
         const state = await loadState(baseDir);
         const pending = state.pendingById[requestId];
         if (!pending) {
           return null;
         }
    +    if (pending.role && options?.callerScopes) {
    +      const missingScope = resolveMissingRequestedScope({
    +        role: pending.role,
    +        requestedScopes: normalizeDeviceAuthScopes(pending.scopes),
    +        callerScopes: options.callerScopes,
    +      });
    +      if (missingScope) {
    +        return { status: "forbidden", missingScope };
    +      }
    +    }
         const now = Date.now();
         const existing = state.pairedByDeviceId[pending.deviceId];
         const roles = mergeRoles(existing?.roles, existing?.role, pending.roles, pending.role);
    @@ -373,7 +421,7 @@ export async function approveDevicePairing(
         delete state.pendingById[requestId];
         state.pairedByDeviceId[device.deviceId] = device;
         await persistState(state, baseDir);
    -    return { requestId, device };
    +    return { status: "approved", requestId, device };
       });
     }
     
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.