mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 11:24:47 +00:00
feat: add bundled admin HTTP RPC plugin
This commit is contained in:
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
@@ -244,6 +244,10 @@
|
||||
- "docs/gateway/security.md"
|
||||
- "security/**"
|
||||
|
||||
"extensions: admin-http-rpc":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/admin-http-rpc/**"
|
||||
"extensions: copilot-proxy":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
32
extensions/admin-http-rpc/index.test.ts
Normal file
32
extensions/admin-http-rpc/index.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
|
||||
describe("admin-http-rpc plugin entry", () => {
|
||||
it("stays startup-off until the plugin entry is explicitly enabled", () => {
|
||||
expect(manifest.activation).toEqual({
|
||||
onStartup: false,
|
||||
onConfigPaths: ["plugins.entries.admin-http-rpc"],
|
||||
});
|
||||
expect(manifest.contracts).toEqual({
|
||||
gatewayMethodDispatch: ["authenticated-request"],
|
||||
});
|
||||
});
|
||||
|
||||
it("registers one trusted gateway HTTP route", () => {
|
||||
const routes: Array<Record<string, unknown>> = [];
|
||||
plugin.register({
|
||||
registerHttpRoute(route) {
|
||||
routes.push(route as unknown as Record<string, unknown>);
|
||||
},
|
||||
} as Parameters<typeof plugin.register>[0]);
|
||||
|
||||
expect(routes).toHaveLength(1);
|
||||
expect(routes[0]).toMatchObject({
|
||||
path: "/api/v1/admin/rpc",
|
||||
auth: "gateway",
|
||||
match: "exact",
|
||||
gatewayRuntimeScopeSurface: "trusted-operator",
|
||||
});
|
||||
});
|
||||
});
|
||||
17
extensions/admin-http-rpc/index.ts
Normal file
17
extensions/admin-http-rpc/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { handleAdminHttpRpcRequest } from "./src/handler.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "admin-http-rpc",
|
||||
name: "Admin HTTP RPC",
|
||||
description: "Expose selected Gateway admin RPC methods over HTTP",
|
||||
register(api) {
|
||||
api.registerHttpRoute({
|
||||
path: "/api/v1/admin/rpc",
|
||||
auth: "gateway",
|
||||
match: "exact",
|
||||
gatewayRuntimeScopeSurface: "trusted-operator",
|
||||
handler: handleAdminHttpRpcRequest,
|
||||
});
|
||||
},
|
||||
});
|
||||
15
extensions/admin-http-rpc/openclaw.plugin.json
Normal file
15
extensions/admin-http-rpc/openclaw.plugin.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "admin-http-rpc",
|
||||
"activation": {
|
||||
"onStartup": false,
|
||||
"onConfigPaths": ["plugins.entries.admin-http-rpc"]
|
||||
},
|
||||
"contracts": {
|
||||
"gatewayMethodDispatch": ["authenticated-request"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
15
extensions/admin-http-rpc/package.json
Normal file
15
extensions/admin-http-rpc/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@openclaw/admin-http-rpc",
|
||||
"version": "2026.5.14",
|
||||
"private": true,
|
||||
"description": "OpenClaw admin HTTP RPC endpoint",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
160
extensions/admin-http-rpc/src/handler.test.ts
Normal file
160
extensions/admin-http-rpc/src/handler.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Readable } from "node:stream";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { handleAdminHttpRpcRequest } from "./handler.js";
|
||||
import { listAdminHttpRpcAllowedMethods } from "./methods.js";
|
||||
|
||||
const { dispatchGatewayMethod } = vi.hoisted(() => ({
|
||||
dispatchGatewayMethod: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/gateway-method-runtime", () => ({
|
||||
dispatchGatewayMethod,
|
||||
}));
|
||||
|
||||
type CapturedResponse = {
|
||||
statusCode: number;
|
||||
headers: Record<string, string | number | readonly string[]>;
|
||||
body: string;
|
||||
};
|
||||
|
||||
function createRequest(body: unknown, method = "POST") {
|
||||
const req = Readable.from([typeof body === "string" ? body : JSON.stringify(body)]);
|
||||
Object.assign(req, {
|
||||
method,
|
||||
url: "/api/v1/admin/rpc",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
return req as import("node:http").IncomingMessage;
|
||||
}
|
||||
|
||||
function createResponse() {
|
||||
const captured: CapturedResponse = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
body: "",
|
||||
};
|
||||
const res = {
|
||||
get statusCode() {
|
||||
return captured.statusCode;
|
||||
},
|
||||
set statusCode(value: number) {
|
||||
captured.statusCode = value;
|
||||
},
|
||||
setHeader(name: string, value: string | number | readonly string[]) {
|
||||
captured.headers[name.toLowerCase()] = value;
|
||||
},
|
||||
end(chunk?: string | Buffer) {
|
||||
captured.body = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : (chunk ?? "");
|
||||
},
|
||||
} as import("node:http").ServerResponse;
|
||||
return { res, captured };
|
||||
}
|
||||
|
||||
async function invoke(body: unknown, method = "POST") {
|
||||
const { res, captured } = createResponse();
|
||||
const handled = await handleAdminHttpRpcRequest(createRequest(body, method), res);
|
||||
return {
|
||||
handled,
|
||||
captured,
|
||||
json: captured.body ? (JSON.parse(captured.body) as unknown) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe("admin-http-rpc plugin handler", () => {
|
||||
beforeEach(() => {
|
||||
dispatchGatewayMethod.mockReset();
|
||||
});
|
||||
|
||||
it("returns the allowlist without dispatching through the Gateway", async () => {
|
||||
const result = await invoke({ id: "1", method: "commands.list" });
|
||||
|
||||
expect(result.handled).toBe(true);
|
||||
expect(result.captured.statusCode).toBe(200);
|
||||
expect(result.json).toEqual({
|
||||
id: "1",
|
||||
ok: true,
|
||||
payload: {
|
||||
methods: listAdminHttpRpcAllowedMethods(),
|
||||
},
|
||||
});
|
||||
expect(dispatchGatewayMethod).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dispatches allowed methods through the authenticated plugin request scope", async () => {
|
||||
dispatchGatewayMethod.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: { status: "ok" },
|
||||
meta: { requestId: "abc" },
|
||||
});
|
||||
|
||||
const result = await invoke({
|
||||
id: "cfg",
|
||||
method: "config.get",
|
||||
params: { path: "gateway" },
|
||||
});
|
||||
|
||||
expect(dispatchGatewayMethod).toHaveBeenCalledWith("config.get", { path: "gateway" });
|
||||
expect(result.captured.statusCode).toBe(200);
|
||||
expect(result.json).toEqual({
|
||||
id: "cfg",
|
||||
ok: true,
|
||||
payload: { status: "ok" },
|
||||
meta: { requestId: "abc" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects methods outside the admin HTTP RPC allowlist", async () => {
|
||||
const result = await invoke({ id: "bad", method: "sessions.send" });
|
||||
|
||||
expect(dispatchGatewayMethod).not.toHaveBeenCalled();
|
||||
expect(result.captured.statusCode).toBe(400);
|
||||
expect(result.json).toEqual({
|
||||
id: "bad",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "admin HTTP RPC method is not supported: sessions.send",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps Gateway errors to HTTP status codes", async () => {
|
||||
dispatchGatewayMethod.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: "NOT_PAIRED", message: "pair first" },
|
||||
});
|
||||
|
||||
const result = await invoke({ id: "node", method: "node.list" });
|
||||
|
||||
expect(result.captured.statusCode).toBe(409);
|
||||
expect(result.json).toEqual({
|
||||
id: "node",
|
||||
ok: false,
|
||||
error: { code: "NOT_PAIRED", message: "pair first" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid request bodies before dispatch", async () => {
|
||||
const result = await invoke({ id: "missing" });
|
||||
|
||||
expect(result.captured.statusCode).toBe(400);
|
||||
expect(result.json).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
type: "invalid_request",
|
||||
message: "method must be a non-empty string",
|
||||
},
|
||||
});
|
||||
expect(dispatchGatewayMethod).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only accepts POST", async () => {
|
||||
const result = await invoke({ method: "status" }, "GET");
|
||||
|
||||
expect(result.captured.statusCode).toBe(405);
|
||||
expect(result.captured.headers.allow).toBe("POST");
|
||||
expect(dispatchGatewayMethod).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
238
extensions/admin-http-rpc/src/handler.ts
Normal file
238
extensions/admin-http-rpc/src/handler.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { dispatchGatewayMethod } from "openclaw/plugin-sdk/gateway-method-runtime";
|
||||
import { isAdminHttpRpcAllowedMethod, listAdminHttpRpcAllowedMethods } from "./methods.js";
|
||||
|
||||
const DEFAULT_RPC_BODY_BYTES = 1024 * 1024;
|
||||
|
||||
const ErrorCodes = {
|
||||
AGENT_TIMEOUT: "AGENT_TIMEOUT",
|
||||
APPROVAL_NOT_FOUND: "APPROVAL_NOT_FOUND",
|
||||
INVALID_REQUEST: "INVALID_REQUEST",
|
||||
NOT_LINKED: "NOT_LINKED",
|
||||
NOT_PAIRED: "NOT_PAIRED",
|
||||
UNAVAILABLE: "UNAVAILABLE",
|
||||
} as const;
|
||||
|
||||
type RpcBody = {
|
||||
id?: unknown;
|
||||
method?: unknown;
|
||||
params?: unknown;
|
||||
};
|
||||
|
||||
type RpcError = {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
retryable?: boolean;
|
||||
retryAfterMs?: number;
|
||||
};
|
||||
|
||||
type RpcResponse =
|
||||
| { id: string; ok: true; payload: unknown; meta?: Record<string, unknown> }
|
||||
| { id: string; ok: false; error: RpcError; meta?: Record<string, unknown> };
|
||||
|
||||
type ParsedRequest = {
|
||||
id: string;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function createError(code: string, message: string): RpcError {
|
||||
return { code, message };
|
||||
}
|
||||
|
||||
function rpcHttpStatus(response: RpcResponse): number {
|
||||
if (response.ok) {
|
||||
return 200;
|
||||
}
|
||||
switch (response.error.code) {
|
||||
case ErrorCodes.INVALID_REQUEST:
|
||||
return 400;
|
||||
case ErrorCodes.APPROVAL_NOT_FOUND:
|
||||
return 404;
|
||||
case ErrorCodes.UNAVAILABLE:
|
||||
return 503;
|
||||
case ErrorCodes.AGENT_TIMEOUT:
|
||||
return 504;
|
||||
case ErrorCodes.NOT_LINKED:
|
||||
case ErrorCodes.NOT_PAIRED:
|
||||
return 409;
|
||||
default:
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
res.statusCode = status;
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function sendError(res: ServerResponse, status: number, error: { type: string; message: string }) {
|
||||
sendJson(res, status, { ok: false, error });
|
||||
}
|
||||
|
||||
async function readJsonBody(
|
||||
req: IncomingMessage,
|
||||
maxBytes: number,
|
||||
): Promise<{ ok: true; value: unknown } | { ok: false; status: number; message: string }> {
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
try {
|
||||
for await (const chunk of req) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
totalBytes += buffer.byteLength;
|
||||
if (totalBytes > maxBytes) {
|
||||
return { ok: false, status: 413, message: "Payload too large" };
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, status: 400, message: "failed to read request body" };
|
||||
}
|
||||
|
||||
const raw = Buffer.concat(chunks).toString("utf8");
|
||||
if (!raw.trim()) {
|
||||
return { ok: false, status: 400, message: "request body must be JSON" };
|
||||
}
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(raw) };
|
||||
} catch {
|
||||
return { ok: false, status: 400, message: "request body must be valid JSON" };
|
||||
}
|
||||
}
|
||||
|
||||
function readRpcRequestBody(body: unknown):
|
||||
| { ok: true; request: ParsedRequest }
|
||||
| {
|
||||
ok: false;
|
||||
message: string;
|
||||
} {
|
||||
if (!isRecord(body)) {
|
||||
return { ok: false, message: "request body must be an object" };
|
||||
}
|
||||
const rpcBody = body as RpcBody;
|
||||
if (typeof rpcBody.method !== "string" || rpcBody.method.trim().length === 0) {
|
||||
return { ok: false, message: "method must be a non-empty string" };
|
||||
}
|
||||
const id =
|
||||
typeof rpcBody.id === "string" && rpcBody.id.trim().length > 0
|
||||
? rpcBody.id.trim()
|
||||
: randomUUID();
|
||||
return {
|
||||
ok: true,
|
||||
request: {
|
||||
id,
|
||||
method: rpcBody.method.trim(),
|
||||
...(Object.prototype.hasOwnProperty.call(rpcBody, "params")
|
||||
? { params: rpcBody.params }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function methodNotAllowed(id: string, method: string): RpcResponse {
|
||||
return {
|
||||
id,
|
||||
ok: false,
|
||||
error: createError(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`admin HTTP RPC method is not supported: ${method}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function commandsList(id: string): RpcResponse {
|
||||
return {
|
||||
id,
|
||||
ok: true,
|
||||
payload: {
|
||||
methods: listAdminHttpRpcAllowedMethods(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function dispatchAdminRpc(request: ParsedRequest): Promise<RpcResponse> {
|
||||
try {
|
||||
const response = await dispatchGatewayMethod(request.method, request.params);
|
||||
if (response.ok) {
|
||||
return {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
payload: response.payload,
|
||||
...(response.meta ? { meta: response.meta } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error:
|
||||
response.error ??
|
||||
createError(ErrorCodes.UNAVAILABLE, "gateway method failed before returning a response"),
|
||||
...(response.meta ? { meta: response.meta } : {}),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: createError(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
"gateway method failed before returning a response",
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleAdminHttpRpcRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<boolean> {
|
||||
if ((req.method ?? "GET").toUpperCase() !== "POST") {
|
||||
res.setHeader("Allow", "POST");
|
||||
sendError(res, 405, {
|
||||
type: "method_not_allowed",
|
||||
message: "Method Not Allowed",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const body = await readJsonBody(req, DEFAULT_RPC_BODY_BYTES);
|
||||
if (!body.ok) {
|
||||
sendError(res, body.status, {
|
||||
type: "invalid_request",
|
||||
message: body.message,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const parsed = readRpcRequestBody(body.value);
|
||||
if (!parsed.ok) {
|
||||
sendError(res, 400, {
|
||||
type: "invalid_request",
|
||||
message: parsed.message,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isAdminHttpRpcAllowedMethod(parsed.request.method)) {
|
||||
const response = methodNotAllowed(parsed.request.id, parsed.request.method);
|
||||
sendJson(res, rpcHttpStatus(response), response);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parsed.request.method === "commands.list") {
|
||||
const response = commandsList(parsed.request.id);
|
||||
sendJson(res, 200, response);
|
||||
return true;
|
||||
}
|
||||
|
||||
const response = await dispatchAdminRpc(parsed.request);
|
||||
sendJson(res, rpcHttpStatus(response), response);
|
||||
return true;
|
||||
}
|
||||
62
extensions/admin-http-rpc/src/methods.ts
Normal file
62
extensions/admin-http-rpc/src/methods.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
const ADMIN_HTTP_RPC_ALLOWED_METHOD_GROUPS = {
|
||||
gateway: [
|
||||
"health",
|
||||
"status",
|
||||
"logs.tail",
|
||||
"usage.status",
|
||||
"usage.cost",
|
||||
"gateway.restart.request",
|
||||
],
|
||||
discovery: ["commands.list"],
|
||||
config: [
|
||||
"config.get",
|
||||
"config.schema",
|
||||
"config.schema.lookup",
|
||||
"config.set",
|
||||
"config.patch",
|
||||
"config.apply",
|
||||
],
|
||||
channels: ["channels.status", "channels.start", "channels.stop", "channels.logout"],
|
||||
models: ["models.list", "models.authStatus"],
|
||||
agents: ["agents.list", "agents.create", "agents.update", "agents.delete"],
|
||||
approvals: [
|
||||
"exec.approvals.get",
|
||||
"exec.approvals.set",
|
||||
"exec.approvals.node.get",
|
||||
"exec.approvals.node.set",
|
||||
],
|
||||
cron: [
|
||||
"cron.status",
|
||||
"cron.list",
|
||||
"cron.get",
|
||||
"cron.runs",
|
||||
"cron.add",
|
||||
"cron.update",
|
||||
"cron.remove",
|
||||
"cron.run",
|
||||
],
|
||||
devices: ["device.pair.list", "device.pair.approve", "device.pair.reject", "device.pair.remove"],
|
||||
nodes: [
|
||||
"node.list",
|
||||
"node.describe",
|
||||
"node.pair.list",
|
||||
"node.pair.approve",
|
||||
"node.pair.reject",
|
||||
"node.pair.remove",
|
||||
"node.rename",
|
||||
],
|
||||
tasks: ["tasks.list", "tasks.get", "tasks.cancel"],
|
||||
diagnostics: ["doctor.memory.status", "update.status"],
|
||||
} as const satisfies Record<string, readonly string[]>;
|
||||
|
||||
const ADMIN_HTTP_RPC_ALLOWED_METHODS: ReadonlySet<string> = new Set(
|
||||
Object.values(ADMIN_HTTP_RPC_ALLOWED_METHOD_GROUPS).flat(),
|
||||
);
|
||||
|
||||
export function isAdminHttpRpcAllowedMethod(method: string): boolean {
|
||||
return ADMIN_HTTP_RPC_ALLOWED_METHODS.has(method);
|
||||
}
|
||||
|
||||
export function listAdminHttpRpcAllowedMethods(): string[] {
|
||||
return Array.from(ADMIN_HTTP_RPC_ALLOWED_METHODS);
|
||||
}
|
||||
16
extensions/admin-http-rpc/tsconfig.json
Normal file
16
extensions/admin-http-rpc/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -288,6 +288,12 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
|
||||
extensions/admin-http-rpc:
|
||||
devDependencies:
|
||||
'@openclaw/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
|
||||
extensions/alibaba:
|
||||
devDependencies:
|
||||
'@openclaw/plugin-sdk':
|
||||
|
||||
@@ -150,7 +150,11 @@ export async function authorizeScopedGatewayHttpRequestOrReply(params: {
|
||||
req: IncomingMessage,
|
||||
requestAuth: AuthorizedGatewayHttpRequest,
|
||||
) => string[];
|
||||
}): Promise<{ cfg: OpenClawConfig; requestAuth: AuthorizedGatewayHttpRequest } | null> {
|
||||
}): Promise<{
|
||||
cfg: OpenClawConfig;
|
||||
requestAuth: AuthorizedGatewayHttpRequest;
|
||||
operatorScopes: string[];
|
||||
} | null> {
|
||||
const cfg = getRuntimeConfig();
|
||||
const requestAuth = await authorizeGatewayHttpRequestOrReply({
|
||||
req: params.req,
|
||||
@@ -164,14 +168,14 @@ export async function authorizeScopedGatewayHttpRequestOrReply(params: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestedScopes = params.resolveOperatorScopes(params.req, requestAuth);
|
||||
const scopeAuth = authorizeOperatorScopesForMethod(params.operatorMethod, requestedScopes);
|
||||
const operatorScopes = params.resolveOperatorScopes(params.req, requestAuth);
|
||||
const scopeAuth = authorizeOperatorScopesForMethod(params.operatorMethod, operatorScopes);
|
||||
if (!scopeAuth.allowed) {
|
||||
sendMissingScopeForbidden(params.res, scopeAuth.missingScope);
|
||||
return null;
|
||||
}
|
||||
|
||||
return { cfg, requestAuth };
|
||||
return { cfg, requestAuth, operatorScopes };
|
||||
}
|
||||
|
||||
export function isGatewayBearerHttpRequest(
|
||||
@@ -212,10 +216,17 @@ export function resolveTrustedHttpOperatorScopes(
|
||||
export function resolveOpenAiCompatibleHttpOperatorScopes(
|
||||
req: IncomingMessage,
|
||||
requestAuth: AuthorizedGatewayHttpRequest,
|
||||
): string[] {
|
||||
return resolveSharedSecretHttpOperatorScopes(req, requestAuth);
|
||||
}
|
||||
|
||||
export function resolveSharedSecretHttpOperatorScopes(
|
||||
req: IncomingMessage,
|
||||
requestAuth: AuthorizedGatewayHttpRequest,
|
||||
): string[] {
|
||||
if (usesSharedSecretGatewayMethod(requestAuth.authMethod)) {
|
||||
// Shared-secret HTTP bearer auth is a documented trusted-operator surface
|
||||
// for the compat APIs and direct /tools/invoke. This is designed-as-is:
|
||||
// for direct HTTP surfaces that opt into it. This is designed-as-is:
|
||||
// token/password auth proves possession of the gateway operator secret, not
|
||||
// a narrower per-request scope identity, so restore the normal defaults.
|
||||
return [...CLI_DEFAULT_OPERATOR_SCOPES];
|
||||
|
||||
@@ -24,6 +24,7 @@ export {
|
||||
resolveHttpSenderIsOwner,
|
||||
resolveOpenAiCompatibleHttpOperatorScopes,
|
||||
resolveOpenAiCompatibleHttpSenderIsOwner,
|
||||
resolveSharedSecretHttpOperatorScopes,
|
||||
resolveTrustedHttpOperatorScopes,
|
||||
type AuthorizedGatewayHttpRequest,
|
||||
type GatewayHttpRequestAuthCheckResult,
|
||||
|
||||
@@ -652,10 +652,12 @@ export function collectGatewayHttpNoAuthFindings(
|
||||
|
||||
const chatCompletionsEnabled = cfg.gateway?.http?.endpoints?.chatCompletions?.enabled === true;
|
||||
const responsesEnabled = cfg.gateway?.http?.endpoints?.responses?.enabled === true;
|
||||
const adminHttpRpcEnabled = cfg.plugins?.entries?.["admin-http-rpc"]?.enabled === true;
|
||||
const enabledEndpoints = [
|
||||
"/tools/invoke",
|
||||
chatCompletionsEnabled ? "/v1/chat/completions" : null,
|
||||
responsesEnabled ? "/v1/responses" : null,
|
||||
adminHttpRpcEnabled ? "/api/v1/admin/rpc" : null,
|
||||
].filter((entry): entry is string => Boolean(entry));
|
||||
|
||||
const remoteExposure = isGatewayRemotelyExposed(cfg);
|
||||
@@ -667,7 +669,7 @@ export function collectGatewayHttpNoAuthFindings(
|
||||
`gateway.auth.mode="none" leaves ${enabledEndpoints.join(", ")} callable without a shared secret. ` +
|
||||
"Treat this as trusted-local only and avoid exposing the gateway beyond loopback.",
|
||||
remediation:
|
||||
"Set gateway.auth.mode to token/password (recommended). If you intentionally keep mode=none, keep gateway.bind=loopback and disable optional HTTP endpoints.",
|
||||
"Set gateway.auth.mode to token/password (recommended). If you intentionally keep mode=none, keep gateway.bind=loopback and disable optional HTTP endpoints/plugins.",
|
||||
});
|
||||
|
||||
return findings;
|
||||
|
||||
@@ -39,8 +39,10 @@ describe("security audit gateway HTTP auth findings", () => {
|
||||
auth: { mode: "none" },
|
||||
http: { endpoints: { responses: { enabled: true } } },
|
||||
},
|
||||
plugins: { entries: { "admin-http-rpc": { enabled: true } } },
|
||||
} satisfies OpenClawConfig,
|
||||
expectedFinding: { checkId: "gateway.http.no_auth", severity: "critical" as const },
|
||||
detailIncludes: ["/api/v1/admin/rpc"],
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user