Merged
Size
M
Change Breakdown
Security60%
Bug Fix25%
CI/CD10%
Maintenance5%
#63495fix(qqbot): guard image-size probe against SSRF

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() in extensions/qqbot/src/utils/image-size.ts used a bare fetch() 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() with fetchRemoteMedia() from the plugin SDK (maxBytes: 65536, maxRedirects: 0, generic public-network-only SSRF policy). Also fixed scripts/lib/ts-guard-utils.mjs repo-root resolution so lint:tmp:no-raw-channel-fetch actually 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. The downloadFile() 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 bare fetch() with no SSRF guard. The function was added to support QQ Bot markdown image dimension probing and was never routed through the shared fetchRemoteMedia() / fetchWithSsrFGuard() infrastructure.
  • Missing detection / guardrail: lint:tmp:no-raw-channel-fetch was false-green because scripts/lib/ts-guard-utils.mjs:resolveRepoRoot() hardcoded two parent traversals from the caller's import.meta.url. Callers at scripts/*.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 (uses fetchRemoteMedia with QQBOT_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() returns null for loopback, IPv6 loopback, link-local/metadata, and RFC1918 addresses (all via https:// to isolate SSRF blocking from protocol checks); passes correct maxBytes, maxRedirects, ssrfPolicy options to fetchRemoteMedia.
  • Why this is the smallest reliable guardrail: Tests mock fetchRemoteMedia at 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 when getImageSize returns null or throws. test/scripts/ts-guard-utils.test.ts (4 tests) locks in resolveRepoRoot() 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 with fetchRemoteMedia() (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:

  1. node --import tsx -e probe against https://127.0.0.1/... -> returns null with [security] blocked URL fetch log
  2. Same for 169.254.169.254, 10.0.0.1, 192.168.1.1, 172.16.0.1, [::1] -> all blocked, all return null in <5ms
  3. Public HTTPS image (upload.wikimedia.org PNG) -> returns { width: 800, height: 600 } after warm-up
  4. pnpm run lint:tmp:no-raw-channel-fetch -> exit 0 (passes with fix)
  5. 21 automated tests pass (12 + 5 + 4)
  6. pnpm check + pnpm build pass

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 fetch log 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, and pnpm build. SSRF blocking (Steps 2-6) confirmed via direct node --import tsx -e scripts calling getImageSizeFromUrl() 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 fetchRemoteMedia call (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: 0 could 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.
  • Risk: Cold-start latency on first fetchRemoteMedia call (30-60s on some boxes) could cause the 5s default timeout in getImageSizeFromUrl to 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.
© 2026 · via Gitpulse