mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-29 10:02:04 +00:00
refactor(gateway): unify control-ui and plugin webhook routing
This commit is contained in:
@@ -1,269 +1,27 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||
import { createGatewayRequest, createHooksConfig } from "./hooks-test-helpers.js";
|
||||
import { canonicalizePathVariant, isProtectedPluginRoutePath } from "./security-path.js";
|
||||
import { createGatewayHttpServer, createHooksRequestHandler } from "./server-http.js";
|
||||
import { withTempConfig } from "./test-temp-config.js";
|
||||
|
||||
type GatewayHttpServer = ReturnType<typeof createGatewayHttpServer>;
|
||||
type GatewayServerOptions = Partial<Parameters<typeof createGatewayHttpServer>[0]>;
|
||||
|
||||
const AUTH_NONE: ResolvedGatewayAuth = {
|
||||
mode: "none",
|
||||
token: undefined,
|
||||
password: undefined,
|
||||
allowTailscale: false,
|
||||
};
|
||||
|
||||
const AUTH_TOKEN: ResolvedGatewayAuth = {
|
||||
mode: "token",
|
||||
token: "test-token",
|
||||
password: undefined,
|
||||
allowTailscale: false,
|
||||
};
|
||||
|
||||
function createRequest(params: {
|
||||
path: string;
|
||||
authorization?: string;
|
||||
method?: string;
|
||||
}): IncomingMessage {
|
||||
return createGatewayRequest({
|
||||
path: params.path,
|
||||
authorization: params.authorization,
|
||||
method: params.method,
|
||||
});
|
||||
}
|
||||
|
||||
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: GatewayHttpServer,
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<void> {
|
||||
server.emit("request", req, res);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
async function withGatewayTempConfig(prefix: string, run: () => Promise<void>): Promise<void> {
|
||||
await withTempConfig({
|
||||
cfg: { gateway: { trustedProxies: [] } },
|
||||
prefix,
|
||||
run,
|
||||
});
|
||||
}
|
||||
|
||||
function createTestGatewayServer(options: {
|
||||
resolvedAuth: ResolvedGatewayAuth;
|
||||
overrides?: GatewayServerOptions;
|
||||
}): GatewayHttpServer {
|
||||
return createGatewayHttpServer({
|
||||
canvasHost: null,
|
||||
clients: new Set(),
|
||||
controlUiEnabled: false,
|
||||
controlUiBasePath: "/__control__",
|
||||
openAiChatCompletionsEnabled: false,
|
||||
openResponsesEnabled: false,
|
||||
handleHooksRequest: async () => false,
|
||||
...options.overrides,
|
||||
resolvedAuth: options.resolvedAuth,
|
||||
});
|
||||
}
|
||||
|
||||
async function withGatewayServer(params: {
|
||||
prefix: string;
|
||||
resolvedAuth: ResolvedGatewayAuth;
|
||||
overrides?: GatewayServerOptions;
|
||||
run: (server: GatewayHttpServer) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
await withGatewayTempConfig(params.prefix, async () => {
|
||||
const server = createTestGatewayServer({
|
||||
resolvedAuth: params.resolvedAuth,
|
||||
overrides: params.overrides,
|
||||
});
|
||||
await params.run(server);
|
||||
});
|
||||
}
|
||||
|
||||
async function sendRequest(
|
||||
server: GatewayHttpServer,
|
||||
params: {
|
||||
path: string;
|
||||
authorization?: string;
|
||||
method?: string;
|
||||
},
|
||||
) {
|
||||
const response = createResponse();
|
||||
await dispatchRequest(server, createRequest(params), response.res);
|
||||
return response;
|
||||
}
|
||||
|
||||
function expectUnauthorizedResponse(
|
||||
response: ReturnType<typeof createResponse>,
|
||||
label?: string,
|
||||
): void {
|
||||
expect(response.res.statusCode, label).toBe(401);
|
||||
expect(response.getBody(), label).toContain("Unauthorized");
|
||||
}
|
||||
import {
|
||||
AUTH_NONE,
|
||||
AUTH_TOKEN,
|
||||
buildChannelPathFuzzCorpus,
|
||||
CANONICAL_AUTH_VARIANTS,
|
||||
CANONICAL_UNAUTH_VARIANTS,
|
||||
createCanonicalizedChannelPluginHandler,
|
||||
createHooksHandler,
|
||||
createTestGatewayServer,
|
||||
expectAuthorizedVariants,
|
||||
expectUnauthorizedResponse,
|
||||
expectUnauthorizedVariants,
|
||||
sendRequest,
|
||||
withGatewayServer,
|
||||
withGatewayTempConfig,
|
||||
} from "./server-http.test-harness.js";
|
||||
|
||||
function canonicalizePluginPath(pathname: string): string {
|
||||
return canonicalizePathVariant(pathname);
|
||||
}
|
||||
|
||||
function createCanonicalizedChannelPluginHandler() {
|
||||
return vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||
const canonicalPath = canonicalizePluginPath(pathname);
|
||||
if (canonicalPath !== "/api/channels/nostr/default/profile") {
|
||||
return false;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify({ ok: true, route: "channel-canonicalized" }));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function createHooksHandler(bindHost: string) {
|
||||
return createHooksRequestHandler({
|
||||
getHooksConfig: () => createHooksConfig(),
|
||||
bindHost,
|
||||
port: 18789,
|
||||
logHooks: {
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as ReturnType<typeof createSubsystemLogger>,
|
||||
dispatchWakeHook: () => {},
|
||||
dispatchAgentHook: () => "run-1",
|
||||
});
|
||||
}
|
||||
|
||||
type RouteVariant = {
|
||||
label: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
const CANONICAL_UNAUTH_VARIANTS: RouteVariant[] = [
|
||||
{ label: "case-variant", path: "/API/channels/nostr/default/profile" },
|
||||
{ label: "encoded-slash", path: "/api/channels%2Fnostr%2Fdefault%2Fprofile" },
|
||||
{
|
||||
label: "encoded-slash-4x",
|
||||
path: "/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile",
|
||||
},
|
||||
{ label: "encoded-segment", path: "/api/%63hannels/nostr/default/profile" },
|
||||
{ label: "dot-traversal-encoded-slash", path: "/api/foo/..%2fchannels/nostr/default/profile" },
|
||||
{
|
||||
label: "dot-traversal-encoded-dotdot-slash",
|
||||
path: "/api/foo/%2e%2e%2fchannels/nostr/default/profile",
|
||||
},
|
||||
{
|
||||
label: "dot-traversal-double-encoded",
|
||||
path: "/api/foo/%252e%252e%252fchannels/nostr/default/profile",
|
||||
},
|
||||
{ label: "duplicate-slashes", path: "/api/channels//nostr/default/profile" },
|
||||
{ label: "trailing-slash", path: "/api/channels/nostr/default/profile/" },
|
||||
{ label: "malformed-short-percent", path: "/api/channels%2" },
|
||||
{ label: "malformed-double-slash-short-percent", path: "/api//channels%2" },
|
||||
];
|
||||
|
||||
const CANONICAL_AUTH_VARIANTS: RouteVariant[] = [
|
||||
{ label: "auth-case-variant", path: "/API/channels/nostr/default/profile" },
|
||||
{
|
||||
label: "auth-encoded-slash-4x",
|
||||
path: "/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile",
|
||||
},
|
||||
{ label: "auth-encoded-segment", path: "/api/%63hannels/nostr/default/profile" },
|
||||
{ label: "auth-duplicate-trailing-slash", path: "/api/channels//nostr/default/profile/" },
|
||||
{
|
||||
label: "auth-dot-traversal-encoded-slash",
|
||||
path: "/api/foo/..%2fchannels/nostr/default/profile",
|
||||
},
|
||||
{
|
||||
label: "auth-dot-traversal-double-encoded",
|
||||
path: "/api/foo/%252e%252e%252fchannels/nostr/default/profile",
|
||||
},
|
||||
];
|
||||
|
||||
function buildChannelPathFuzzCorpus(): RouteVariant[] {
|
||||
const variants = [
|
||||
"/api/channels/nostr/default/profile",
|
||||
"/API/channels/nostr/default/profile",
|
||||
"/api/foo/..%2fchannels/nostr/default/profile",
|
||||
"/api/foo/%2e%2e%2fchannels/nostr/default/profile",
|
||||
"/api/foo/%252e%252e%252fchannels/nostr/default/profile",
|
||||
"/api/channels//nostr/default/profile/",
|
||||
"/api/channels%2Fnostr%2Fdefault%2Fprofile",
|
||||
"/api/channels%252Fnostr%252Fdefault%252Fprofile",
|
||||
"/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile",
|
||||
"/api//channels/nostr/default/profile",
|
||||
"/api/channels%2",
|
||||
"/api/channels%zz",
|
||||
"/api//channels%2",
|
||||
"/api//channels%zz",
|
||||
];
|
||||
return variants.map((path) => ({ label: `fuzz:${path}`, path }));
|
||||
}
|
||||
|
||||
async function expectUnauthorizedVariants(params: {
|
||||
server: GatewayHttpServer;
|
||||
variants: RouteVariant[];
|
||||
}) {
|
||||
for (const variant of params.variants) {
|
||||
const response = await sendRequest(params.server, { path: variant.path });
|
||||
expectUnauthorizedResponse(response, variant.label);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectAuthorizedVariants(params: {
|
||||
server: GatewayHttpServer;
|
||||
variants: RouteVariant[];
|
||||
authorization: string;
|
||||
}) {
|
||||
for (const variant of params.variants) {
|
||||
const response = await sendRequest(params.server, {
|
||||
path: variant.path,
|
||||
authorization: params.authorization,
|
||||
});
|
||||
expect(response.res.statusCode, variant.label).toBe(200);
|
||||
expect(response.getBody(), variant.label).toContain('"route":"channel-canonicalized"');
|
||||
}
|
||||
}
|
||||
|
||||
describe("gateway plugin HTTP auth boundary", () => {
|
||||
test("applies default security headers and optional strict transport security", async () => {
|
||||
await withGatewayTempConfig("openclaw-plugin-http-security-headers-test-", async () => {
|
||||
|
||||
Reference in New Issue
Block a user