fix(qqbot): restrict structured payload local paths#58453
Conversation
🔒 Aisle Security AnalysisWe found 2 potential security issue(s) in this PR:
1. 🟡 Unbounded inline data:image base64 URLs allowed in structured image payloads (memory/processing DoS)
Description
Vulnerable code: function isInlineImageDataUrl(p: string): boolean {
return /^data:image\/[^;]+;base64,/i.test(p);
}
...
} else if (isRemoteHttpUrl(normalizedPath) || isInlineImageDataUrl(normalizedPath)) {
imageUrl = normalizedPath;
}RecommendationAdd strict validation and size limits for inline data URLs. Minimum recommended changes:
Example: const MAX_INLINE_IMAGE_BYTES = 1 * 1024 * 1024; // 1MB decoded
const MAX_INLINE_IMAGE_CHARS = Math.ceil(MAX_INLINE_IMAGE_BYTES * 4 / 3) + 128;
const ALLOWED_INLINE_MIMES = new Set(["image/png","image/jpeg","image/gif","image/webp"]);
function parseInlineImageDataUrl(p: string): { mime: string; b64: string } | null {
const m = /^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i.exec(p);
if (!m) return null;
const mime = m[1].toLowerCase();
if (!ALLOWED_INLINE_MIMES.has(mime)) return null;
if (p.length > MAX_INLINE_IMAGE_CHARS) return null;
const buf = Buffer.from(m[2], "base64");
if (buf.length > MAX_INLINE_IMAGE_BYTES) return null;
return { mime, b64: m[2] };
}Then only allow 2. 🟡 Local file disclosure via hardlink in QQ Bot media directory allowlist
Description
This still allows hardlink attacks:
Vulnerable flow:
Vulnerable code: // reply-dispatcher.ts
const openFlags = fs.constants.O_RDONLY | ("O_NOFOLLOW" in fs.constants ? fs.constants.O_NOFOLLOW : 0);
const handle = await fs.promises.open(filePath, openFlags);
const stat = await handle.stat();
if (!stat.isFile()) throw new Error("Path is not a regular file");
return handle.readFile();Even though // platform.ts
const canonicalCandidate = fs.realpathSync(resolvedCandidate);
if (isPathWithinRoot(canonicalCandidate, canonicalRoot)) return canonicalCandidate;RecommendationMitigate hardlink-based exfiltration by adding an explicit hardlink defense and by tightening how files enter the media directory. Options (combine where possible):
const stat = await handle.stat();
if (!stat.isFile()) throw new Error("Path is not a regular file");
// Best-effort hardlink mitigation on platforms that expose nlink.
if (typeof (stat as any).nlink === "number" && (stat as any).nlink > 1) {
throw new Error("Hardlinked files are not allowed");
}
Analyzed PR: #58453 at commit Last updated on: 2026-04-01T16:56:02Z |
Greptile SummaryThis PR closes an arbitrary file-read vulnerability in the QQ Bot structured payload handlers by introducing Key changes:
Minor findings:
Confidence Score: 5/5Safe to merge — all remaining findings are P2 style/cleanup items that do not affect correctness or security. The security logic is sound: path traversal is neutralised via path.resolve before the allowlist check, symlinks are resolved with realpathSync, and all three media handlers are gated behind the new validator. The three P2 findings (unused import, redundant normalizePath call, missing path-traversal test) are cosmetic and do not affect the correctness of the fix. No files require special attention; all issues are minor style observations.
|
| Filename | Overview |
|---|---|
| extensions/qqbot/src/utils/platform.ts | Adds resolveQQBotPayloadLocalFilePath, which enforces the QQ Bot storage allowlist (media dir, data dir, workspace root) with path.resolve-based traversal neutralization and realpathSync symlink canonicalization. Logic is sound; one cosmetic double-normalization noted. |
| extensions/qqbot/src/reply-dispatcher.ts | Replaces direct resolveQQBotLocalMediaPath calls in image/video/file handlers with the new allowlist-enforcing validateStructuredPayloadLocalPath wrapper; adds isRemoteHttpUrl helper; cleans up indentation. One leftover unused import of resolveQQBotLocalMediaPath. |
| extensions/qqbot/src/utils/platform.test.ts | Adds four regression tests for resolveQQBotPayloadLocalFilePath covering block-outside-root, allow-media-dir, and legacy workspace remapping. Missing an explicit path-traversal (double-dot) test case. |
Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/qqbot/src/reply-dispatcher.ts
Line: 44
Comment:
**Unused import after handler refactoring**
`resolveQQBotLocalMediaPath` is imported but no longer called directly in this file. The PR replaced all three direct uses in `handleImagePayload`, `handleVideoPayload`, and `handleFilePayload` with `validateStructuredPayloadLocalPath` → `resolveQQBotPayloadLocalFilePath`. The import can be removed.
```suggestion
getQQBotDataDir,
normalizePath,
resolveQQBotPayloadLocalFilePath,
sanitizeFileName,
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: extensions/qqbot/src/utils/platform.ts
Line: 162
Comment:
**Redundant `normalizePath` call**
`resolveQQBotLocalMediaPath` already calls `normalizePath` as its first statement, so the outer `normalizePath(p)` here is applied twice. Since `normalizePath` is idempotent this causes no bug, but the pre-normalization before passing to `resolveQQBotLocalMediaPath` is unnecessary.
```suggestion
const candidate = resolveQQBotLocalMediaPath(p);
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: extensions/qqbot/src/utils/platform.test.ts
Line: 77-85
Comment:
**No path-traversal regression test**
The allowlist enforcement relies on `path.resolve(candidate)` to neutralize `..` segments before the root comparison (e.g. `~/.openclaw/media/qqbot/../../etc/passwd` → `/home/user/.openclaw/etc/passwd`). The existing test suite validates the happy path (allowed media dir) and the simple block case (outside temp dir), but there is no explicit test for an attacker-supplied path that uses `..` to escape the allowed root. Adding one here would lock in the protection against that class of bypass:
```typescript
it("blocks structured payload paths that escape allowed roots via '..'", () => {
const escapedPath = path.join(
getHomeDir(),
".openclaw",
"media",
"qqbot",
"..",
"..",
"etc",
"passwd",
);
expect(resolveQQBotPayloadLocalFilePath(escapedPath)).toBeNull();
});
```
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "fix(qqbot): restrict structured payload ..." | Re-trigger Greptile
🔒 Aisle Security AnalysisWe found 3 potential security issue(s) in this PR:
1. 🟠 TOCTOU + symlink/junction traversal allows arbitrary file read via structured media payload local paths
Description
Impact: a crafted structured payload referencing a path under the QQ Bot media directory can be raced/redirected to read arbitrary files on the host and exfiltrate them by sending as an image/video/file payload. Vulnerable code (path validation): const resolvedCandidate = path.resolve(candidate);
const canonicalCandidate = fs.existsSync(resolvedCandidate)
? fs.realpathSync(resolvedCandidate)
: resolvedCandidate;
...
if (isPathWithinRoot(canonicalCandidate, canonicalRoot)) {
return canonicalCandidate;
}RecommendationMake the authorization check and the open operation refer to the same object, and prevent symlink traversal for all path components. Recommended approach:
Example (POSIX-oriented) using a root FD and export function resolveQQBotPayloadLocalFilePath(p: string): string | null {
const candidate = resolveQQBotLocalMediaPath(normalizePath(p));
if (!candidate.trim()) return null;
// Must exist; always canonicalize.
let canonicalCandidate: string;
try {
canonicalCandidate = fs.realpathSync(candidate);
} catch {
return null;
}
const canonicalRoot = fs.realpathSync(getQQBotMediaDir());
if (!isPathWithinRoot(canonicalCandidate, canonicalRoot)) return null;
return canonicalCandidate;
}And in If supporting Windows securely, consider copying the target file into a fresh, newly-created temp file within the media dir and reading that copy (after verifying it is a regular file), rather than reading arbitrary existing paths. 2. 🟡 Sensitive URL/path information disclosure via QQBot media logging
DescriptionSeveral log statements include user-controlled URL/path values with insufficient redaction.
Vulnerable code: ctx.log?.error(
`... Blocked ... local path ...: ${sanitizeForLog(payloadPath)}`,
);
...
return sanitizeForLog(`${url.protocol}//${url.host}${url.pathname}`);Impact depends on who can access logs; in multi-tenant deployments or shared log aggregation this can leak internal hosts, path structure, or secrets embedded in URL path segments. RecommendationReduce logged detail for user-controlled URLs/paths.
Example safer implementation: import crypto from "node:crypto";
function describeMediaTargetForLog(pathValue: string, isHttpUrl: boolean): string {
if (!isHttpUrl) return "<local-file>";
try {
const url = new URL(pathValue);
url.username = "";
url.password = "";
// keep only origin; do not log pathname
const id = crypto.createHash("sha256").update(url.toString()).digest("hex").slice(0, 12);
return `${url.protocol}//${url.host}#${id}`;
} catch {
return "<invalid-url>";
}
}
// When blocking:
ctx.log?.error(`[qqbot:${ctx.account.accountId}] Blocked ${mediaType} payload local path outside QQ Bot media storage`);Also consider gating such details behind a debug log level and ensuring log access controls match tenant boundaries. 3. 🟡 User-controlled remote URL passed to QQ media upload API (SSRF/unintended fetch by upstream platform)
DescriptionStructured media payloads allow arbitrary
This enables SSRF-like behavior by proxy (the QQ platform performs the fetch) and can be abused to target:
Vulnerable code (accepts URL with only prefix check and forwards it): } else if (isRemoteHttpUrl(normalizedPath)) {
imageUrl = normalizedPath;
}
...
uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, imageUrl, undefined, false);Even if the fetch occurs on QQ infrastructure (not the bot host), it is still a security risk and can violate network egress / acceptable use policies, and can be used to trigger network requests to attacker-chosen targets. RecommendationTreat remote-URL media uploads as a privileged feature.
Example (skeletal): function validateRemoteMediaUrl(raw: string): string | null {
let u: URL;
try { u = new URL(raw); } catch { return null; }
if (u.protocol !== "https:" && u.protocol !== "http:") return null;
if (u.username || u.password) return null;
if (u.port && !["80","443"].includes(u.port)) return null;
// Resolve u.hostname and block private/link-local/etc.
return u.toString();
}
// usage
const safeUrl = validateRemoteMediaUrl(payload.path);
if (!safeUrl) { /* reject */ }If remote URL uploads are required, document clearly that the upstream QQ service will fetch the URL and that only safe/public URLs should be used. Analyzed PR: #58453 at commit Last updated on: 2026-03-31T16:14:36Z |
|
Follow-up from bot review on the latest push:
I left remote http(s) media URL pass-through unchanged in this PR because that is existing channel behavior outside this change set. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4699cce4a7
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
4699cce to
9062cf7
Compare
|
@codex review Rebased onto |
|
Codex Review: Didn't find any major issues. Nice work! ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback". |
* fix(qqbot): restrict structured payload local paths * fix(qqbot): narrow structured payload file access * test(qqbot): cover payload path traversal guards * fix(qqbot): reduce structured payload log exposure * fix(qqbot): preserve inline image payload URLs
* fix(qqbot): restrict structured payload local paths * fix(qqbot): narrow structured payload file access * test(qqbot): cover payload path traversal guards * fix(qqbot): reduce structured payload log exposure * fix(qqbot): preserve inline image payload URLs
* fix(qqbot): restrict structured payload local paths * fix(qqbot): narrow structured payload file access * test(qqbot): cover payload path traversal guards * fix(qqbot): reduce structured payload log exposure * fix(qqbot): preserve inline image payload URLs
* fix(qqbot): restrict structured payload local paths * fix(qqbot): narrow structured payload file access * test(qqbot): cover payload path traversal guards * fix(qqbot): reduce structured payload log exposure * fix(qqbot): preserve inline image payload URLs
* fix(qqbot): restrict structured payload local paths * fix(qqbot): narrow structured payload file access * test(qqbot): cover payload path traversal guards * fix(qqbot): reduce structured payload log exposure * fix(qqbot): preserve inline image payload URLs
* fix(qqbot): restrict structured payload local paths * fix(qqbot): narrow structured payload file access * test(qqbot): cover payload path traversal guards * fix(qqbot): reduce structured payload log exposure * fix(qqbot): preserve inline image payload URLs
* fix(qqbot): restrict structured payload local paths * fix(qqbot): narrow structured payload file access * test(qqbot): cover payload path traversal guards * fix(qqbot): reduce structured payload log exposure * fix(qqbot): preserve inline image payload URLs
Summary
Changes
Validation
pnpm test -- extensions/qqbot/src/utils/platform.test.tspnpm checkpnpm buildclaude -p "/review"and addressed the actionable feedbackNotes