Root cause
Authentication & access control
fix(an-07): apply security fix
How AI contributed: Flawed AI-written codePublished Mar 19, 2026 in
openclaw/openclawAll 35 findings in this repository
The change added a gateway authentication gate for the channel HTTP endpoints that plugins register, and the gate itself left the exposure. Before it, a request that a channel plugin could match was served without checking gateway credentials; the new check was meant to close that, but it recognized only one spelling of the prefix. Because the path was never decoded or lowercased, a request written as /API/channels/... or with an encoded slash kept the plugin route reachable without a credential. The repair decodes the path once and lowercases it before the prefix comparison, which closes the case-varied and single-encoded forms, and the commits that continued the same repair added slash normalization, fail-closed handling for malformed percent-encodings, and repeated decoding with dot-segment resolution.
An AI-assisted change added gateway authentication for OpenClaw's plugin channel routes but compared the raw request path, exactly as the client sent it, against a lowercase prefix. Percent-encoded, case-varied or dotted spellings of the same route skipped authentication and could reach a plugin handler that normalizes the path itself. The first repair decoded the path a limited number of times, which still let a four-times-encoded slash through; a later change raised the limit and rejects paths it cannot fully decode.
Root cause
fix(an-07): apply security fix
Fix
fix(gateway): fail closed plugin auth path canonicalization
Case facts
@@ -333,6 +333,7 @@ export function createGatewayHttpServer(opts: { try { const configSnapshot = loadConfig(); const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];+ const requestPath = new URL(req.url ?? "/", "http://localhost").pathname; if (await handleHooksRequest(req, res)) { return; }@@ -347,8 +348,23 @@ export function createGatewayHttpServer(opts: { if (await handleSlackHttpRequest(req, res)) { return; }- if (handlePluginRequest && (await handlePluginRequest(req, res))) {- return;+ if (handlePluginRequest) {+ 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 (@@ -372,8 +388,7 @@ export function createGatewayHttpServer(opts: { } } if (canvasHost) {- const url = new URL(req.url ?? "/", "http://localhost");- if (isCanvasPath(url.pathname)) {+ if (isCanvasPath(requestPath)) { const ok = await authorizeCanvasRequest({ req, auth: resolvedAuth,@@ -1,11 +1,13 @@ export type SecurityPathCanonicalization = { canonicalPath: string; candidates: string[];+ decodePasses: number;+ decodePassLimitReached: boolean; malformedEncoding: boolean; rawNormalizedPath: string; }; -const MAX_PATH_DECODE_PASSES = 3;+const MAX_PATH_DECODE_PASSES = 32; function normalizePathSeparators(pathname: string): string { const collapsed = pathname.replace(/\/{2,}/g, "/");@@ -43,13 +45,19 @@ function pushNormalizedCandidate(candidates: string[], seen: Set<string>, value: export function buildCanonicalPathCandidates( pathname: string, maxDecodePasses = MAX_PATH_DECODE_PASSES,-): { candidates: string[]; malformedEncoding: boolean } {+): {+ candidates: string[];+ decodePasses: number;+ decodePassLimitReached: boolean;+ malformedEncoding: boolean;+} { const candidates: string[] = []; const seen = new Set<string>(); pushNormalizedCandidate(candidates, seen, pathname); let decoded = pathname; let malformedEncoding = false;+ let decodePasses = 0; for (let pass = 0; pass < maxDecodePasses; pass++) { let nextDecoded = decoded; try {@@ -61,10 +69,24 @@ export function buildCanonicalPathCandidates( if (nextDecoded === decoded) { break; }+ decodePasses += 1; decoded = nextDecoded; pushNormalizedCandidate(candidates, seen, decoded); }- return { candidates, malformedEncoding };+ let decodePassLimitReached = false;+ if (!malformedEncoding) {+ try {+ decodePassLimitReached = decodeURIComponent(decoded) !== decoded;+ } catch {+ malformedEncoding = true;+ }+ }+ return {+ candidates,+ decodePasses,+ decodePassLimitReached,+ malformedEncoding,+ }; } export function canonicalizePathVariant(pathname: string): string {@@ -82,16 +104,24 @@ function prefixMatch(pathname: string, prefix: string): boolean { } export function canonicalizePathForSecurity(pathname: string): SecurityPathCanonicalization {- const { candidates, malformedEncoding } = buildCanonicalPathCandidates(pathname);+ const { candidates, decodePasses, decodePassLimitReached, malformedEncoding } =+ buildCanonicalPathCandidates(pathname); return { canonicalPath: candidates[candidates.length - 1] ?? "/", candidates,+ decodePasses,+ decodePassLimitReached, malformedEncoding, rawNormalizedPath: normalizePathSeparators(pathname.toLowerCase()) || "/", }; } +export function hasSecurityPathCanonicalizationAnomaly(pathname: string): boolean {+ const canonical = canonicalizePathForSecurity(pathname);+ return canonical.malformedEncoding || canonical.decodePassLimitReached;+}+ const normalizedPrefixesCache = new WeakMap<readonly string[], readonly string[]>(); function getNormalizedPrefixes(prefixes: readonly string[]): readonly string[] {@@ -114,6 +144,10 @@ export function isPathProtectedByPrefixes(pathname: string, prefixes: readonly s ) {AI-assisted change 1f493091f2fa97a525bf1e65daaa481b65f45cbe3f94868c49dd2a7d4db02b06 · Fix 9309cc8b4b47751706ba4d68081bcfa3671bbaf9daff42a964865c252505d040