Skip to content

fix(media): disable pinned DNS dispatcher for FormData transcription requests#64766

Merged
obviyus merged 3 commits intoopenclaw:mainfrom
GodsBoy:fix/ssrf-formdata-multipart-corruption
Apr 11, 2026
Merged

fix(media): disable pinned DNS dispatcher for FormData transcription requests#64766
obviyus merged 3 commits intoopenclaw:mainfrom
GodsBoy:fix/ssrf-formdata-multipart-corruption

Conversation

@GodsBoy
Copy link
Copy Markdown
Contributor

@GodsBoy GodsBoy commented Apr 11, 2026

Summary

  • Problem: The SSRF guard's pinned DNS dispatcher (undici) corrupts FormData multipart bodies, causing audio transcription to fail with HTTP 400 on OpenAI-compatible providers (OpenAI, Groq, Mistral). The API returns "you must provide a model parameter" even though all fields are correctly appended.
  • Why it matters: Audio transcription is completely broken for all OpenAI-compatible providers — voice notes and audio files cannot be processed.
  • What changed:
    1. postTranscriptionRequest now always passes pinDns: false to fetchWithTimeoutGuarded, ensuring native fetch handles FormData encoding correctly instead of the undici dispatcher.
    2. fetchWithSsrFGuard now runs resolvePinnedHostnameWithPolicy in the pinDns === false path, closing a pre-existing SSRF bypass where hostname validation was skipped entirely when DNS pinning was disabled. This is a defense-in-depth improvement that affects all pinDns: false callers.
  • What did NOT change (scope boundary): postJsonRequest behavior is unchanged. The pinDns === false dispatcher selection logic is unchanged — only hostname validation was added as a preflight.
  • Other pinDns: false callers verified: extensions/google/image-generation-provider.ts and extensions/minimax/music-generation-provider.ts both use pinDns: false targeting public API endpoints (Google Generative AI, MiniMax API) with allowPrivateNetwork and dispatcherPolicy from provider config. These are unaffected by the new hostname validation since they target public endpoints.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

Root Cause (if applicable)

  • Root cause: When postTranscriptionRequest sends a FormData body without allowPrivateNetwork or dispatcherPolicy, the options argument to fetchWithTimeoutGuarded is undefined. This causes pinDns to default to undefined, which fetchWithSsrFGuard treats as truthy — creating an undici pinned DNS dispatcher. Undici's fetch corrupts FormData multipart encoding.
  • Missing detection / guardrail: No test verified that postTranscriptionRequest avoids the pinned DNS dispatcher by default. Additionally, the pinDns === false path in fetchWithSsrFGuard skipped hostname validation entirely — a pre-existing SSRF bypass.
  • Contributing context (if known): The resolveGuardedPostRequestOptions helper returns undefined when no options are set, so pinDns was never explicitly set to false for the common case.

Regression Test Plan (if applicable)

  • Coverage level that should have caught this:
    • Unit test
    • Seam / integration test
    • End-to-end test
    • Existing coverage already sufficient
  • Target test or file: src/media-understanding/shared.test.ts
  • Scenario the test should lock in: postTranscriptionRequest always passes pinDns: false to the SSRF guard, even when the caller does not specify it.
  • Why this is the smallest reliable guardrail: The unit test mocks fetchWithSsrFGuard and asserts the pinDns field is always false for transcription requests, catching any regression that re-enables the pinned dispatcher.
  • Existing test that already covers this (if any): Existing test only verified explicit pinDns: false passthrough — updated to verify it's always set.

User-visible / Behavior Changes

Audio transcription via OpenAI-compatible providers (OpenAI, Groq, Mistral) now works correctly. Voice notes and audio files are transcribed successfully.

Diagram (if applicable)

Before:
[voice note] -> postTranscriptionRequest(FormData) -> fetchWithTimeoutGuarded(pinDns=undefined)
  -> undici dispatcher CORRUPTS multipart body -> HTTP 400 "missing model parameter"

After:
[voice note] -> postTranscriptionRequest(FormData) -> fetchWithTimeoutGuarded(pinDns=false)
  -> hostname validation runs (defense-in-depth) -> native fetch preserves multipart body
  -> HTTP 200 transcription success

Security Impact (required)

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? No
  • New/changed network calls? No — same network call, different dispatcher selection
  • Command/tool execution surface changed? No
  • Data access scope changed? No
  • Security improvement: The pinDns === false path in fetchWithSsrFGuard now runs resolvePinnedHostnameWithPolicy as a preflight, closing a pre-existing bypass where hostname/private-IP validation was skipped entirely. Note: a TOCTOU gap remains between the DNS validation and the actual fetch (DNS rebinding), which is an inherent trade-off of not using the pinned dispatcher. Transcription URLs come from operator-controlled provider config, mitigating this risk.

Repro + Verification

Environment

  • OS: Ubuntu 24.04 (Linux 6.8.0-107-generic)
  • Runtime/container: Node.js v22.22.0
  • Model/provider: openai/gpt-4o-transcribe
  • Integration/channel (if any): Telegram voice notes
  • Relevant config (redacted): Standard OpenAI API key config

Steps

  1. Configure OpenClaw with OpenAI API key and gpt-4o-transcribe.
  2. Send a Telegram voice note to an agent.
  3. Observe transcription result.

Expected

  • Voice note is transcribed and agent responds with transcription text.

Actual

  • Before fix: HTTP 400 "you must provide a model parameter"
  • After fix: Transcription succeeds, agent responds correctly.

Evidence

  • Failing test/log before + passing after
  • Trace/log snippets

Runtime patch on compiled output confirmed the fix before this source change. See #64762 for detailed evidence.

Human Verification (required)

  • Verified scenarios: Unit tests pass (13/13 shared, 39/39 SSRF guard), including new tests verifying pinDns: false is always set for transcription requests and NOT forced for JSON requests.
  • Edge cases checked: Verified allowPrivateNetwork: true + auditContext still pass through correctly alongside pinDns: false. Verified other pinDns: false callers (Google image gen, MiniMax music gen) target public endpoints and are unaffected.
  • What you did NOT verify: Live end-to-end transcription with this source change (runtime patch on compiled output was verified previously).

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: Disabling pinned DNS removes dispatcher-level DNS rebinding protection for transcription requests.
    • Mitigation: resolvePinnedHostnameWithPolicy now runs as a preflight even in the pinDns === false path, validating hostnames and blocking private IPs. The remaining TOCTOU gap requires an attacker to control DNS for the target hostname (operator-configured, not user-input). Transcription endpoints are always public HTTPS APIs.
  • Risk: New hostname validation in fetchWithSsrFGuard's pinDns === false path could block other callers.
    • Mitigation: Verified all other pinDns: false callers (Google image gen, MiniMax music gen) target public API endpoints and pass allowPrivateNetwork/dispatcherPolicy from provider config. No breakage expected.

Post-Deploy Monitoring & Validation

  • Log queries: Search for Audio transcription failed (HTTP 400) — should drop to zero after deploy.
  • Expected healthy signals: Successful audio transcription responses from OpenAI/Groq/Mistral providers.
  • Failure signals: Any new HTTP 400 errors from transcription endpoints, or unexpected SSRF blocks from Google/MiniMax providers.
  • Validation window: 24 hours post-deploy.

@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot Bot commented Apr 11, 2026

🔒 Aisle Security Analysis

We found 2 potential security issue(s) in this PR:

# Severity Title
1 🟠 High SSRF guard bypass via DNS rebinding/TOCTOU when pinDns === false
2 🟠 High DNS rebinding SSRF risk by disabling pinned DNS in OpenAI-compatible multipart transcription request
1. 🟠 SSRF guard bypass via DNS rebinding/TOCTOU when `pinDns === false`
Property Value
Severity High
CWE CWE-918
Location src/infra/net/fetch-guard.ts:318-326

Description

The SSRF protection in fetchWithSsrFGuard performs a DNS resolution/policy check when pinDns === false, but then does not pin or enforce those resolved IPs for the actual connection.

  • Input: params.url (potentially attacker-controlled) provides parsedUrl.hostname
  • Check: resolvePinnedHostnameWithPolicy() resolves DNS and validates the current answers against the SSRF policy
  • Use: the dispatcher created by createPolicyDispatcherWithoutPinnedDns() performs the real connection using the system/undici resolver, allowing the hostname to resolve again later (or differently on retries/redirects/pooling)

This creates a classic time-of-check/time-of-use window enabling DNS rebinding: an attacker-controlled hostname can resolve to a public IP during the check, then rebind to a private/internal IP when the connection is made, bypassing private-network blocks.

Vulnerable code:

} else if (params.pinDns === false) {
  await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, {
    lookupFn: params.lookupFn,
    policy: params.policy,
  });
  dispatcher = createPolicyDispatcherWithoutPinnedDns(params.dispatcherPolicy);
}

Recommendation

Avoid check-then-connect without pinning.

Options:

  1. Do not allow pinDns: false for untrusted/user-controlled URLs (keep default pinning enabled), especially in request paths that accept external input.
  2. If pinDns: false must exist, enforce SSRF policy at connection-time by providing a custom connect.lookup that re-validates resolved IPs on every lookup (not just once), or by continuing to use the pinned lookup from resolvePinnedHostnameWithPolicy.

Example (pin the lookup even when you don't want to “freeze” a single address, but still want policy enforcement):

// Use a lookup wrapper that calls dns.lookup(all:true) and rejects private/special-use// results before returning them to undici.
const policyLookup = async (hostname: string, opts: any, cb: any) => {
  const results = await dnsLookup(hostname, { all: true });
  assertAllowedResolvedAddressesOrThrow(results, params.policy);// then pass through to undici in expected callback form
};

return createHttp1Agent({ connect: { lookup: policyLookup } });

Also reconsider call sites newly setting pinDns: false (e.g., audio transcription) unless the target hostname is fully trusted/operator-controlled.

2. 🟠 DNS rebinding SSRF risk by disabling pinned DNS in OpenAI-compatible multipart transcription request
Property Value
Severity High
CWE CWE-918
Location src/media-understanding/openai-compatible-audio.ts:57-66

Description

transcribeOpenAiCompatibleAudio now disables DNS pinning (pinDns: false) for the multipart transcription request.

While the SSRF guard still performs an initial DNS resolution check (via resolvePinnedHostnameWithPolicy(...) even when pinDns === false), the connection is no longer forced to use the vetted IPs. This re-opens a classic DNS rebinding window:

  • Input: baseUrl can be provided via params.baseUrl (and is merged into resolveProviderHttpRequestConfig), so callers may influence the request target hostname.
  • Check: SSRF guard resolves the hostname once and verifies the resolved IPs are not private.
  • Sink: because pinning is disabled, undici performs a fresh DNS lookup at connection time, which can return a different IP (e.g., an internal/private IP) after the initial check.

This can allow SSRF to internal services if an attacker controls a hostname used in baseUrl and can rebind DNS between validation and connection.

Vulnerable code:

const { response: res, release } = await postTranscriptionRequest({
  url,
  headers,
  body: form,
  timeoutMs: params.timeoutMs,
  fetchFn,
  pinDns: false,
  allowPrivateNetwork,
  dispatcherPolicy,
});

Recommendation

Do not disable DNS pinning for user-influenceable targets.

Options:

  1. Re-enable pinning (preferred):
const { response: res, release } = await postTranscriptionRequest({
  url,
  headers,
  body: form,
  timeoutMs: params.timeoutMs,
  fetchFn,// omit pinDns or set to true
  allowPrivateNetwork,
  dispatcherPolicy,
});
  1. If multipart compatibility truly requires pinDns: false, add a strict hostname allowlist for this API (and/or disallow caller-provided baseUrl), so attacker-controlled domains cannot be used.

  2. Alternatively, change the implementation so the dispatcher still uses a pinned lookup (pinning) while addressing the multipart issue separately; DNS pinning itself is orthogonal to multipart bodies and is the primary defense against rebinding.


Analyzed PR: #64766 at commit a350430

Last updated on: 2026-04-11T12:40:08Z

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 11, 2026

Greptile Summary

This PR fixes audio transcription failures for all OpenAI-compatible providers by ensuring postTranscriptionRequest always sets pinDns: false, preventing undici's pinned DNS dispatcher from being selected and corrupting the FormData multipart body. As a defense-in-depth improvement, fetchWithSsrFGuard now also runs resolvePinnedHostnameWithPolicy in the pinDns === false branch, closing a pre-existing bypass where hostname/private-IP validation was skipped entirely for non-pinned requests.

Confidence Score: 5/5

This PR is safe to merge; the fix is narrowly scoped, well-tested, and the acknowledged TOCTOU tradeoff is acceptable given operator-controlled transcription endpoints.

All three files make coherent, complementary changes. The root cause (undici dispatcher corrupting FormData when pinDns defaults to undefined) is correctly addressed. The new hostname validation in the pinDns===false path is consistent with the existing pinned-DNS path. Tests cover the default case, the with-options case, and the negative case for JSON requests. No P0 or P1 findings.

No files require special attention.

Reviews (3): Last reviewed commit: "fix(ssrf): validate hostname even when p..." | Re-trigger Greptile

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4d5695b5d3

ℹ️ 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".

Comment thread src/media-understanding/shared.ts Outdated
@GodsBoy
Copy link
Copy Markdown
Contributor Author

GodsBoy commented Apr 11, 2026

@aisle-research-bot Valid finding — addressed in e49b536.

The pinDns === false path in fetchWithSsrFGuard now calls resolvePinnedHostnameWithPolicy as a preflight before creating the non-pinned dispatcher. This preserves SSRF hostname validation and private-IP checks while still avoiding the undici dispatcher that corrupts FormData multipart bodies.

The fix is at the guard level so all pinDns: false callers get defense-in-depth, matching the recommendation in your analysis.

@GodsBoy
Copy link
Copy Markdown
Contributor Author

GodsBoy commented Apr 11, 2026

@greptile-apps P2 addressed in e49b536 — renamed the test to "always passes pinDns: false to the SSRF guard for transcription requests" to reflect the new behavior where pinDns: false is unconditionally injected by the implementation.

@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot Bot commented Apr 11, 2026

🔒 Aisle Security Analysis

We found 1 potential security issue(s) in this PR:

# Severity Title
1 🟠 High SSRF policy bypass via DNS rebinding when pinDns === false (TOCTOU between DNS check and fetch)
1. 🟠 SSRF policy bypass via DNS rebinding when `pinDns === false` (TOCTOU between DNS check and fetch)
Property Value
Severity High
CWE CWE-367
Location src/infra/net/fetch-guard.ts:318-335

Description

In fetchWithSsrFGuard, when callers set pinDns === false, the code performs a one-time DNS resolution/policy validation via resolvePinnedHostnameWithPolicy(...), but then does not pin the resolved IP(s) for the actual network connection.

  • Input: params.url (potentially attacker-controlled) provides parsedUrl.hostname.
  • Check: resolvePinnedHostnameWithPolicy() resolves the hostname and enforces SSRF policy (e.g., blocks private IPs).
  • Use: The subsequent request uses createPolicyDispatcherWithoutPinnedDns(...) (or potentially no dispatcher), which relies on the runtime/system DNS again when establishing the connection.

Because the checked DNS answer is not bound to the used connection, an attacker controlling DNS for a hostname can exploit DNS rebinding / round-robin to pass the policy check with a public IP and then have the actual fetch resolve to a private IP (e.g., 127.0.0.1, RFC1918, link-local, etc.) after TTL expiry or via alternating answers.

Vulnerable code (new behavior):

} else if (params.pinDns === false) {
  await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, {
    lookupFn: params.lookupFn,
    policy: params.policy,
  });
  dispatcher = createPolicyDispatcherWithoutPinnedDns(params.dispatcherPolicy);
}

This creates a classic time-of-check/time-of-use gap in SSRF protection.

Recommendation

Avoid performing DNS-based IP allow/deny checks unless you can ensure the same resolved address is used for the connection.

Options:

  1. Do not allow pinDns: false for untrusted/user-controlled URLs (only allow it for trusted allowlisted domains with additional safeguards).

  2. If you must support pinDns: false, enforce SSRF policy at connection time by pinning DNS or by using a transport hook that validates the remote IP address after connection establishment (and on redirects).

  3. Preferably, still use the pinned lookup even if you can't use the pinned dispatcher due to FormData issues—fix the FormData corruption bug instead of disabling pinning for security-sensitive paths.

Example secure approach (keep pinning and use the pinned dispatcher):

const pinned = await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, {
  lookupFn: params.lookupFn,
  policy: params.policy,
});
dispatcher = createPinnedDispatcher(pinned, params.dispatcherPolicy, params.policy);

If pinDns: false remains supported, document clearly that it weakens SSRF protections and gate it behind a strict trusted-mode flag.


Analyzed PR: #64766 at commit e49b536

Last updated on: 2026-04-11T11:03:29Z

@GodsBoy
Copy link
Copy Markdown
Contributor Author

GodsBoy commented Apr 11, 2026

@aisle-research-bot Acknowledged — the TOCTOU/DNS rebinding gap between the hostname validation and the actual fetch is an inherent trade-off of the pinDns: false mode.

Why we accept this trade-off here:

  1. Transcription URLs come from operator-controlled provider configuration (OpenAI, Groq, Mistral base URLs), not end-user input. An attacker would need to compromise the provider config to exploit this.
  2. The alternative is broken audio transcription — the pinned dispatcher corrupts FormData multipart bodies, which is the bug this PR fixes. Keeping the pinned dispatcher means transcription stays broken.
  3. The SSRF hostname validation we added is still defense-in-depth — it blocks obvious private IPs and blocklisted hostnames on the initial resolve. The rebinding window requires the attacker to control DNS for the target hostname AND time the TTL rotation between our check and the native fetch.
  4. Fixing the root cause (undici's FormData corruption with pinned dispatchers) is an upstream issue outside this PR's scope.

The pinDns: false code path is intentionally documented as weaker SSRF protection in exchange for correct FormData handling. A connect-time IP validation hook would close the gap but is a larger architectural change for a follow-up.

@GodsBoy
Copy link
Copy Markdown
Contributor Author

GodsBoy commented Apr 11, 2026

@greptile-apps Both concerns addressed:

  1. PR description corrected — now explicitly documents the fetchWithSsrFGuard change, the security hardening it provides, and the scope expansion.

  2. Other pinDns: false callers verified — only two other production callers exist:

    • extensions/google/image-generation-provider.ts:170 — targets Google Generative AI public API
    • extensions/minimax/music-generation-provider.ts:202 — targets MiniMax public API

    Both pass allowPrivateNetwork and dispatcherPolicy from provider config and target public endpoints. They are unaffected by the new hostname validation.

@GodsBoy
Copy link
Copy Markdown
Contributor Author

GodsBoy commented Apr 11, 2026

@greptile review

@obviyus obviyus self-assigned this Apr 11, 2026
GodsBoy and others added 3 commits April 11, 2026 18:03
…requests

The SSRF guard's pinned DNS dispatcher (undici) corrupts FormData
multipart bodies, causing audio transcription to fail with HTTP 400
on OpenAI-compatible providers. Always set pinDns: false in
postTranscriptionRequest so native fetch handles FormData correctly.

SSRF hostname validation is preserved via resolvePinnedHostnameWithPolicy.
When pinDns=false was set to avoid undici dispatcher corruption of
FormData bodies, resolvePinnedHostnameWithPolicy was skipped entirely,
removing SSRF hostname/private-IP validation.

Now the pinDns=false path runs hostname validation as a preflight
before creating the non-pinned dispatcher, preserving defense-in-depth.

Also renames a stale test description per Greptile review feedback.
@obviyus obviyus force-pushed the fix/ssrf-formdata-multipart-corruption branch from 2a80917 to a350430 Compare April 11, 2026 12:34
Copy link
Copy Markdown
Contributor

@obviyus obviyus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the multipart transcription failure path and confirmed the fix is scoped to the OpenAI-compatible FormData caller while the generic shared transcription helper stays on the normal pinned-DNS path.

Maintainer follow-up: moved pinDns: false out of postTranscriptionRequest, added a caller-specific regression test, and appended the Unreleased changelog entry for #64766.

Local gate: pnpm test src/media-understanding/shared.test.ts src/media-understanding/openai-compatible-audio.test.ts src/media-understanding/openai-compatible-audio.pin-dns.test.ts extensions/deepgram/audio.test.ts.

@obviyus obviyus merged commit 43bd554 into openclaw:main Apr 11, 2026
20 of 26 checks passed
@obviyus
Copy link
Copy Markdown
Contributor

obviyus commented Apr 11, 2026

Landed on main.

Thanks @GodsBoy.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a350430d8a

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +321 to +324
await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, {
lookupFn: params.lookupFn,
policy: params.policy,
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip local DNS preflight for pinDns=false proxy flows

Calling resolvePinnedHostnameWithPolicy in the pinDns === false branch makes every request depend on local DNS resolution before the proxy dispatcher is used. For dispatcherPolicy.mode === "explicit-proxy" (and similarly proxy-only environments), the target hostname may only be resolvable by the proxy, so this now throws before fetchImpl runs even though the proxy route itself is valid. This regresses existing pinDns: false callers (for example provider requests with explicit proxy overrides) in corporate/proxy-only networks.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: SSRF guard pinned DNS dispatcher corrupts FormData multipart bodies, breaking audio transcription

2 participants