fix: address issue

This commit is contained in:
Pavan Kumar Gondhi
2026-04-02 16:48:06 +00:00
committed by Peter Steinberger
parent 4f3ad7c6fc
commit b02b2c3a0b
5 changed files with 198 additions and 25 deletions

View File

@@ -57,17 +57,20 @@ import {
resolveHookDeliver,
} from "./hooks.js";
import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js";
import { getBearerToken, resolveHttpBrowserOriginPolicy } from "./http-utils.js";
import {
authorizeGatewayHttpRequestOrReply,
getBearerToken,
resolveHttpBrowserOriginPolicy,
resolveTrustedHttpOperatorScopes,
type AuthorizedGatewayHttpRequest,
} from "./http-utils.js";
import { WRITE_SCOPE } from "./method-scopes.js";
import { handleOpenAiModelsHttpRequest } from "./models-http.js";
import { resolveRequestClientIp } from "./net.js";
import { handleOpenAiHttpRequest } from "./openai-http.js";
import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
import { DEDUPE_MAX, DEDUPE_TTL_MS } from "./server-constants.js";
import {
authorizeCanvasRequest,
enforcePluginRouteGatewayAuth,
isCanvasPath,
} from "./server/http-auth.js";
import { authorizeCanvasRequest, isCanvasPath } from "./server/http-auth.js";
import {
isProtectedPluginRoutePathFromContext,
resolvePluginRoutePathContext,
@@ -156,6 +159,16 @@ function shouldEnforceDefaultPluginGatewayAuth(pathContext: PluginRoutePathConte
);
}
function resolvePluginRouteRuntimeOperatorScopes(
req: IncomingMessage,
requestAuth: AuthorizedGatewayHttpRequest,
): string[] {
if (requestAuth.trustDeclaredOperatorScopes) {
return resolveTrustedHttpOperatorScopes(req, requestAuth);
}
return [WRITE_SCOPE];
}
async function canRevealReadinessDetails(params: {
req: IncomingMessage;
resolvedAuth: ResolvedGatewayAuth;
@@ -307,6 +320,8 @@ function buildPluginRequestStages(params: {
return [];
}
let pluginGatewayAuthSatisfied = false;
let pluginRequestAuth: AuthorizedGatewayHttpRequest | undefined;
let pluginRequestOperatorScopes: string[] | undefined;
return [
{
name: "plugin-auth",
@@ -323,7 +338,7 @@ function buildPluginRequestStages(params: {
) {
return false;
}
const pluginAuthOk = await enforcePluginRouteGatewayAuth({
const requestAuth = await authorizeGatewayHttpRequestOrReply({
req: params.req,
res: params.res,
auth: params.resolvedAuth,
@@ -331,10 +346,15 @@ function buildPluginRequestStages(params: {
allowRealIpFallback: params.allowRealIpFallback,
rateLimiter: params.rateLimiter,
});
if (!pluginAuthOk) {
if (!requestAuth) {
return true;
}
pluginGatewayAuthSatisfied = true;
pluginRequestAuth = requestAuth;
pluginRequestOperatorScopes = resolvePluginRouteRuntimeOperatorScopes(
params.req,
requestAuth,
);
return false;
},
},
@@ -346,6 +366,8 @@ function buildPluginRequestStages(params: {
return (
params.handlePluginRequest?.(params.req, params.res, pathContext, {
gatewayAuthSatisfied: pluginGatewayAuthSatisfied,
gatewayRequestAuth: pluginRequestAuth,
gatewayRequestOperatorScopes: pluginRequestOperatorScopes,
}) ?? false
);
},

View File

@@ -1,5 +1,7 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { describe, expect, test, vi } from "vitest";
import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js";
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
import { canonicalizePathVariant, isProtectedPluginRoutePath } from "./security-path.js";
import {
AUTH_NONE,
@@ -9,7 +11,10 @@ import {
CANONICAL_UNAUTH_VARIANTS,
createCanonicalizedChannelPluginHandler,
createHooksHandler,
createRequest,
createResponse,
createTestGatewayServer,
dispatchRequest,
expectAuthorizedVariants,
expectUnauthorizedResponse,
expectUnauthorizedVariants,
@@ -17,6 +22,8 @@ import {
withGatewayServer,
withGatewayTempConfig,
} from "./server-http.test-harness.js";
import { createTestRegistry } from "./server/__tests__/test-utils.js";
import { createGatewayPluginRequestHandler } from "./server/plugins-http.js";
import { withTempConfig } from "./test-temp-config.js";
type PluginRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
@@ -240,6 +247,79 @@ describe("gateway plugin HTTP auth boundary", () => {
});
});
test("preserves trusted-proxy read scopes for gateway-auth plugin runtime routes", async () => {
const observedRuntimeScopes: string[][] = [];
const writeAllowedResults: boolean[] = [];
const handlePluginRequest = createGatewayPluginRequestHandler({
registry: createTestRegistry({
httpRoutes: [
{
pluginId: "runtime-scope",
source: "runtime-scope",
path: "/secure-hook",
auth: "gateway",
match: "exact",
handler: async (_req: IncomingMessage, res: ServerResponse) => {
const runtimeScopes =
getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes?.slice() ?? [];
observedRuntimeScopes.push(runtimeScopes);
const writeAuth = authorizeOperatorScopesForMethod("node.invoke", runtimeScopes);
writeAllowedResults.push(writeAuth.allowed);
res.statusCode = 200;
res.end("ok");
return true;
},
},
],
}),
log: { warn: vi.fn() } as Parameters<typeof createGatewayPluginRequestHandler>[0]["log"],
});
await withTempConfig({
cfg: {
gateway: {
trustedProxies: ["203.0.113.10"],
},
},
prefix: "openclaw-plugin-http-runtime-scope-trusted-proxy-test-",
run: async () => {
const server = createTestGatewayServer({
resolvedAuth: {
mode: "trusted-proxy",
allowTailscale: false,
trustedProxy: { userHeader: "x-forwarded-user" },
},
overrides: {
handlePluginRequest,
shouldEnforcePluginGatewayAuth: (pathContext) =>
pathContext.pathname === "/secure-hook",
},
});
const response = createResponse();
await dispatchRequest(
server,
createRequest({
path: "/secure-hook",
remoteAddress: "203.0.113.10",
headers: {
"x-forwarded-user": "operator",
"x-forwarded-for": "198.51.100.20",
"x-openclaw-scopes": "operator.read",
},
}),
response.res,
);
expect(response.res.statusCode).toBe(200);
expect(response.getBody()).toBe("ok");
},
});
expect(observedRuntimeScopes).toEqual([["operator.read"]]);
expect(writeAllowedResults).toEqual([false]);
});
test("allows unauthenticated Mattermost slash callback routes while keeping other channel routes protected", async () => {
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;

View File

@@ -63,6 +63,7 @@ describe("plugin HTTP route runtime scopes", () => {
path: string;
auth: "gateway" | "plugin";
gatewayAuthSatisfied: boolean;
gatewayRequestOperatorScopes?: readonly string[];
}) {
const log = createMockLogger();
const handler = createGatewayPluginRequestHandler({
@@ -86,7 +87,10 @@ describe("plugin HTTP route runtime scopes", () => {
{ url: params.path } as IncomingMessage,
response.res,
undefined,
{ gatewayAuthSatisfied: params.gatewayAuthSatisfied },
{
gatewayAuthSatisfied: params.gatewayAuthSatisfied,
gatewayRequestOperatorScopes: params.gatewayRequestOperatorScopes,
},
);
return { handled, log, ...response };
}
@@ -110,6 +114,7 @@ describe("plugin HTTP route runtime scopes", () => {
path: "/secure-hook",
auth: "gateway",
gatewayAuthSatisfied: true,
gatewayRequestOperatorScopes: ["operator.write"],
});
expect(handled).toBe(true);
@@ -117,22 +122,53 @@ describe("plugin HTTP route runtime scopes", () => {
expect(log.warn).not.toHaveBeenCalled();
});
it("fails closed when gateway-auth route runtime scopes are missing", async () => {
const { handled, res, log } = await invokeRoute({
path: "/secure-hook",
auth: "gateway",
gatewayAuthSatisfied: true,
});
expect(handled).toBe(false);
expect(res.statusCode).toBe(200);
expect(log.warn).toHaveBeenCalledWith(
expect.stringContaining("blocked without caller scope context"),
);
});
it("does not allow write helpers for read-scoped gateway-auth requests", async () => {
const { handled, res, setHeader, end, log } = await invokeRoute({
path: "/secure-hook",
auth: "gateway",
gatewayAuthSatisfied: true,
gatewayRequestOperatorScopes: ["operator.read"],
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(500);
expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/plain; charset=utf-8");
expect(end).toHaveBeenCalledWith("Internal Server Error");
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("missing scope: operator.write"));
});
it.each([
{
auth: "plugin" as const,
gatewayAuthSatisfied: false,
path: "/hook",
gatewayRequestOperatorScopes: undefined,
expectedScopes: [],
},
{
auth: "gateway" as const,
gatewayAuthSatisfied: true,
path: "/secure-hook",
expectedScopes: ["operator.write"],
gatewayRequestOperatorScopes: ["operator.read"],
expectedScopes: ["operator.read"],
},
])(
"maps $auth routes to $expectedScopes",
async ({ auth, gatewayAuthSatisfied, path, expectedScopes }) => {
async ({ auth, gatewayAuthSatisfied, gatewayRequestOperatorScopes, path, expectedScopes }) => {
let observedScopes: string[] | undefined;
const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry({
@@ -154,6 +190,7 @@ describe("plugin HTTP route runtime scopes", () => {
const { res } = makeMockHttpResponse();
const handled = await handler({ url: path } as IncomingMessage, res, undefined, {
gatewayAuthSatisfied,
gatewayRequestOperatorScopes,
});
expect(handled).toBe(true);

View File

@@ -32,7 +32,7 @@ function createRoute(params: {
return {
pluginId: params.pluginId ?? "route",
path: params.path,
auth: params.auth ?? "gateway",
auth: params.auth ?? "plugin",
match: params.match ?? "exact",
handler: params.handler ?? (() => {}),
source: params.pluginId ?? "route",
@@ -72,7 +72,10 @@ function createSecurePluginRouteHandler(params: {
});
}
async function invokeSecureGatewayRoute(params: { gatewayAuthSatisfied: boolean }) {
async function invokeSecureGatewayRoute(params: {
gatewayAuthSatisfied: boolean;
gatewayRequestOperatorScopes?: readonly string[];
}) {
const exactPluginHandler = vi.fn(async () => false);
const prefixGatewayHandler = vi.fn(async () => true);
const handler = createSecurePluginRouteHandler({
@@ -84,7 +87,10 @@ async function invokeSecureGatewayRoute(params: { gatewayAuthSatisfied: boolean
{ url: "/plugin/secure/report" } as IncomingMessage,
res,
undefined,
{ gatewayAuthSatisfied: params.gatewayAuthSatisfied },
{
gatewayAuthSatisfied: params.gatewayAuthSatisfied,
gatewayRequestOperatorScopes: params.gatewayRequestOperatorScopes,
},
);
return { handled, exactPluginHandler, prefixGatewayHandler };
}
@@ -93,6 +99,7 @@ async function invokeRouteAndCollectRuntimeScopes(params: {
path: string;
auth: "gateway" | "plugin";
gatewayAuthSatisfied: boolean;
gatewayRequestOperatorScopes?: readonly string[];
}) {
let observedScopes: string[] | undefined;
const handler = createGatewayPluginRequestHandler({
@@ -115,6 +122,7 @@ async function invokeRouteAndCollectRuntimeScopes(params: {
const response = makeMockHttpResponse();
const handled = await handler({ url: params.path } as IncomingMessage, response.res, undefined, {
gatewayAuthSatisfied: params.gatewayAuthSatisfied,
gatewayRequestOperatorScopes: params.gatewayRequestOperatorScopes,
});
return { handled, observedScopes, ...response };
}
@@ -137,16 +145,17 @@ describe("createGatewayPluginRequestHandler", () => {
expect(observedScopes).toEqual([]);
});
it("keeps gateway-authenticated plugin routes on write runtime scopes", async () => {
it("preserves gateway-authenticated plugin route runtime scopes from request auth", async () => {
const { handled, observedScopes, res } = await invokeRouteAndCollectRuntimeScopes({
path: "/secure-hook",
auth: "gateway",
gatewayAuthSatisfied: true,
gatewayRequestOperatorScopes: ["operator.read"],
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(observedScopes).toEqual(["operator.write"]);
expect(observedScopes).toEqual(["operator.read"]);
});
it("returns false when no routes are registered", async () => {
@@ -231,12 +240,22 @@ describe("createGatewayPluginRequestHandler", () => {
it("allows gateway route fallthrough only after gateway auth succeeds", async () => {
const { handled, exactPluginHandler, prefixGatewayHandler } = await invokeSecureGatewayRoute({
gatewayAuthSatisfied: true,
gatewayRequestOperatorScopes: ["operator.write"],
});
expect(handled).toBe(true);
expect(exactPluginHandler).toHaveBeenCalledTimes(1);
expect(prefixGatewayHandler).toHaveBeenCalledTimes(1);
});
it("fails closed when gateway route dispatch lacks caller scopes", async () => {
const { handled, exactPluginHandler, prefixGatewayHandler } = await invokeSecureGatewayRoute({
gatewayAuthSatisfied: true,
});
expect(handled).toBe(false);
expect(exactPluginHandler).not.toHaveBeenCalled();
expect(prefixGatewayHandler).not.toHaveBeenCalled();
});
it("matches canonicalized route variants", async () => {
const routeHandler = vi.fn(async (_req, res: ServerResponse) => {
res.statusCode = 200;

View File

@@ -3,7 +3,7 @@ import type { createSubsystemLogger } from "../../logging/subsystem.js";
import type { PluginRegistry } from "../../plugins/registry.js";
import { resolveActivePluginHttpRouteRegistry } from "../../plugins/runtime.js";
import { withPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gateway-request-scope.js";
import { WRITE_SCOPE } from "../method-scopes.js";
import type { AuthorizedGatewayHttpRequest } from "../http-utils.js";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../protocol/client-info.js";
import { PROTOCOL_VERSION } from "../protocol/index.js";
import type { GatewayRequestOptions } from "../server-methods/types.js";
@@ -28,9 +28,8 @@ export { shouldEnforceGatewayAuthForPluginPath } from "./plugins-http/route-auth
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
function createPluginRouteRuntimeClient(
requiresGatewayAuth: boolean,
scopes: readonly string[],
): GatewayRequestOptions["client"] {
const scopes = requiresGatewayAuth ? [WRITE_SCOPE] : [];
return {
connect: {
minProtocol: PROTOCOL_VERSION,
@@ -42,16 +41,22 @@ function createPluginRouteRuntimeClient(
mode: GATEWAY_CLIENT_MODES.BACKEND,
},
role: "operator",
scopes,
scopes: [...scopes],
},
};
}
export type PluginRouteDispatchContext = {
gatewayAuthSatisfied?: boolean;
gatewayRequestAuth?: AuthorizedGatewayHttpRequest;
gatewayRequestOperatorScopes?: readonly string[];
};
export type PluginHttpRequestHandler = (
req: IncomingMessage,
res: ServerResponse,
pathContext?: PluginRoutePathContext,
dispatchContext?: { gatewayAuthSatisfied?: boolean },
dispatchContext?: PluginRouteDispatchContext,
) => Promise<boolean>;
export function createGatewayPluginRequestHandler(params: {
@@ -77,11 +82,21 @@ export function createGatewayPluginRequestHandler(params: {
return false;
}
const requiresGatewayAuth = matchedPluginRoutesRequireGatewayAuth(matchedRoutes);
if (requiresGatewayAuth && dispatchContext?.gatewayAuthSatisfied === false) {
log.warn(`plugin http route blocked without gateway auth (${pathContext.canonicalPath})`);
return false;
let runtimeScopes: readonly string[] = [];
if (requiresGatewayAuth) {
if (dispatchContext?.gatewayAuthSatisfied !== true) {
log.warn(`plugin http route blocked without gateway auth (${pathContext.canonicalPath})`);
return false;
}
if (dispatchContext.gatewayRequestOperatorScopes === undefined) {
log.warn(
`plugin http route blocked without caller scope context (${pathContext.canonicalPath})`,
);
return false;
}
runtimeScopes = dispatchContext.gatewayRequestOperatorScopes;
}
const runtimeClient = createPluginRouteRuntimeClient(requiresGatewayAuth);
const runtimeClient = createPluginRouteRuntimeClient(runtimeScopes);
return await withPluginRuntimeGatewayRequestScope(
{