High severity8.2GHSA Advisory· Published May 5, 2026· Updated May 7, 2026
CVE-2026-43526
CVE-2026-43526
Description
OpenClaw before 2026.4.12 contains a server-side request forgery vulnerability in QQBot reply media URL handling that allows attackers to fetch arbitrary content. Attackers can exploit this by providing malicious media URLs that trigger SSRF requests, with fetched bytes subsequently re-uploaded through the channel.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.4.12 | 2026.4.12 |
Affected products
2Patches
3ddb7a8dd80b8Feat/fix qq ssrf url list (#65788)
2 files changed · +20 −2
extensions/qqbot/src/utils/file-utils.test.ts+10 −1 modified@@ -47,7 +47,16 @@ describe("qqbot file-utils downloadFile", () => { ssrfPolicy: QQBOT_MEDIA_SSRF_POLICY, }); expect(QQBOT_MEDIA_SSRF_POLICY).toEqual({ - hostnameAllowlist: ["*.myqcloud.com", "*.qpic.cn", "*.qq.com", "*.tencentcos.com"], + hostnameAllowlist: [ + "*.qpic.cn", + "*.qq.com", + "*.weiyun.com", + "*.qq.com.cn", + "*.ugcimg.cn", + "*.myqcloud.com", + "*.tencentcos.cn", + "*.tencentcos.com", + ], allowRfc2544BenchmarkRange: true, }); });
extensions/qqbot/src/utils/file-utils.ts+10 −1 modified@@ -16,9 +16,18 @@ export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024; export const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024; const QQBOT_MEDIA_HOSTNAME_ALLOWLIST = [ - "*.myqcloud.com", + // QQ富媒体 "*.qpic.cn", "*.qq.com", + "*.weiyun.com", + "*.qq.com.cn", + + // QQ机器人 + "*.ugcimg.cn", + + // 腾讯云COS + "*.myqcloud.com", + "*.tencentcos.cn", "*.tencentcos.com", ];
08ae021d1f42fix(qqbot): guard image-size probe against SSRF (#63495)
7 files changed · +379 −64
CHANGELOG.md+1 −0 modified@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - WhatsApp/outbound queue: drain queued WhatsApp deliveries when the listener reconnects without dropping reconnect-delayed sends after a special TTL or rewriting retry history, so disconnect-window outbound messages can recover once the channel is ready again. (#46299) Thanks @manuel-claw. - Tools/web_fetch: add an opt-in `tools.web.fetch.ssrfPolicy.allowRfc2544BenchmarkRange` config so fake-IP proxy environments that resolve public sites into `198.18.0.0/15` can use `web_fetch` without weakening the default SSRF block. (#61830) Thanks @xing-xing-coder. - Config validation: surface the actual offending field for strict-schema union failures in bindings, including top-level unexpected keys on the matching ACP branch. (#40841) Thanks @Hollychou924. +- QQBot/security: replace raw `fetch()` in the image-size probe with SSRF-guarded `fetchRemoteMedia`, fix `resolveRepoRoot()` to walk up to `.git` instead of hardcoding two parent levels, and refresh the raw-fetch allowlist to match the corrected scan. (#63495) Thanks @dims. ## 2026.4.9
extensions/qqbot/src/outbound-deliver.test.ts+60 −3 modified@@ -48,10 +48,17 @@ vi.mock("./runtime.js", () => ({ }), })); -vi.mock("./utils/image-size.js", () => ({ +const imageSizeMocks = vi.hoisted(() => ({ getImageSize: vi.fn(), - formatQQBotMarkdownImage: vi.fn((url: string) => ``), - hasQQBotImageSize: vi.fn(() => false), + formatQQBotMarkdownImage: vi.fn(), + hasQQBotImageSize: vi.fn(), +})); + +vi.mock("./utils/image-size.js", () => ({ + getImageSize: (...args: unknown[]) => imageSizeMocks.getImageSize(...args), + formatQQBotMarkdownImage: (...args: unknown[]) => + imageSizeMocks.formatQQBotMarkdownImage(...args), + hasQQBotImageSize: (...args: unknown[]) => imageSizeMocks.hasQQBotImageSize(...args), })); import { @@ -95,6 +102,9 @@ describe("qqbot outbound deliver", () => { beforeEach(() => { vi.clearAllMocks(); runtimeMocks.chunkMarkdownText.mockImplementation((text: string) => [text]); + imageSizeMocks.getImageSize.mockResolvedValue(null); + imageSizeMocks.formatQQBotMarkdownImage.mockImplementation((url: string) => ``); + imageSizeMocks.hasQQBotImageSize.mockReturnValue(false); }); it("sends plain replies through the shared text chunk sender", async () => { @@ -168,4 +178,51 @@ describe("qqbot outbound deliver", () => { ); expect(outboundMocks.sendPhoto).toHaveBeenCalledTimes(1); }); + + describe("private-network image URL degradation", () => { + it("sends markdown reply with fallback dimensions when getImageSize returns null", async () => { + imageSizeMocks.getImageSize.mockResolvedValue(null); + + await sendPlainReply( + {}, + "Look at this: ", + buildEvent(), + buildAccountContext(true), + sendWithRetry, + consumeQuoteRef, + [], + ); + + // getImageSize was called with the private-network URL + expect(imageSizeMocks.getImageSize).toHaveBeenCalledWith("https://10.0.0.1/internal.png"); + // formatQQBotMarkdownImage was called with null size (triggers default dimensions) + expect(imageSizeMocks.formatQQBotMarkdownImage).toHaveBeenCalledWith( + "https://10.0.0.1/internal.png", + null, + ); + // Message was still sent (not crashed) + expect(apiMocks.sendC2CMessage).toHaveBeenCalled(); + }); + + it("sends markdown reply with fallback when getImageSize throws", async () => { + imageSizeMocks.getImageSize.mockRejectedValue(new Error("SSRF blocked")); + + await sendPlainReply( + {}, + "Check ", + buildEvent(), + buildAccountContext(true), + sendWithRetry, + consumeQuoteRef, + [], + ); + + // formatQQBotMarkdownImage still called with null (catch path in outbound-deliver) + expect(imageSizeMocks.formatQQBotMarkdownImage).toHaveBeenCalledWith( + "https://169.254.169.254/latest/meta-data/", + null, + ); + expect(apiMocks.sendC2CMessage).toHaveBeenCalled(); + }); + }); });
extensions/qqbot/src/utils/image-size.test.ts+164 −0 added@@ -0,0 +1,164 @@ +import { Buffer } from "buffer"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mediaRuntimeMocks = vi.hoisted(() => ({ + fetchRemoteMedia: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/media-runtime", () => ({ + fetchRemoteMedia: (...args: unknown[]) => mediaRuntimeMocks.fetchRemoteMedia(...args), +})); + +import { getImageSizeFromUrl, parseImageSize } from "./image-size.js"; + +/** Build a minimal valid PNG header with the given dimensions. */ +function buildPngHeader(width: number, height: number): Buffer { + const buf = Buffer.alloc(24); + // PNG signature + buf[0] = 0x89; + buf[1] = 0x50; + buf[2] = 0x4e; + buf[3] = 0x47; + buf[4] = 0x0d; + buf[5] = 0x0a; + buf[6] = 0x1a; + buf[7] = 0x0a; + // IHDR chunk length + buf.writeUInt32BE(13, 8); + // "IHDR" + buf.write("IHDR", 12, "ascii"); + // Width and height + buf.writeUInt32BE(width, 16); + buf.writeUInt32BE(height, 20); + return buf; +} + +describe("getImageSizeFromUrl", () => { + beforeEach(() => { + mediaRuntimeMocks.fetchRemoteMedia.mockReset(); + }); + + describe("fetchRemoteMedia options contract", () => { + it("passes maxBytes, maxRedirects, ssrfPolicy, and headers", async () => { + mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({ + buffer: buildPngHeader(800, 600), + contentType: "image/png", + }); + + await getImageSizeFromUrl("https://cdn.example.com/photo.png"); + + expect(mediaRuntimeMocks.fetchRemoteMedia).toHaveBeenCalledOnce(); + const opts = mediaRuntimeMocks.fetchRemoteMedia.mock.calls[0][0]; + + expect(opts.url).toBe("https://cdn.example.com/photo.png"); + expect(opts.maxBytes).toBe(65_536); + expect(opts.maxRedirects).toBe(0); + // Generic public-network-only policy: no hostname allowlist + expect(opts.ssrfPolicy).toEqual({}); + expect(opts.requestInit.headers).toEqual({ + Range: "bytes=0-65535", + "User-Agent": "QQBot-Image-Size-Detector/1.0", + }); + }); + + it("threads caller abort signal through requestInit", async () => { + mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({ + buffer: buildPngHeader(100, 100), + }); + + await getImageSizeFromUrl("https://cdn.example.com/img.png", 3000); + + const opts = mediaRuntimeMocks.fetchRemoteMedia.mock.calls[0][0]; + expect(opts.requestInit.signal).toBeInstanceOf(AbortSignal); + }); + }); + + describe("SSRF blocking (fetchRemoteMedia rejects)", () => { + it("returns null when fetchRemoteMedia throws for loopback", async () => { + mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce( + new Error("SSRF blocked: loopback address"), + ); + + const result = await getImageSizeFromUrl("https://127.0.0.1/img.png"); + + expect(result).toBeNull(); + }); + + it("returns null when fetchRemoteMedia throws for IPv6 loopback", async () => { + mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce( + new Error("SSRF blocked: loopback address"), + ); + + const result = await getImageSizeFromUrl("https://[::1]/img.png"); + + expect(result).toBeNull(); + }); + + it("returns null when fetchRemoteMedia throws for link-local/metadata", async () => { + mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce( + new Error("SSRF blocked: link-local address"), + ); + + const result = await getImageSizeFromUrl("https://169.254.169.254/latest/meta-data/"); + + expect(result).toBeNull(); + }); + + it("returns null when fetchRemoteMedia throws for RFC1918 addresses", async () => { + mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce( + new Error("SSRF blocked: private address"), + ); + + const result = await getImageSizeFromUrl("https://10.0.0.1/img.png"); + + expect(result).toBeNull(); + }); + + it("returns null on http error from fetchRemoteMedia", async () => { + mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce(new Error("HTTP 403 Forbidden")); + + const result = await getImageSizeFromUrl("https://cdn.example.com/forbidden.png"); + + expect(result).toBeNull(); + }); + }); + + describe("happy path", () => { + it("returns parsed dimensions for a valid PNG", async () => { + mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({ + buffer: buildPngHeader(1920, 1080), + contentType: "image/png", + }); + + const size = await getImageSizeFromUrl("https://cdn.example.com/banner.png"); + + expect(size).toEqual({ width: 1920, height: 1080 }); + }); + + it("returns null when the buffer is not a recognized image format", async () => { + mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({ + buffer: Buffer.from("not an image"), + contentType: "text/html", + }); + + const size = await getImageSizeFromUrl("https://cdn.example.com/notimage.html"); + + expect(size).toBeNull(); + }); + }); +}); + +describe("parseImageSize", () => { + it("parses PNG dimensions", () => { + const size = parseImageSize(buildPngHeader(640, 480)); + expect(size).toEqual({ width: 640, height: 480 }); + }); + + it("returns null for unrecognized data", () => { + expect(parseImageSize(Buffer.from("hello"))).toBeNull(); + }); + + it("returns null for empty buffer", () => { + expect(parseImageSize(Buffer.alloc(0))).toBeNull(); + }); +});
extensions/qqbot/src/utils/image-size.ts+36 −26 modified@@ -5,6 +5,8 @@ */ import { Buffer } from "buffer"; +import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime"; +import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; import { debugLog } from "./debug-log.js"; export interface ImageSize { @@ -143,8 +145,18 @@ export function parseImageSize(buffer: Buffer): ImageSize | null { ); } +/** + * SSRF policy for image-dimension probing. Generic public-network-only blocking + * (no hostname allowlist) because markdown image URLs can legitimately point to + * any public host, not just QQ-owned CDNs. + */ +const IMAGE_PROBE_SSRF_POLICY: SsrFPolicy = {}; + /** * Fetch image dimensions from a public URL using only the first 64 KB. + * + * Uses {@link fetchRemoteMedia} with SSRF guard to block probes against + * private/reserved/loopback/link-local/metadata destinations. */ export async function getImageSizeFromUrl( url: string, @@ -154,33 +166,31 @@ export async function getImageSizeFromUrl( const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); - // Request only the first 64 KB, which is enough for common headers. - const response = await fetch(url, { - signal: controller.signal, - headers: { - Range: "bytes=0-65535", - "User-Agent": "QQBot-Image-Size-Detector/1.0", - }, - }); - - clearTimeout(timeoutId); - - if (!response.ok && response.status !== 206) { - debugLog(`[image-size] Failed to fetch ${url}: ${response.status}`); - return null; - } - - const arrayBuffer = await response.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - - const size = parseImageSize(buffer); - if (size) { - debugLog( - `[image-size] Got size from URL: ${size.width}x${size.height} - ${url.slice(0, 60)}...`, - ); + try { + const { buffer } = await fetchRemoteMedia({ + url, + maxBytes: 65_536, + maxRedirects: 0, + ssrfPolicy: IMAGE_PROBE_SSRF_POLICY, + requestInit: { + signal: controller.signal, + headers: { + Range: "bytes=0-65535", + "User-Agent": "QQBot-Image-Size-Detector/1.0", + }, + }, + }); + + const size = parseImageSize(buffer); + if (size) { + debugLog( + `[image-size] Got size from URL: ${size.width}x${size.height} - ${url.slice(0, 60)}...`, + ); + } + return size; + } finally { + clearTimeout(timeoutId); } - - return size; } catch (err) { debugLog(`[image-size] Error fetching ${url.slice(0, 60)}...: ${String(err)}`); return null;
scripts/check-no-raw-channel-fetch.mjs+54 −34 modified@@ -14,41 +14,61 @@ const sourceRoots = ["src/channels", "src/routing", "src/line", "extensions"]; // Temporary allowlist for legacy callsites. New raw fetch callsites in channel/plugin runtime // code should be rejected and migrated to fetchWithSsrFGuard/shared channel helpers. const allowedRawFetchCallsites = new Set([ - bundledPluginCallsite("bluebubbles", "src/types.ts", 133), - bundledPluginCallsite("feishu", "src/streaming-card.ts", 31), - bundledPluginCallsite("feishu", "src/streaming-card.ts", 101), - bundledPluginCallsite("feishu", "src/streaming-card.ts", 143), - bundledPluginCallsite("feishu", "src/streaming-card.ts", 199), - bundledPluginCallsite("googlechat", "src/api.ts", 22), - bundledPluginCallsite("googlechat", "src/api.ts", 43), - bundledPluginCallsite("googlechat", "src/api.ts", 63), - bundledPluginCallsite("googlechat", "src/api.ts", 188), - bundledPluginCallsite("googlechat", "src/auth.ts", 82), - bundledPluginCallsite("matrix", "src/directory-live.ts", 41), - bundledPluginCallsite("matrix", "src/matrix/client/config.ts", 171), - bundledPluginCallsite("mattermost", "src/mattermost/client.ts", 211), - bundledPluginCallsite("mattermost", "src/mattermost/monitor.ts", 230), - bundledPluginCallsite("mattermost", "src/mattermost/probe.ts", 27), - bundledPluginCallsite("minimax", "oauth.ts", 62), - bundledPluginCallsite("minimax", "oauth.ts", 93), - bundledPluginCallsite("msteams", "src/graph.ts", 39), - bundledPluginCallsite("nextcloud-talk", "src/room-info.ts", 92), - bundledPluginCallsite("nextcloud-talk", "src/send.ts", 107), - bundledPluginCallsite("nextcloud-talk", "src/send.ts", 198), - bundledPluginCallsite("talk-voice", "index.ts", 27), - bundledPluginCallsite("thread-ownership", "index.ts", 105), - bundledPluginCallsite("voice-call", "src/providers/plivo.ts", 95), - bundledPluginCallsite("voice-call", "src/providers/telnyx.ts", 61), - bundledPluginCallsite("voice-call", "src/providers/tts-openai.ts", 111), + bundledPluginCallsite("bluebubbles", "src/test-harness.ts", 128), + bundledPluginCallsite("bluebubbles", "src/types.ts", 181), + bundledPluginCallsite("browser", "src/browser/cdp.helpers.ts", 235), + bundledPluginCallsite("browser", "src/browser/client-fetch.ts", 217), + bundledPluginCallsite("browser", "src/browser/test-fetch.ts", 24), + bundledPluginCallsite("browser", "src/browser/test-fetch.ts", 27), + bundledPluginCallsite("chutes", "models.ts", 535), + bundledPluginCallsite("chutes", "models.ts", 542), + bundledPluginCallsite("discord", "src/monitor/gateway-plugin.ts", 322), + bundledPluginCallsite("discord", "src/monitor/gateway-plugin.ts", 360), + bundledPluginCallsite("discord", "src/voice-message.ts", 298), + bundledPluginCallsite("discord", "src/voice-message.ts", 333), + bundledPluginCallsite("elevenlabs", "speech-provider.ts", 295), + bundledPluginCallsite("elevenlabs", "tts.ts", 116), + bundledPluginCallsite("feishu", "src/monitor.webhook.test-helpers.ts", 25), + bundledPluginCallsite("github-copilot", "login.ts", 48), + bundledPluginCallsite("github-copilot", "login.ts", 80), + bundledPluginCallsite("googlechat", "src/auth.ts", 83), + bundledPluginCallsite("huggingface", "models.ts", 142), + bundledPluginCallsite("kilocode", "provider-models.ts", 130), + bundledPluginCallsite("matrix", "src/matrix/sdk/transport.ts", 112), + bundledPluginCallsite("microsoft-foundry", "onboard.ts", 479), + bundledPluginCallsite("microsoft", "speech-provider.ts", 132), + bundledPluginCallsite("minimax", "oauth.ts", 66), + bundledPluginCallsite("minimax", "oauth.ts", 107), + bundledPluginCallsite("minimax", "tts.ts", 52), + bundledPluginCallsite("msteams", "src/graph.ts", 47), + bundledPluginCallsite("msteams", "src/sdk.ts", 292), + bundledPluginCallsite("msteams", "src/sdk.ts", 333), + bundledPluginCallsite("ollama", "src/stream.ts", 649), + bundledPluginCallsite("openai", "tts.ts", 133), + bundledPluginCallsite("qa-channel", "src/bus-client.ts", 41), + bundledPluginCallsite("qa-channel", "src/bus-client.ts", 221), + bundledPluginCallsite("qa-lab", "src/docker-up.runtime.ts", 274), + bundledPluginCallsite("qa-lab", "src/gateway-child.ts", 488), + bundledPluginCallsite("qa-lab", "src/suite.ts", 330), + bundledPluginCallsite("qa-lab", "src/suite.ts", 341), + bundledPluginCallsite("qa-lab", "web/src/app.ts", 15), + bundledPluginCallsite("qa-lab", "web/src/app.ts", 23), + bundledPluginCallsite("qa-lab", "web/src/app.ts", 31), + bundledPluginCallsite("qqbot", "src/api.ts", 102), + bundledPluginCallsite("qqbot", "src/api.ts", 237), + bundledPluginCallsite("qqbot", "src/stt.ts", 81), + bundledPluginCallsite("qqbot", "src/tools/channel.ts", 180), + bundledPluginCallsite("qqbot", "src/utils/audio-convert.ts", 377), + bundledPluginCallsite("signal", "src/install-signal-cli.ts", 224), + bundledPluginCallsite("slack", "src/monitor/media.ts", 96), + bundledPluginCallsite("slack", "src/monitor/media.ts", 115), + bundledPluginCallsite("slack", "src/monitor/media.ts", 120), + bundledPluginCallsite("tlon", "src/tlon-api.ts", 185), + bundledPluginCallsite("tlon", "src/tlon-api.ts", 235), + bundledPluginCallsite("tlon", "src/tlon-api.ts", 289), + bundledPluginCallsite("venice", "models.ts", 552), + bundledPluginCallsite("vercel-ai-gateway", "models.ts", 181), bundledPluginCallsite("voice-call", "src/providers/twilio/api.ts", 23), - bundledPluginCallsite("telegram", "src/api-fetch.ts", 8), - bundledPluginCallsite("discord", "src/send.outbound.ts", 363), - bundledPluginCallsite("discord", "src/voice-message.ts", 268), - bundledPluginCallsite("discord", "src/voice-message.ts", 312), - bundledPluginCallsite("slack", "src/monitor/media.ts", 55), - bundledPluginCallsite("slack", "src/monitor/media.ts", 59), - bundledPluginCallsite("slack", "src/monitor/media.ts", 73), - bundledPluginCallsite("slack", "src/monitor/media.ts", 99), ]); function isRawFetchCall(expression) {
scripts/lib/ts-guard-utils.mjs+13 −1 modified@@ -1,4 +1,4 @@ -import { promises as fs } from "node:fs"; +import { existsSync, promises as fs } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -14,6 +14,18 @@ function getTypeScript() { const baseTestSuffixes = [".test.ts", ".test-utils.ts", ".test-harness.ts", ".e2e-harness.ts"]; export function resolveRepoRoot(importMetaUrl) { + // Walk up from the caller's directory until we find the repo root (.git). + // This handles callers at any depth (scripts/*.mjs, scripts/lib/*.mjs, etc.) + // instead of assuming a fixed number of parent traversals. + let dir = path.dirname(fileURLToPath(importMetaUrl)); + const { root } = path.parse(dir); + while (dir !== root) { + if (existsSync(path.join(dir, ".git"))) { + return dir; + } + dir = path.dirname(dir); + } + // Fallback: two levels up (original behavior). return path.resolve(path.dirname(fileURLToPath(importMetaUrl)), "..", ".."); }
test/scripts/ts-guard-utils.test.ts+51 −0 added@@ -0,0 +1,51 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; +import { resolveRepoRoot } from "../../scripts/lib/ts-guard-utils.mjs"; + +/** + * Regression tests for resolveRepoRoot(). + * + * The original implementation went up exactly two levels from the caller's + * import.meta.url, which broke for scripts at scripts/*.mjs (one level below + * root) — it overshot to the repo's parent directory. + */ +describe("resolveRepoRoot", () => { + it("resolves correctly from a scripts/lib/*.mjs path (two levels below root)", () => { + const fakeUrl = pathToFileURL(path.resolve("scripts", "lib", "some-guard-utils.mjs")).href; + const root = resolveRepoRoot(fakeUrl); + + expect(existsSync(path.join(root, ".git"))).toBe(true); + expect(existsSync(path.join(root, "package.json"))).toBe(true); + }); + + it("resolves correctly from a scripts/*.mjs path (one level below root)", () => { + const fakeUrl = pathToFileURL(path.resolve("scripts", "check-no-raw-channel-fetch.mjs")).href; + const root = resolveRepoRoot(fakeUrl); + + expect(existsSync(path.join(root, ".git"))).toBe(true); + expect(existsSync(path.join(root, "package.json"))).toBe(true); + }); + + it("resolves correctly from a deeply nested extension path", () => { + const fakeUrl = pathToFileURL( + path.resolve("extensions", "qqbot", "src", "utils", "hypothetical.mjs"), + ).href; + const root = resolveRepoRoot(fakeUrl); + + expect(existsSync(path.join(root, ".git"))).toBe(true); + expect(existsSync(path.join(root, "package.json"))).toBe(true); + }); + + it("all caller depths resolve to the same root", () => { + const fromLib = resolveRepoRoot(pathToFileURL(path.resolve("scripts", "lib", "a.mjs")).href); + const fromScripts = resolveRepoRoot(pathToFileURL(path.resolve("scripts", "b.mjs")).href); + const fromExtension = resolveRepoRoot( + pathToFileURL(path.resolve("extensions", "qqbot", "c.mjs")).href, + ); + + expect(fromLib).toBe(fromScripts); + expect(fromScripts).toBe(fromExtension); + }); +});
08ae021d1f4fVulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
9- github.com/openclaw/openclaw/commit/08ae021d1f4f02e0ca5fd8a3b9659291c1ecf95anvdPatchWEB
- github.com/openclaw/openclaw/commit/ddb7a8dd80b8d5dd04aafa44ce7a4354b568bb2dnvdPatchWEB
- github.com/advisories/GHSA-2767-2q9v-9326ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-2767-2q9v-9326nvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-43526ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-server-side-request-forgery-via-qqbot-reply-media-url-handlingnvdThird Party AdvisoryWEB
- github.com/openclaw/openclaw/commit/08ae021d1f42905a85a550813c0d95169b171a6cghsaWEB
- github.com/openclaw/openclaw/pull/63495ghsaWEB
- github.com/openclaw/openclaw/pull/65788ghsaWEB
News mentions
0No linked articles in our index yet.