mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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.
This commit is contained in:
@@ -333,6 +333,7 @@ export function createGatewayHttpServer(opts: {
|
|||||||
try {
|
try {
|
||||||
const configSnapshot = loadConfig();
|
const configSnapshot = loadConfig();
|
||||||
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
|
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
|
||||||
|
const requestPath = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||||
if (await handleHooksRequest(req, res)) {
|
if (await handleHooksRequest(req, res)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -347,8 +348,26 @@ export function createGatewayHttpServer(opts: {
|
|||||||
if (await handleSlackHttpRequest(req, res)) {
|
if (await handleSlackHttpRequest(req, res)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (handlePluginRequest && (await handlePluginRequest(req, res))) {
|
if (handlePluginRequest) {
|
||||||
return;
|
// 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.startsWith("/api/channels/")) {
|
||||||
|
const token = getBearerToken(req);
|
||||||
|
const authResult = await authorizeGatewayConnect({
|
||||||
|
auth: resolvedAuth,
|
||||||
|
connectAuth: token ? { token, password: token } : null,
|
||||||
|
req,
|
||||||
|
trustedProxies,
|
||||||
|
});
|
||||||
|
if (!authResult.ok) {
|
||||||
|
sendUnauthorized(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (await handlePluginRequest(req, res)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (openResponsesEnabled) {
|
if (openResponsesEnabled) {
|
||||||
if (
|
if (
|
||||||
@@ -372,8 +391,7 @@ export function createGatewayHttpServer(opts: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (canvasHost) {
|
if (canvasHost) {
|
||||||
const url = new URL(req.url ?? "/", "http://localhost");
|
if (isCanvasPath(requestPath)) {
|
||||||
if (isCanvasPath(url.pathname)) {
|
|
||||||
const ok = await authorizeCanvasRequest({
|
const ok = await authorizeCanvasRequest({
|
||||||
req,
|
req,
|
||||||
auth: resolvedAuth,
|
auth: resolvedAuth,
|
||||||
|
|||||||
174
src/gateway/server.plugin-http-auth.test.ts
Normal file
174
src/gateway/server.plugin-http-auth.test.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -66,6 +66,27 @@ function buildNostrProfileUrl(accountId: string, suffix = ""): string {
|
|||||||
return `/api/channels/nostr/${encodeURIComponent(accountId)}/profile${suffix}`;
|
return `/api/channels/nostr/${encodeURIComponent(accountId)}/profile${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveGatewayHttpAuthHeader(host: OpenClawApp): string | null {
|
||||||
|
const deviceToken = host.hello?.auth?.deviceToken?.trim();
|
||||||
|
if (deviceToken) {
|
||||||
|
return `Bearer ${deviceToken}`;
|
||||||
|
}
|
||||||
|
const token = host.settings.token.trim();
|
||||||
|
if (token) {
|
||||||
|
return `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
const password = host.password.trim();
|
||||||
|
if (password) {
|
||||||
|
return `Bearer ${password}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGatewayHttpHeaders(host: OpenClawApp): Record<string, string> {
|
||||||
|
const authorization = resolveGatewayHttpAuthHeader(host);
|
||||||
|
return authorization ? { Authorization: authorization } : {};
|
||||||
|
}
|
||||||
|
|
||||||
export function handleNostrProfileEdit(
|
export function handleNostrProfileEdit(
|
||||||
host: OpenClawApp,
|
host: OpenClawApp,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
@@ -133,6 +154,7 @@ export async function handleNostrProfileSave(host: OpenClawApp) {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
...buildGatewayHttpHeaders(host),
|
||||||
},
|
},
|
||||||
body: JSON.stringify(state.values),
|
body: JSON.stringify(state.values),
|
||||||
});
|
});
|
||||||
@@ -203,6 +225,7 @@ export async function handleNostrProfileImport(host: OpenClawApp) {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
...buildGatewayHttpHeaders(host),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ autoMerge: true }),
|
body: JSON.stringify({ autoMerge: true }),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user