Skip to content

[Security] Feishu outbound media fetch allows SSRF to loopback and internal hosts #567

Description

@YLChen-007

Advisory Details

Title: Feishu outbound media fetch allows SSRF to loopback and internal hosts

Description:

The Feishu outbound media implementation in openclaw-cn downloads attacker-controlled remote URLs with an unguarded fetch(mediaUrl) call. Because this happens before Feishu upload/authentication completes and bypasses the repository's shared SSRF-guarded media runtime, a reachable attacker-controlled media value can trigger outbound requests from the gateway host to loopback or internal network targets.

Summary

The bundled Feishu extension fetches attacker-controlled remote media URLs with a raw fetch(mediaUrl) before any Feishu upload or authentication succeeds. In deployments where a lower-trust sender, tool, or automation path can influence outbound Feishu media, this lets that actor trigger server-side requests from the gateway host to loopback or internal network targets.

Details

I verified this against the current source, the manual verification notes, and a fresh rerun of the PoC on June 19, 2026.

The issue is in the Feishu extension's outbound media path. src/infra/outbound/message-action-runner.ts reads the user-supplied media parameter, extensions/feishu/src/outbound.ts passes it into sendMediaFeishu(), and extensions/feishu/src/media.ts treats non-local values as remote URLs and fetches them directly:

} else {
  const response = await fetch(mediaUrl);
  if (!response.ok) {

The relevant data flow is:

message send --channel feishu --media <attacker URL>
  -> src/infra/outbound/message-action-runner.ts
  -> extensions/feishu/src/outbound.ts
  -> extensions/feishu/src/media.ts
  -> fetch(mediaUrl)

This bypasses the shared guarded media runtime in src/media/fetch.ts, which already routes remote downloads through fetchWithSsrFGuard(...). The problem is not that a guard is missing globally; it is that this Feishu extension path does not use the existing guard at all.

I also checked tag history for the vulnerable file. extensions/feishu/src/media.ts is present starting at v0.1.5 and remains vulnerable at the highest affected upstream tag v0.2.1. The older 2026.x tag line does not contain this extension file, so it is not the version line used for the affected range or the occurrence permalinks below.

PoC

Prerequisites

  • A checkout of the repository with dist/entry.js already built.
  • Node.js 22.12.0 or later.
  • Python 3.
  • No live Feishu tenant is required.
  • The PoC scripts create an isolated temporary OPENCLAW_STATE_DIR, enable the bundled Feishu plugin, and intentionally point the Feishu domain to https://127.0.0.1:1 so the SSRF-relevant fetch can be observed before later Feishu auth fails.

Reproduction Steps

  1. Download the verification script from: verification_test.py
  2. Download the control script from: control-local-file.py
  3. From the repository root, run the verification PoC:
    python3 verification_test.py
  4. Confirm that the script reports one canary hit for the attacker-controlled URL passed through the official CLI:
    node dist/entry.js message send --channel feishu --target user:ou_demo --media http://127.0.0.1:<port>/canary.png
  5. Run the control PoC:
    python3 control-local-file.py
  6. Confirm that the control keeps the same CLI path but switches --media to a local file and produces zero canary hits.

Log of Evidence

Fresh rerun on June 19, 2026:

Verification Mode: Integration-Test
Canary URL: http://127.0.0.1:44997/canary.png
Interface: official CLI `openclaw message send --channel feishu --media <url>`
Processing path: CLI -> message send -> Feishu outbound adapter -> sendMediaFeishu -> fetch(mediaUrl)
Canary hits observed: 1
CLI exit code: 1
[feishu] sendMediaFeishu failed: TypeError: Cannot destructure property 'tenant_access_token' ...
[DEFECT-CONFIRMED-WITH-LIMITATIONS]
Control Mode: Integration-Test baseline
Canary URL (should remain untouched): http://127.0.0.1:50721/control-canary.png
Interface: official CLI `openclaw message send --channel feishu --media <local-path>`
Canary hits observed: 0
CLI exit code: 1
[CONTROL-PASS]

The important point is the differential: both runs fail later at Feishu auth because of the intentionally invalid domain, but only the remote-URL path causes a real outbound request to the localhost canary first.

Impact

This is SSRF in a standard outbound media feature. An actor who can influence Feishu outbound media URLs can use the gateway host as an HTTP client against loopback or internal network targets. That can be used for local service discovery, internal HTTP probing, metadata endpoint access attempts, or reaching internal endpoints with side effects. Based on SECURITY.md, this is not an unauthenticated public-gateway bug by itself; the security impact depends on whether accepted channel senders, tool-enabled agents, or other lower-trust automation paths can drive Feishu outbound media with attacker-controlled URLs.

Affected products

  • Ecosystem: npm
  • Package name: openclaw-cn
  • Affected versions: >= 0.1.5, <= 0.2.1
  • Patched versions:

Severity

  • Severity: Medium
  • Vector string: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N

Weaknesses

  • CWE: CWE-918: Server-Side Request Forgery (SSRF)

Occurrences

Permalink Description
const mediaUrl = readStringParam(params, "media", { trim: false });
The shared outbound runner reads the untrimmed media parameter that later reaches the Feishu extension.
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => {
// Send text first if provided
if (text?.trim()) {
await sendMessageFeishu({ cfg, to, text, accountId: accountId ?? undefined });
}
// Upload and send media if URL provided
if (mediaUrl) {
try {
const result = await sendMediaFeishu({
cfg,
to,
mediaUrl,
accountId: accountId ?? undefined,
});
The Feishu outbound adapter forwards mediaUrl directly into sendMediaFeishu().
} else if (mediaUrl) {
if (isLocalPath(mediaUrl)) {
// Local file path - read directly
const filePath = mediaUrl.startsWith("~")
? mediaUrl.replace("~", process.env.HOME ?? "")
: mediaUrl.replace("file://", "");
if (!fs.existsSync(filePath)) {
throw new Error(`Local file not found: ${filePath}`);
}
buffer = fs.readFileSync(filePath);
name = fileName ?? path.basename(filePath);
} else {
// Remote URL - fetch
const response = await fetch(mediaUrl);
if (!response.ok) {
throw new Error(`Failed to fetch media from URL: ${response.status}`);
}
buffer = Buffer.from(await response.arrayBuffer());
name = fileName ?? (path.basename(new URL(mediaUrl).pathname) || "file");
The vulnerable branch treats non-local values as remote URLs and performs a raw fetch(mediaUrl) without SSRF protections.
export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<FetchMediaResult> {
const { url, fetchImpl, filePathHint, maxBytes, maxRedirects, ssrfPolicy, lookupFn } = options;
let res: Response;
let finalUrl = url;
let release: (() => Promise<void>) | null = null;
try {
const result = await fetchWithSsrFGuard({
url,
fetchImpl,
maxRedirects,
policy: ssrfPolicy,
lookupFn,
});
res = result.response;
finalUrl = result.finalUrl;
release = result.release;
} catch (err) {
The repository already has a guarded remote-media helper using fetchWithSsrFGuard(...); the vulnerable Feishu extension path bypasses this helper entirely.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions