mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-05-01 06:36:23 +08:00
fix: address issue
This commit is contained in:
committed by
Peter Steinberger
parent
4f3ad7c6fc
commit
b02b2c3a0b
@@ -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
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user