mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 06:50:23 +00:00
refactor(gateway): hard-break plugin wildcard http handlers
This commit is contained in:
@@ -1,31 +1,117 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import type { PluginRegistry } from "../../plugins/registry.js";
|
||||
import { canonicalizePathVariant } from "../security-path.js";
|
||||
import { hasSecurityPathCanonicalizationAnomaly } from "../security-path.js";
|
||||
import { isProtectedPluginRoutePath } from "../security-path.js";
|
||||
import {
|
||||
PROTECTED_PLUGIN_ROUTE_PREFIXES,
|
||||
canonicalizePathForSecurity,
|
||||
canonicalizePathVariant,
|
||||
} from "../security-path.js";
|
||||
|
||||
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
||||
|
||||
export type PluginRoutePathContext = {
|
||||
pathname: string;
|
||||
canonicalPath: string;
|
||||
candidates: string[];
|
||||
malformedEncoding: boolean;
|
||||
decodePassLimitReached: boolean;
|
||||
rawNormalizedPath: string;
|
||||
};
|
||||
|
||||
export type PluginHttpRequestHandler = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
pathContext?: PluginRoutePathContext,
|
||||
) => Promise<boolean>;
|
||||
|
||||
type PluginHttpRouteEntry = NonNullable<PluginRegistry["httpRoutes"]>[number];
|
||||
|
||||
function normalizeProtectedPrefix(prefix: string): string {
|
||||
const collapsed = prefix.toLowerCase().replace(/\/{2,}/g, "/");
|
||||
if (collapsed.length <= 1) {
|
||||
return collapsed || "/";
|
||||
}
|
||||
return collapsed.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function prefixMatch(pathname: string, prefix: string): boolean {
|
||||
return (
|
||||
pathname === prefix || pathname.startsWith(`${prefix}/`) || pathname.startsWith(`${prefix}%`)
|
||||
);
|
||||
}
|
||||
|
||||
const NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES =
|
||||
PROTECTED_PLUGIN_ROUTE_PREFIXES.map(normalizeProtectedPrefix);
|
||||
|
||||
export function isProtectedPluginRoutePathFromContext(context: PluginRoutePathContext): boolean {
|
||||
if (
|
||||
context.candidates.some((candidate) =>
|
||||
NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES.some((prefix) => prefixMatch(candidate, prefix)),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (!context.malformedEncoding) {
|
||||
return false;
|
||||
}
|
||||
return NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES.some((prefix) =>
|
||||
prefixMatch(context.rawNormalizedPath, prefix),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePluginRoutePathContext(pathname: string): PluginRoutePathContext {
|
||||
const canonical = canonicalizePathForSecurity(pathname);
|
||||
return {
|
||||
pathname,
|
||||
canonicalPath: canonical.canonicalPath,
|
||||
candidates: canonical.candidates,
|
||||
malformedEncoding: canonical.malformedEncoding,
|
||||
decodePassLimitReached: canonical.decodePassLimitReached,
|
||||
rawNormalizedPath: canonical.rawNormalizedPath,
|
||||
};
|
||||
}
|
||||
|
||||
function doesRouteMatchPath(route: PluginHttpRouteEntry, context: PluginRoutePathContext): boolean {
|
||||
const routeCanonicalPath = canonicalizePathVariant(route.path);
|
||||
if (route.match === "prefix") {
|
||||
return context.candidates.some((candidate) => prefixMatch(candidate, routeCanonicalPath));
|
||||
}
|
||||
return context.candidates.some((candidate) => candidate === routeCanonicalPath);
|
||||
}
|
||||
|
||||
function findMatchingPluginHttpRoutes(
|
||||
registry: PluginRegistry,
|
||||
context: PluginRoutePathContext,
|
||||
): PluginHttpRouteEntry[] {
|
||||
const routes = registry.httpRoutes ?? [];
|
||||
if (routes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const exactMatches: PluginHttpRouteEntry[] = [];
|
||||
const prefixMatches: PluginHttpRouteEntry[] = [];
|
||||
for (const route of routes) {
|
||||
if (!doesRouteMatchPath(route, context)) {
|
||||
continue;
|
||||
}
|
||||
if (route.match === "prefix") {
|
||||
prefixMatches.push(route);
|
||||
} else {
|
||||
exactMatches.push(route);
|
||||
}
|
||||
}
|
||||
exactMatches.sort((a, b) => b.path.length - a.path.length);
|
||||
prefixMatches.sort((a, b) => b.path.length - a.path.length);
|
||||
return [...exactMatches, ...prefixMatches];
|
||||
}
|
||||
|
||||
export function findRegisteredPluginHttpRoute(
|
||||
registry: PluginRegistry,
|
||||
pathname: string,
|
||||
): PluginHttpRouteEntry | undefined {
|
||||
const canonicalPath = canonicalizePathVariant(pathname);
|
||||
const routes = registry.httpRoutes ?? [];
|
||||
return routes.find((entry) => canonicalizePathVariant(entry.path) === canonicalPath);
|
||||
const pathContext = resolvePluginRoutePathContext(pathname);
|
||||
return findMatchingPluginHttpRoutes(registry, pathContext)[0];
|
||||
}
|
||||
|
||||
// Only checks specific routes registered via registerHttpRoute, not wildcard handlers
|
||||
// registered via registerHttpHandler. Wildcard handlers (e.g., webhooks) implement
|
||||
// their own signature-based auth and are handled separately in the auth enforcement logic.
|
||||
export function isRegisteredPluginHttpRoutePath(
|
||||
registry: PluginRegistry,
|
||||
pathname: string,
|
||||
@@ -35,13 +121,23 @@ export function isRegisteredPluginHttpRoutePath(
|
||||
|
||||
export function shouldEnforceGatewayAuthForPluginPath(
|
||||
registry: PluginRegistry,
|
||||
pathname: string,
|
||||
pathnameOrContext: string | PluginRoutePathContext,
|
||||
): boolean {
|
||||
return (
|
||||
hasSecurityPathCanonicalizationAnomaly(pathname) ||
|
||||
isProtectedPluginRoutePath(pathname) ||
|
||||
isRegisteredPluginHttpRoutePath(registry, pathname)
|
||||
);
|
||||
const pathContext =
|
||||
typeof pathnameOrContext === "string"
|
||||
? resolvePluginRoutePathContext(pathnameOrContext)
|
||||
: pathnameOrContext;
|
||||
if (pathContext.malformedEncoding || pathContext.decodePassLimitReached) {
|
||||
return true;
|
||||
}
|
||||
if (isProtectedPluginRoutePathFromContext(pathContext)) {
|
||||
return true;
|
||||
}
|
||||
const route = findMatchingPluginHttpRoutes(registry, pathContext)[0];
|
||||
if (!route) {
|
||||
return false;
|
||||
}
|
||||
return route.auth === "gateway";
|
||||
}
|
||||
|
||||
export function createGatewayPluginRequestHandler(params: {
|
||||
@@ -49,40 +145,31 @@ export function createGatewayPluginRequestHandler(params: {
|
||||
log: SubsystemLogger;
|
||||
}): PluginHttpRequestHandler {
|
||||
const { registry, log } = params;
|
||||
return async (req, res) => {
|
||||
return async (req, res, providedPathContext) => {
|
||||
const routes = registry.httpRoutes ?? [];
|
||||
const handlers = registry.httpHandlers ?? [];
|
||||
if (routes.length === 0 && handlers.length === 0) {
|
||||
if (routes.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (routes.length > 0) {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const route = findRegisteredPluginHttpRoute(registry, url.pathname);
|
||||
if (route) {
|
||||
try {
|
||||
await route.handler(req, res);
|
||||
return true;
|
||||
} catch (err) {
|
||||
log.warn(`plugin http route failed (${route.pluginId ?? "unknown"}): ${String(err)}`);
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Internal Server Error");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const pathContext =
|
||||
providedPathContext ??
|
||||
(() => {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
return resolvePluginRoutePathContext(url.pathname);
|
||||
})();
|
||||
const matchedRoutes = findMatchingPluginHttpRoutes(registry, pathContext);
|
||||
if (matchedRoutes.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const entry of handlers) {
|
||||
for (const route of matchedRoutes) {
|
||||
try {
|
||||
const handled = await entry.handler(req, res);
|
||||
if (handled) {
|
||||
const handled = await route.handler(req, res);
|
||||
if (handled !== false) {
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(`plugin http handler failed (${entry.pluginId}): ${String(err)}`);
|
||||
log.warn(`plugin http route failed (${route.pluginId ?? "unknown"}): ${String(err)}`);
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
|
||||
Reference in New Issue
Block a user