Files
Moltbot/src/gateway/server.plugin-http-auth.test.ts
Coy Geek 647d929c9d fix: Unauthenticated Nostr profile API allows remote config tampering (#13719)
* fix(an-07): apply security fix

Generated by staged fix workflow.

* fix(an-07): apply security fix

Generated by staged fix workflow.

* fix(an-07): satisfy lint in plugin auth regression test

Replace unsafe unknown-to-string coercion in the gateway plugin auth test helper with explicit string/null/JSON handling so pnpm check passes.
2026-02-12 07:55:22 -06:00

175 lines
5.4 KiB
TypeScript

import type { IncomingMessage, ServerResponse } from "node:http";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import type { ResolvedGatewayAuth } from "./auth.js";
import { createGatewayHttpServer } from "./server-http.js";
async function withTempConfig(params: { cfg: unknown; run: () => Promise<void> }): Promise<void> {
const prevConfigPath = process.env.OPENCLAW_CONFIG_PATH;
const prevDisableCache = process.env.OPENCLAW_DISABLE_CONFIG_CACHE;
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-http-auth-test-"));
const configPath = path.join(dir, "openclaw.json");
process.env.OPENCLAW_CONFIG_PATH = configPath;
process.env.OPENCLAW_DISABLE_CONFIG_CACHE = "1";
try {
await writeFile(configPath, JSON.stringify(params.cfg, null, 2), "utf-8");
await params.run();
} finally {
if (prevConfigPath === undefined) {
delete process.env.OPENCLAW_CONFIG_PATH;
} else {
process.env.OPENCLAW_CONFIG_PATH = prevConfigPath;
}
if (prevDisableCache === undefined) {
delete process.env.OPENCLAW_DISABLE_CONFIG_CACHE;
} else {
process.env.OPENCLAW_DISABLE_CONFIG_CACHE = prevDisableCache;
}
await rm(dir, { recursive: true, force: true });
}
}
function createRequest(params: {
path: string;
authorization?: string;
method?: string;
}): IncomingMessage {
const headers: Record<string, string> = {
host: "localhost:18789",
};
if (params.authorization) {
headers.authorization = params.authorization;
}
return {
method: params.method ?? "GET",
url: params.path,
headers,
socket: { remoteAddress: "127.0.0.1" },
} as IncomingMessage;
}
function createResponse(): {
res: ServerResponse;
setHeader: ReturnType<typeof vi.fn>;
end: ReturnType<typeof vi.fn>;
getBody: () => string;
} {
const setHeader = vi.fn();
let body = "";
const end = vi.fn((chunk?: unknown) => {
if (typeof chunk === "string") {
body = chunk;
return;
}
if (chunk == null) {
body = "";
return;
}
body = JSON.stringify(chunk);
});
const res = {
headersSent: false,
statusCode: 200,
setHeader,
end,
} as unknown as ServerResponse;
return {
res,
setHeader,
end,
getBody: () => body,
};
}
async function dispatchRequest(
server: ReturnType<typeof createGatewayHttpServer>,
req: IncomingMessage,
res: ServerResponse,
): Promise<void> {
server.emit("request", req, res);
await new Promise((resolve) => setImmediate(resolve));
}
describe("gateway plugin HTTP auth boundary", () => {
test("requires gateway auth for /api/channels/* plugin routes and allows authenticated pass-through", async () => {
const resolvedAuth: ResolvedGatewayAuth = {
mode: "token",
token: "test-token",
password: undefined,
allowTailscale: false,
};
await withTempConfig({
cfg: { gateway: { trustedProxies: [] } },
run: async () => {
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
if (pathname === "/api/channels/nostr/default/profile") {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify({ ok: true, route: "channel" }));
return true;
}
if (pathname === "/plugin/public") {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify({ ok: true, route: "public" }));
return true;
}
return false;
});
const server = createGatewayHttpServer({
canvasHost: null,
clients: new Set(),
controlUiEnabled: false,
controlUiBasePath: "/__control__",
openAiChatCompletionsEnabled: false,
openResponsesEnabled: false,
handleHooksRequest: async () => false,
handlePluginRequest,
resolvedAuth,
});
const unauthenticated = createResponse();
await dispatchRequest(
server,
createRequest({ path: "/api/channels/nostr/default/profile" }),
unauthenticated.res,
);
expect(unauthenticated.res.statusCode).toBe(401);
expect(unauthenticated.getBody()).toContain("Unauthorized");
expect(handlePluginRequest).not.toHaveBeenCalled();
const authenticated = createResponse();
await dispatchRequest(
server,
createRequest({
path: "/api/channels/nostr/default/profile",
authorization: "Bearer test-token",
}),
authenticated.res,
);
expect(authenticated.res.statusCode).toBe(200);
expect(authenticated.getBody()).toContain('"route":"channel"');
const unauthenticatedPublic = createResponse();
await dispatchRequest(
server,
createRequest({ path: "/plugin/public" }),
unauthenticatedPublic.res,
);
expect(unauthenticatedPublic.res.statusCode).toBe(200);
expect(unauthenticatedPublic.getBody()).toContain('"route":"public"');
expect(handlePluginRequest).toHaveBeenCalledTimes(2);
},
});
});
});