mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-21 02:54:59 +00:00
fix(gateway): protect /api/channels root auth boundary (#25753) (thanks @bmendonca3)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user