Image-size probe patched against SSRF attacks
The QQBot gateway no longer accepts arbitrary image URLs that could be weaponized for Server-Side Request Forgery against internal networks.
The QQBot extension probed remote image URLs to extract dimensions for markdown rendering. The function used a bare fetch() with no restrictions on where requests could go. An attacker who could control an image URL in a message could have turned the gateway into a blind SSRF client—probing cloud metadata endpoints, internal services, or mapping private networks.
Image-size probes now route through a guarded fetch that blocks private, loopback, link-local, and metadata addresses. IPv4 loopback (127.0.0.1), IPv6 loopback ([::1]), RFC1918 private ranges (10.x, 192.168.x, 172.16.x), and the cloud metadata endpoint (169.254.169.254) are all rejected. Public images still return real dimensions. Blocked URLs return null within milliseconds, and callers fall back to default sizing—message delivery is unaffected.
The fix lives in the QQBot extension's image-size utility. A secondary issue in the repo-root resolution logic, which had prevented the lint check from scanning extension files, was also corrected. The lint script now walks up to the .git directory instead of hardcoding traversal depth.
View Original GitHub Description
Summary
- Problem:
getImageSizeFromUrl()inextensions/qqbot/src/utils/image-size.tsused a barefetch()to probe remote image dimensions for QQ markdown. An attacker who could induce a reply containing a chosen image URL could turn the gateway into a blind SSRF client against loopback, RFC1918, link-local, or metadata endpoints. - Why it matters: Blind SSRF against cloud metadata endpoints (
169.254.169.254) can leak instance credentials; probes against internal services can map the private network. - What changed: Replaced the raw
fetch()withfetchRemoteMedia()from the plugin SDK (maxBytes: 65536,maxRedirects: 0, generic public-network-only SSRF policy). Also fixedscripts/lib/ts-guard-utils.mjsrepo-root resolution solint:tmp:no-raw-channel-fetchactually scans extension files. - What did NOT change (scope boundary): Direct-upload paths (
sendPhoto/sendVoice/sendVideoMsg) that hand URLs to QQ's API are out of scope — the fetch happens on QQ's servers, not the gateway. ThedownloadFile()path was already fixed in a prior release.
Change Type (select all)
- Bug fix
- Security hardening
Scope (select all touched areas)
- Integrations
- CI/CD / infra
Linked Issue/PR
- This PR fixes a bug or regression
Root Cause (if applicable)
- Root cause:
getImageSizeFromUrl()used a barefetch()with no SSRF guard. The function was added to support QQ Bot markdown image dimension probing and was never routed through the sharedfetchRemoteMedia()/fetchWithSsrFGuard()infrastructure. - Missing detection / guardrail:
lint:tmp:no-raw-channel-fetchwas false-green becausescripts/lib/ts-guard-utils.mjs:resolveRepoRoot()hardcoded two parent traversals from the caller'simport.meta.url. Callers atscripts/*.mjs(one level below root) overshot to the repo's parent, so extension files were never scanned. - Contributing context: The
downloadFile()path in the same extension was already fixed (usesfetchRemoteMediawithQQBOT_MEDIA_SSRF_POLICY), but the image-size probe was a separate code path that was missed.
Regression Test Plan (if applicable)
- Coverage level that should have caught this:
- Unit test
- Seam / integration test
- Target test or file:
extensions/qqbot/src/utils/image-size.test.ts(new, 12 tests) - Scenario the test should lock in:
getImageSizeFromUrl()returnsnullfor loopback, IPv6 loopback, link-local/metadata, and RFC1918 addresses (all viahttps://to isolate SSRF blocking from protocol checks); passes correctmaxBytes,maxRedirects,ssrfPolicyoptions tofetchRemoteMedia. - Why this is the smallest reliable guardrail: Tests mock
fetchRemoteMediaat the module boundary — verifies the options contract and the null-return degradation without needing network access or a live QQBot account. - Additional coverage:
outbound-deliver.test.ts(2 new cases) confirms callers degrade gracefully whengetImageSizereturns null or throws.test/scripts/ts-guard-utils.test.ts(4 tests) locks inresolveRepoRoot()for callers at different directory depths.
User-visible / Behavior Changes
None. getImageSizeFromUrl() already returns null on any fetch failure; callers in outbound-deliver.ts already apply 512x512 default dimensions. SSRF-blocked URLs silently degrade to default sizing — message delivery is unaffected.
Diagram (if applicable)
N/A
Security Impact (required)
- New permissions/capabilities? No
- Secrets/tokens handling changed? No
- New/changed network calls? Yes — raw
fetch()replaced withfetchRemoteMedia()(DNS-pinned, SSRF-guarded,maxRedirects: 0,maxBytes: 65536) - Command/tool execution surface changed? No
- Data access scope changed? No
- Risk + mitigation: The change narrows the network surface. Previously, any URL (including private IPs) could be probed. Now only public-network destinations are reachable, with no redirect following. False positives degrade gracefully (null -> default image size).
Repro + Verification
Environment
- OS: macOS (local), Ubuntu 24.04 (bigbox remote)
- Runtime: Node 22
- Integration: QQBot extension (no live QQ account needed)
Steps
Verified on a remote Linux box using direct probe scripts:
node --import tsx -eprobe againsthttps://127.0.0.1/...-> returnsnullwith[security] blocked URL fetchlog- Same for
169.254.169.254,10.0.0.1,192.168.1.1,172.16.0.1,[::1]-> all blocked, all returnnullin <5ms - Public HTTPS image (
upload.wikimedia.orgPNG) -> returns{ width: 800, height: 600 }after warm-up pnpm run lint:tmp:no-raw-channel-fetch-> exit 0 (passes with fix)- 21 automated tests pass (12 + 5 + 4)
pnpm check+pnpm buildpass
Expected
- Private/reserved/loopback/link-local/metadata URLs blocked (return
null) - Public URLs still return real image dimensions
- No crash, no hang, no behavior change for delivered messages
Actual
- All blocked as expected
- Public URL returns
{ width: 800, height: 600 } - Blocked URLs return in <5ms with
[security] blocked URL fetchlog line
Evidence
- Failing test/log before + passing after
- Trace/log snippets
SSRF block log output from remote verification:
[security] blocked URL fetch (url-fetch) target=https://127.0.0.1/ssrf-test.png reason=Blocked hostname or private/internal/special-use IP address
[security] blocked URL fetch (url-fetch) target=https://169.254.169.254/latest/meta-data/ reason=Blocked hostname or private/internal/special-use IP address
[security] blocked URL fetch (url-fetch) target=https://10.0.0.1/i.png reason=Blocked hostname or private/internal/special-use IP address
[security] blocked URL fetch (url-fetch) target=https://192.168.1.1/i.png reason=Blocked hostname or private/internal/special-use IP address
[security] blocked URL fetch (url-fetch) target=https://172.16.0.1/i.png reason=Blocked hostname or private/internal/special-use IP address
[security] blocked URL fetch (url-fetch) target=https://[::1]/i.png reason=Blocked hostname or private/internal/special-use IP address
Human Verification (required)
- Verified scenarios: All 9 steps of the human verification runbook executed on a remote Ubuntu 24.04 box with a fresh clone,
pnpm install, andpnpm build. SSRF blocking (Steps 2-6) confirmed via directnode --import tsx -escripts callinggetImageSizeFromUrl()against private-network URLs. Public image probe (Step 1) confirmed after warm-up. Lint guardrail (Step 7), automated tests (Step 8), and full build/check (Step 9) all verified. - Edge cases checked: Cold-start timeout on first
fetchRemoteMediacall (takes 30-60s due to DNS-pinning/undici init; documented in runbook with warm-up call). IPv6 loopback. All three RFC1918 ranges. - What was NOT verified: Live QQBot message delivery (requires a QQ Bot Platform account with approval). The unit/integration tests and direct probe scripts cover the SSRF guard behavior without needing a live account.
Review Conversations
- I replied to or resolved every bot review conversation I addressed in this PR.
- I left unresolved only the conversations that still need reviewer or maintainer judgment.
Compatibility / Migration
- Backward compatible? Yes
- Config/env changes? No
- Migration needed? No
Risks and Mitigations
- Risk:
maxRedirects: 0could break image dimension probing for CDNs that require one redirect hop.- Mitigation: No known QQ-ecosystem CDN requires a redirect for image hosting. If discovered, bump to
maxRedirects: 1— the SSRF guard re-validates each hop regardless.
- Mitigation: No known QQ-ecosystem CDN requires a redirect for image hosting. If discovered, bump to
- Risk: Cold-start latency on first
fetchRemoteMediacall (30-60s on some boxes) could cause the 5s default timeout ingetImageSizeFromUrlto fire before the SSRF guard completes DNS resolution.- Mitigation: This is a pre-existing characteristic of the SSRF guard infrastructure, not introduced by this change. In production, the gateway process is long-lived and the dispatcher is warm after the first call.