VYPR
High severity7.1NVD Advisory· Published Apr 23, 2026· Updated Apr 28, 2026

CVE-2026-41347

CVE-2026-41347

Description

OpenClaw before 2026.3.31 lacks browser-origin validation in HTTP operator endpoints when operating in trusted-proxy mode, allowing cross-site request forgery attacks. Attackers can exploit this by sending malicious requests from a browser in trusted-proxy deployments to perform unauthorized actions on HTTP operator endpoints.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.312026.3.31

Affected products

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

Patches

1
6b3f99a11f4d

fix(gateway): enforce trusted-proxy HTTP origin checks (#58229)

https://github.com/openclaw/openclawVincent KocMar 31, 2026via ghsa
9 files changed · +228 4
  • CHANGELOG.md+1 0 modified
    @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai
     
     ### Fixes
     
    +- Gateway/auth: reject mismatched browser `Origin` headers on trusted-proxy HTTP operator requests while keeping origin-less headless proxy clients working. Thanks @AntAISecurityLab and @vincentkoc.
     - Plugins/startup: block workspace `.env` from overriding `OPENCLAW_BUNDLED_PLUGINS_DIR`, so bundled plugin trust roots only come from inherited runtime env or package resolution instead of repo-local dotenv files. Thanks @nexrin and @vincentkoc.
     - Image generation/build: write stable runtime alias files into `dist/` and route provider-auth runtime lookups through those aliases so image-generation providers keep resolving auth/runtime modules after rebuilds instead of crashing on missing hashed chunk files.
     - Config/runtime: pin the first successful config load in memory for the running process and refresh that snapshot on successful writes/reloads, so hot paths stop reparsing `openclaw.json` between watcher-driven swaps.
    
  • src/gateway/auth.test.ts+93 0 modified
    @@ -491,6 +491,99 @@ describe("trusted-proxy auth", () => {
         expect(res.user).toBe("nick@example.com");
       });
     
    +  it("rejects trusted-proxy HTTP requests from origins outside the allowlist", async () => {
    +    await expect(
    +      authorizeHttpGatewayConnect({
    +        auth: {
    +          mode: "trusted-proxy",
    +          allowTailscale: false,
    +          trustedProxy: trustedProxyConfig,
    +        },
    +        connectAuth: null,
    +        trustedProxies: ["10.0.0.1"],
    +        req: {
    +          socket: { remoteAddress: "10.0.0.1" },
    +          headers: {
    +            host: "gateway.example.com",
    +            origin: "https://evil.example",
    +            "x-forwarded-user": "nick@example.com",
    +            "x-forwarded-proto": "https",
    +          },
    +        } as never,
    +        browserOriginPolicy: {
    +          requestHost: "gateway.example.com",
    +          origin: "https://evil.example",
    +          allowedOrigins: ["https://control.example.com"],
    +        },
    +      }),
    +    ).resolves.toEqual({
    +      ok: false,
    +      reason: "trusted_proxy_origin_not_allowed",
    +    });
    +  });
    +
    +  it("accepts trusted-proxy HTTP requests from allowed origins", async () => {
    +    await expect(
    +      authorizeHttpGatewayConnect({
    +        auth: {
    +          mode: "trusted-proxy",
    +          allowTailscale: false,
    +          trustedProxy: trustedProxyConfig,
    +        },
    +        connectAuth: null,
    +        trustedProxies: ["10.0.0.1"],
    +        req: {
    +          socket: { remoteAddress: "10.0.0.1" },
    +          headers: {
    +            host: "gateway.example.com",
    +            origin: "https://control.example.com",
    +            "x-forwarded-user": "nick@example.com",
    +            "x-forwarded-proto": "https",
    +          },
    +        } as never,
    +        browserOriginPolicy: {
    +          requestHost: "gateway.example.com",
    +          origin: "https://control.example.com",
    +          allowedOrigins: ["https://control.example.com"],
    +        },
    +      }),
    +    ).resolves.toMatchObject({
    +      ok: true,
    +      method: "trusted-proxy",
    +      user: "nick@example.com",
    +    });
    +  });
    +
    +  it("keeps origin-less trusted-proxy HTTP requests working", async () => {
    +    await expect(
    +      authorizeHttpGatewayConnect({
    +        auth: {
    +          mode: "trusted-proxy",
    +          allowTailscale: false,
    +          trustedProxy: trustedProxyConfig,
    +        },
    +        connectAuth: null,
    +        trustedProxies: ["10.0.0.1"],
    +        req: {
    +          socket: { remoteAddress: "10.0.0.1" },
    +          headers: {
    +            host: "gateway.example.com",
    +            "x-forwarded-user": "nick@example.com",
    +            "x-forwarded-proto": "https",
    +          },
    +        } as never,
    +        browserOriginPolicy: {
    +          requestHost: "gateway.example.com",
    +          allowedOrigins: ["https://control.example.com"],
    +        },
    +      }),
    +    ).resolves.toMatchObject({
    +      ok: true,
    +      method: "trusted-proxy",
    +      user: "nick@example.com",
    +    });
    +  });
    +
       it("rejects request from untrusted source", async () => {
         const res = await authorizeTrustedProxy({
           remoteAddress: "192.168.1.100",
    
  • src/gateway/auth.ts+41 0 modified
    @@ -19,6 +19,7 @@ import {
       isTrustedProxyAddress,
       resolveClientIp,
     } from "./net.js";
    +import { checkBrowserOrigin } from "./origin-check.js";
     
     export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy";
     export type ResolvedGatewayAuthModeSource =
    @@ -81,6 +82,13 @@ export type AuthorizeGatewayConnectParams = {
       rateLimitScope?: string;
       /** Trust X-Real-IP only when explicitly enabled. */
       allowRealIpFallback?: boolean;
    +  /** Optional browser-origin policy for trusted-proxy HTTP requests. */
    +  browserOriginPolicy?: {
    +    requestHost?: string;
    +    origin?: string;
    +    allowedOrigins?: string[];
    +    allowHostHeaderOriginFallback?: boolean;
    +  };
     };
     
     type TailscaleUser = {
    @@ -367,6 +375,32 @@ function shouldAllowTailscaleHeaderAuth(authSurface: GatewayAuthSurface): boolea
       return authSurface === "ws-control-ui";
     }
     
    +function authorizeTrustedProxyBrowserOrigin(params: {
    +  authSurface: GatewayAuthSurface;
    +  browserOriginPolicy?: AuthorizeGatewayConnectParams["browserOriginPolicy"];
    +}): { ok: false; reason: string } | null {
    +  if (params.authSurface !== "http") {
    +    return null;
    +  }
    +
    +  const origin = params.browserOriginPolicy?.origin?.trim();
    +  if (!origin) {
    +    return null;
    +  }
    +
    +  const originCheck = checkBrowserOrigin({
    +    requestHost: params.browserOriginPolicy?.requestHost,
    +    origin,
    +    allowedOrigins: params.browserOriginPolicy?.allowedOrigins,
    +    allowHostHeaderOriginFallback: params.browserOriginPolicy?.allowHostHeaderOriginFallback,
    +    isLocalClient: false,
    +  });
    +  if (originCheck.ok) {
    +    return null;
    +  }
    +  return { ok: false, reason: "trusted_proxy_origin_not_allowed" };
    +}
    +
     function authorizeTokenAuth(params: {
       authToken?: string;
       connectToken?: string;
    @@ -452,6 +486,13 @@ export async function authorizeGatewayConnect(
         });
     
         if ("user" in result) {
    +      const originResult = authorizeTrustedProxyBrowserOrigin({
    +        authSurface,
    +        browserOriginPolicy: params.browserOriginPolicy,
    +      });
    +      if (originResult) {
    +        return originResult;
    +      }
           return { ok: true, method: "trusted-proxy", user: result.user };
         }
         return { ok: false, reason: result.reason };
    
  • src/gateway/http-auth-helpers.test.ts+28 1 modified
    @@ -18,11 +18,18 @@ vi.mock("./http-common.js", () => ({
     vi.mock("./http-utils.js", () => ({
       getBearerToken: vi.fn(),
       getHeader: vi.fn(),
    +  resolveHttpBrowserOriginPolicy: vi.fn(() => ({
    +    requestHost: "gateway.example.com",
    +    origin: "https://evil.example",
    +    allowedOrigins: ["https://control.example.com"],
    +    allowHostHeaderOriginFallback: false,
    +  })),
     }));
     
     const { authorizeHttpGatewayConnect } = await import("./auth.js");
     const { sendGatewayAuthFailure } = await import("./http-common.js");
    -const { getBearerToken, getHeader } = await import("./http-utils.js");
    +const { getBearerToken, getHeader, resolveHttpBrowserOriginPolicy } =
    +  await import("./http-utils.js");
     
     describe("authorizeGatewayBearerRequestOrReply", () => {
       const bearerAuth = {
    @@ -74,6 +81,26 @@ describe("authorizeGatewayBearerRequestOrReply", () => {
         );
         expect(vi.mocked(sendGatewayAuthFailure)).not.toHaveBeenCalled();
       });
    +
    +  it("forwards browser-origin policy into HTTP auth", async () => {
    +    const params = makeAuthorizeParams();
    +    vi.mocked(getBearerToken).mockReturnValue(undefined);
    +    vi.mocked(authorizeHttpGatewayConnect).mockResolvedValue({ ok: true, method: "trusted-proxy" });
    +
    +    await authorizeGatewayBearerRequestOrReply(params);
    +
    +    expect(vi.mocked(resolveHttpBrowserOriginPolicy)).toHaveBeenCalledWith(params.req);
    +    expect(vi.mocked(authorizeHttpGatewayConnect)).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        browserOriginPolicy: {
    +          requestHost: "gateway.example.com",
    +          origin: "https://evil.example",
    +          allowedOrigins: ["https://control.example.com"],
    +          allowHostHeaderOriginFallback: false,
    +        },
    +      }),
    +    );
    +  });
     });
     
     describe("resolveGatewayRequestedOperatorScopes", () => {
    
  • src/gateway/http-auth-helpers.ts+3 1 modified
    @@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
     import type { AuthRateLimiter } from "./auth-rate-limit.js";
     import { authorizeHttpGatewayConnect, type ResolvedGatewayAuth } from "./auth.js";
     import { sendGatewayAuthFailure } from "./http-common.js";
    -import { getBearerToken, getHeader } from "./http-utils.js";
    +import { getBearerToken, getHeader, resolveHttpBrowserOriginPolicy } from "./http-utils.js";
     import { CLI_DEFAULT_OPERATOR_SCOPES } from "./method-scopes.js";
     
     const OPERATOR_SCOPES_HEADER = "x-openclaw-scopes";
    @@ -16,13 +16,15 @@ export async function authorizeGatewayBearerRequestOrReply(params: {
       rateLimiter?: AuthRateLimiter;
     }): Promise<boolean> {
       const token = getBearerToken(params.req);
    +  const browserOriginPolicy = resolveHttpBrowserOriginPolicy(params.req);
       const authResult = await authorizeHttpGatewayConnect({
         auth: params.auth,
         connectAuth: token ? { token, password: token } : null,
         req: params.req,
         trustedProxies: params.trustedProxies,
         allowRealIpFallback: params.allowRealIpFallback,
         rateLimiter: params.rateLimiter,
    +    browserOriginPolicy,
       });
       if (!authResult.ok) {
         sendGatewayAuthFailure(params.res, authResult);
    
  • src/gateway/http-utils.authorize-request.test.ts+43 0 modified
    @@ -5,6 +5,16 @@ vi.mock("./auth.js", () => ({
       authorizeHttpGatewayConnect: vi.fn(),
     }));
     
    +vi.mock("../config/config.js", () => ({
    +  loadConfig: vi.fn(() => ({
    +    gateway: {
    +      controlUi: {
    +        allowedOrigins: ["https://control.example.com"],
    +      },
    +    },
    +  })),
    +}));
    +
     vi.mock("./http-common.js", () => ({
       sendGatewayAuthFailure: vi.fn(),
     }));
    @@ -66,6 +76,39 @@ describe("authorizeGatewayHttpRequestOrReply", () => {
         });
       });
     
    +  it("forwards browser-origin policy into HTTP auth", async () => {
    +    vi.mocked(authorizeHttpGatewayConnect).mockResolvedValue({
    +      ok: true,
    +      method: "trusted-proxy",
    +      user: "operator",
    +    });
    +
    +    await authorizeGatewayHttpRequestOrReply({
    +      req: createReq({
    +        host: "gateway.example.com",
    +        origin: "https://evil.example",
    +      }),
    +      res: {} as ServerResponse,
    +      auth: {
    +        mode: "trusted-proxy",
    +        allowTailscale: false,
    +        trustedProxy: { userHeader: "x-user" },
    +      },
    +      trustedProxies: ["127.0.0.1"],
    +    });
    +
    +    expect(vi.mocked(authorizeHttpGatewayConnect)).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        browserOriginPolicy: {
    +          requestHost: "gateway.example.com",
    +          origin: "https://evil.example",
    +          allowedOrigins: ["https://control.example.com"],
    +          allowHostHeaderOriginFallback: false,
    +        },
    +      }),
    +    );
    +  });
    +
       it("replies with auth failure and returns null when auth fails", async () => {
         const res = {} as ServerResponse;
         vi.mocked(authorizeHttpGatewayConnect).mockResolvedValue({
    
  • src/gateway/http-utils.ts+15 0 modified
    @@ -49,6 +49,19 @@ export type AuthorizedGatewayHttpRequest = {
       trustDeclaredOperatorScopes: boolean;
     };
     
    +export function resolveHttpBrowserOriginPolicy(
    +  req: IncomingMessage,
    +  cfg = loadConfig(),
    +): NonNullable<Parameters<typeof authorizeHttpGatewayConnect>[0]["browserOriginPolicy"]> {
    +  return {
    +    requestHost: getHeader(req, "host"),
    +    origin: getHeader(req, "origin"),
    +    allowedOrigins: cfg.gateway?.controlUi?.allowedOrigins,
    +    allowHostHeaderOriginFallback:
    +      cfg.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true,
    +  };
    +}
    +
     function usesSharedSecretHttpAuth(auth: SharedSecretGatewayAuth | undefined): boolean {
       return auth?.mode === "token" || auth?.mode === "password";
     }
    @@ -79,13 +92,15 @@ export async function authorizeGatewayHttpRequestOrReply(params: {
       rateLimiter?: AuthRateLimiter;
     }): Promise<AuthorizedGatewayHttpRequest | null> {
       const token = getBearerToken(params.req);
    +  const browserOriginPolicy = resolveHttpBrowserOriginPolicy(params.req);
       const authResult = await authorizeHttpGatewayConnect({
         auth: params.auth,
         connectAuth: token ? { token, password: token } : null,
         req: params.req,
         trustedProxies: params.trustedProxies,
         allowRealIpFallback: params.allowRealIpFallback,
         rateLimiter: params.rateLimiter,
    +    browserOriginPolicy,
       });
       if (!authResult.ok) {
         sendGatewayAuthFailure(params.res, authResult);
    
  • src/gateway/server/http-auth.ts+2 1 modified
    @@ -9,7 +9,7 @@ import {
     } from "../auth.js";
     import { CANVAS_CAPABILITY_TTL_MS } from "../canvas-capability.js";
     import { authorizeGatewayBearerRequestOrReply } from "../http-auth-helpers.js";
    -import { getBearerToken } from "../http-utils.js";
    +import { getBearerToken, resolveHttpBrowserOriginPolicy } from "../http-utils.js";
     import { GATEWAY_CLIENT_MODES, normalizeGatewayClientMode } from "../protocol/client-info.js";
     import type { GatewayWsClient } from "./ws-types.js";
     
    @@ -88,6 +88,7 @@ export async function authorizeCanvasRequest(params: {
           trustedProxies,
           allowRealIpFallback,
           rateLimiter,
    +      browserOriginPolicy: resolveHttpBrowserOriginPolicy(req),
         });
         if (authResult.ok) {
           return authResult;
    
  • src/gateway/server-http.ts+2 1 modified
    @@ -55,7 +55,7 @@ import {
       resolveHookDeliver,
     } from "./hooks.js";
     import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js";
    -import { getBearerToken } from "./http-utils.js";
    +import { getBearerToken, resolveHttpBrowserOriginPolicy } from "./http-utils.js";
     import { handleOpenAiModelsHttpRequest } from "./models-http.js";
     import { resolveRequestClientIp } from "./net.js";
     import { handleOpenAiHttpRequest } from "./openai-http.js";
    @@ -217,6 +217,7 @@ async function canRevealReadinessDetails(params: {
         req: params.req,
         trustedProxies: params.trustedProxies,
         allowRealIpFallback: params.allowRealIpFallback,
    +    browserOriginPolicy: resolveHttpBrowserOriginPolicy(params.req),
       });
       return authResult.ok;
     }
    

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.