fix(gateway): protect /api/channels root auth boundary (#25753) (thanks @bmendonca3)

This commit is contained in:
Peter Steinberger
2026-02-24 23:44:10 +00:00
parent b5c07d7228
commit db70187c7c
3 changed files with 42 additions and 2 deletions

View File

@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/Security: enforce gateway auth for the exact `/api/channels` plugin root path (plus `/api/channels/` descendants), with regression coverage for query/trailing-slash variants and near-miss paths that must remain plugin-owned. (#25753) Thanks @bmendonca3.
- Security/Workspace FS: normalize `@`-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/Exec approvals: bind `system.run` command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only `rawCommand` mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. This ships in the next npm release. Thanks @tdjackey for reporting.

View File

@@ -88,6 +88,10 @@ function isCanvasPath(pathname: string): boolean {
);
}
function isAuthProtectedChannelPluginPath(pathname: string): boolean {
return pathname === "/api/channels" || pathname.startsWith("/api/channels/");
}
function isNodeWsClient(client: GatewayWsClient): boolean {
if (client.connect.role === "node") {
return true;
@@ -491,7 +495,7 @@ export function createGatewayHttpServer(opts: {
// Channel HTTP endpoints are gateway-auth protected by default.
// Non-channel plugin routes remain plugin-owned and must enforce
// their own auth when exposing sensitive functionality.
if (requestPath === "/api/channels" || requestPath.startsWith("/api/channels/")) {
if (isAuthProtectedChannelPluginPath(requestPath)) {
const token = getBearerToken(req);
const authResult = await authorizeHttpGatewayConnect({
auth: resolvedAuth,

View File

@@ -142,6 +142,12 @@ describe("gateway plugin HTTP auth boundary", () => {
run: async () => {
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
if (pathname === "/api/channels-health") {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify({ ok: true, route: "channel-health" }));
return true;
}
if (pathname === "/api/channels") {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json; charset=utf-8");
@@ -195,6 +201,26 @@ describe("gateway plugin HTTP auth boundary", () => {
expect(unauthenticatedRoot.getBody()).toContain("Unauthorized");
expect(handlePluginRequest).not.toHaveBeenCalled();
const unauthenticatedRootWithQuery = createResponse();
await dispatchRequest(
server,
createRequest({ path: "/api/channels?view=compact" }),
unauthenticatedRootWithQuery.res,
);
expect(unauthenticatedRootWithQuery.res.statusCode).toBe(401);
expect(unauthenticatedRootWithQuery.getBody()).toContain("Unauthorized");
expect(handlePluginRequest).not.toHaveBeenCalled();
const unauthenticatedRootWithTrailingSlash = createResponse();
await dispatchRequest(
server,
createRequest({ path: "/api/channels/" }),
unauthenticatedRootWithTrailingSlash.res,
);
expect(unauthenticatedRootWithTrailingSlash.res.statusCode).toBe(401);
expect(unauthenticatedRootWithTrailingSlash.getBody()).toContain("Unauthorized");
expect(handlePluginRequest).not.toHaveBeenCalled();
const authenticated = createResponse();
await dispatchRequest(
server,
@@ -216,7 +242,16 @@ describe("gateway plugin HTTP auth boundary", () => {
expect(unauthenticatedPublic.res.statusCode).toBe(200);
expect(unauthenticatedPublic.getBody()).toContain('"route":"public"');
expect(handlePluginRequest).toHaveBeenCalledTimes(2);
const unauthenticatedNearMiss = createResponse();
await dispatchRequest(
server,
createRequest({ path: "/api/channels-health" }),
unauthenticatedNearMiss.res,
);
expect(unauthenticatedNearMiss.res.statusCode).toBe(200);
expect(unauthenticatedNearMiss.getBody()).toContain('"route":"channel-health"');
expect(handlePluginRequest).toHaveBeenCalledTimes(3);
},
});
});