[升级v2026.4.14后专用网络音频转录SSRF拦截错误] - SSRF Block on Private Network Audio Transcription After v2026.4.14 Upgrade
语音消息转录到专用IP的自托管STT端点失败,即使已配置 models.providers.*.request.allowPrivateNetwork,仍会抛出 SsrFBlockedError,这是由提供商请求解析中的两个级联bug导致的。
🔍 症状
主要错误表现
向专用 LAN IP 上的 OpenAI 兼容端点发出的音频转录请求被 SSRF 违规阻止,尽管已明确配置允许专用网络访问。
[security] blocked URL fetch (url-fetch) target=http://192.168.x.x:5092/v1/audio/transcriptions reason=Blocked hostname or private/internal/special-use IP address
[media-understanding] audio: failed (0/1) reason=SsrFBlockedError受影响的操作
- 通过自托管 STT 端点上的
parakeet模型进行语音消息转录 - 任何针对专用/内部 IP 地址的
media.audio工具调用 - LAN 地址上的 OpenAI 兼容 API 端点(例如
192.168.x.x、10.x.x.x、172.16.x.x)
应该生效的配置
以下是为 STT 提供商启用专用网络访问的文档化方法:
{
"models": {
"providers": {
"openai": {
"apiKey": "local",
"baseUrl": "http://192.168.x.x:5092/v1",
"request": {
"allowPrivateNetwork": true
}
}
}
},
"tools": {
"media": {
"audio": {
"enabled": true,
"models": [{ "provider": "openai", "model": "parakeet" }]
}
}
}
}版本上下文
| Version | Behavior |
|---|---|
| v2026.4.12 | ✅ 正常工作 |
| v2026.4.13 | ✅ 正常工作 |
| v2026.4.14 | ❌ SSRF 阻止 — 引入回归 |
诊断命令输出
启用调试时,日志中可能出现以下内容:
# Enable debug logging to trace request resolution
DEBUG=openclaw:* node index.js
# Expected SSRF block in output:
[security] blocked URL fetch (url-fetch) target=http://192.168.x.x:5092/v1/audio/transcriptions reason=Blocked hostname or private/internal/special-use IP address🧠 根因分析
概述
v2026.4.14 版本中的两个独立缺陷形成级联,悄悄地从提供商的请求设置中丢弃了 allowPrivateNetwork 配置。两个缺陷必须同时存在才能复现失败,两个缺陷都必须修复才能恢复正常行为。
缺陷 1:resolveProviderExecutionContext 从提供商配置中丢弃 allowPrivateNetwork
受影响文件: runner.entries-*.js(dist 文件)
代码路径:
resolveProviderExecutionContext 函数通过以下合并链构建传递给 transcribeAudio 的 request 对象:
javascript request: mergeProviderRequestOverrides( sanitizeConfiguredProviderRequest(params.config?.request), sanitizeConfiguredProviderRequest(params.entry.request) )
根本原因:
该函数仅合并:
params.config?.request— 工具级请求配置(来自tools.media.audio.request)params.entry.request— 条目级请求覆盖
关键在于,提供商级配置(models.providers.<id>.request)在此合并中从未被包含。sanitizeConfiguredProviderRequest 函数明确过滤为仅包含以下字段:
javascript // Fields preserved by sanitizeConfiguredProviderRequest const ALLOWED_REQUEST_FIELDS = [‘headers’, ‘auth’, ‘proxy’, ’tls’]; // Note: ‘allowPrivateNetwork’ is intentionally NOT in this list
结果: 即使操作员正确配置了:
json “models”: { “providers”: { “openai”: { “request”: { “allowPrivateNetwork”: true } } } }
此值被悄悄丢弃,因为在音频转录的请求对象构建过程中从未查询提供商配置。
缺陷 2:resolveProviderRequestPolicyConfig 忽略 params.request 中的 allowPrivateNetwork
受影响文件: provider-request-config-*.js(dist 文件)
代码路径:
resolveProviderRequestPolicyConfig 函数返回解析后的安全策略:
javascript allowPrivateNetwork: params.allowPrivateNetwork ?? false
根本原因:
该函数仅检查 params.allowPrivateNetwork — 这是一个调用方必须显式传递的直参参数。然而,所有音频转录调用方都从 resolveProviderHttpRequestConfig 派生其请求配置,并将其作为 request: params.request 传递。
调用方(例如 transcribeOpenAiCompatibleAudio、transcribeDeepgramAudio)设置:
javascript { request: resolveProviderHttpRequestConfig({ model: params.model, provider: params.provider, // allowPrivateNetwork IS present here from Bug 1 fix }) }
但 resolveProviderRequestPolicyConfig 接收 params.request?.allowPrivateNetwork 并从不检查它。该值仅从扁平的 params.allowPrivateNetwork 参数读取,而音频调用方不会显式设置此参数。
结果: 即使缺陷 1 被修复且 allowPrivateNetwork 被正确放入 params.request,缺陷 2 也会在策略解析期间悄悄丢弃它。
失败序列图
┌─────────────────────────────────────────────────────────────────────────┐ │ CORRECT BEHAVIOR (v2026.4.12/13) │ ├─────────────────────────────────────────────────────────────────────────┤ │ models.providers.openai.request.allowPrivateNetwork = true │ │ │ │ │ ▼ │ │ resolveProviderExecutionContext() merges provider config │ │ │ │ │ ▼ │ │ request.allowPrivateNetwork = true (propagated correctly) │ │ │ │ │ ▼ │ │ resolveProviderRequestPolicyConfig() reads from request object │ │ │ │ │ ▼ │ │ Audio transcription succeeds on private IP endpoint │ └─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐ │ BROKEN BEHAVIOR (v2026.4.14) │ ├─────────────────────────────────────────────────────────────────────────┤ │ models.providers.openai.request.allowPrivateNetwork = true │ │ │ │ │ ┌───────────────────┴───────────────────┐ │ │ ▼ ▼ │ │ [BUG 1] resolveProviderExecutionContext() Provider config │ │ NEVER includes provider config → allowPrivateNetwork = undefined │ │ │ │ │ ▼ │ │ mergeProviderRequestOverrides() produces request without │ │ allowPrivateNetwork field │ │ │ │ │ ▼ │ │ [BUG 2] resolveProviderRequestPolicyConfig() │ │ only checks params.allowPrivateNetwork (not params.request) │ │ → allowPrivateNetwork = false (default) │ │ │ │ │ ▼ │ │ SSRF policy blocks private IP → SsrFBlockedError │ └─────────────────────────────────────────────────────────────────────────┘
为何两个缺陷都需要
| 缺陷 1 已修复? | 缺陷 2 已修复? | 结果 |
|---|---|---|
| ❌ 否 | ❌ 否 | SSRF 阻止(当前损坏状态) |
| ✅ 是 | ❌ 否 | SSRF 阻止(缺陷 2 仍丢弃该值) |
| ❌ 否 | ✅ 是 | 无变化(缺陷 1 从未填充该值) |
| ✅ 是 | ✅ 是 | ✅ 恢复正常行为 |
🛠️ 逐步修复
选项 1:应用于 Dist 文件的热修复(立即生效)
此方法直接修补编译后的分发文件。适用于容器化部署或在无法立即从源码重建时。
前置条件
- 访问运行中容器的文件系统或部署环境
- 两个受影响的 dist 文件:
runner.entries-*.jsprovider-request-config-*.js
步骤 1:定位受影响的文件
# Find the dist files in your deployment
find /app -name "runner.entries-*.js" 2>/dev/null
find /app -name "provider-request-config-*.js" 2>/dev/null
# Typical container paths:
# /app/dist/api/worker/runner.entries-*.js
# /app/dist/api/providers/provider-request-config-*.js步骤 2:修补 runner.entries-*.js(缺陷 1 修复)
修复前(有缺陷): javascript request: mergeProviderRequestOverrides( sanitizeConfiguredProviderRequest(params.config?.request), sanitizeConfiguredProviderRequest(params.entry.request) )
修复后(已修复): javascript request: mergeModelProviderRequestOverrides( sanitizeConfiguredModelProviderRequest(providerConfig?.request), sanitizeConfiguredProviderRequest(params.config?.request), sanitizeConfiguredProviderRequest(params.entry.request) )
步骤 3:修补 provider-request-config-*.js(缺陷 2 修复)
修复前(有缺陷): javascript allowPrivateNetwork: params.allowPrivateNetwork ?? false
修复后(已修复): javascript allowPrivateNetwork: params.allowPrivateNetwork ?? params.request?.allowPrivateNetwork ?? false
步骤 4:重启应用程序
# For Docker containers
docker-compose restart openclaw
# For Kubernetes
kubectl rollout restart deployment/openclaw
# For systemd
sudo systemctl restart openclaw选项 2:配置变通方案(无需代码更改)
如果无法立即修改 dist 文件,可通过在工具级配置而非提供商级配置中指定 allowPrivateNetwork 来绕过此缺陷。
配置更改
修复前(提供商级 — 在 v2026.4.14 中不生效): json { “models”: { “providers”: { “openai”: { “baseUrl”: “http://192.168.x.x:5092/v1", “request”: { “allowPrivateNetwork”: true } } } }, “tools”: { “media”: { “audio”: { “models”: [{ “provider”: “openai”, “model”: “parakeet” }] } } } }
修复后(工具级变通方案): json { “models”: { “providers”: { “openai”: { “baseUrl”: “http://192.168.x.x:5092/v1" } } }, “tools”: { “media”: { “audio”: { “request”: { “allowPrivateNetwork”: true }, “models”: [{ “provider”: “openai”, “model”: “parakeet” }] } } } }
注意: 此变通方案将 allowPrivateNetwork 放在 tools.media.audio.request 中,通过现有合并链中的 params.config?.request 检查。但是,必须为每个需要专用网络访问的工具配置应用此设置。
选项 3:通过源码修改永久修复(推荐)
对于长期解决方案,在构建前将修复应用到源码 TypeScript 文件。
缺陷 1 修复 — 源文件
文件: src/api/worker/runner/entries.ts(或等效文件)
更改: typescript // Before const request = mergeProviderRequestOverrides( sanitizeConfiguredProviderRequest(params.config?.request), sanitizeConfiguredProviderRequest(params.entry.request) );
// After const request = mergeModelProviderRequestOverrides( sanitizeConfiguredModelProviderRequest(providerConfig?.request), sanitizeConfiguredProviderRequest(params.config?.request), sanitizeConfiguredProviderRequest(params.entry.request) );
缺陷 2 修复 — 源文件
文件: src/api/providers/provider-request-config.ts(或等效文件)
更改: typescript // Before const allowPrivateNetwork = params.allowPrivateNetwork ?? false;
// After const allowPrivateNetwork = params.allowPrivateNetwork ?? params.request?.allowPrivateNetwork ?? false;
重新构建和部署
# Rebuild the application
npm run build
# Or with specific build command
pnpm build
# Redeploy
docker build -t openclaw:fixed .
docker push your-registry/openclaw:fixed
kubectl rollout restart deployment/openclaw🧪 验证
验证前置条件
确保你拥有:
- 位于专用 IP 上的自托管 STT 端点(例如
192.168.1.100:5092上的 Parakeet) - 用于转录的测试音频文件
- 访问部署日志
步骤 1:验证配置已加载
检查你的提供商配置及 allowPrivateNetwork 是否被正确识别:
# Check loaded configuration (if CLI exposes this)
openclaw config show --path "models.providers.openai.request"
# Expected output:
# { allowPrivateNetwork: true }
# Or in debug logs, look for:
# [config] loaded provider config: openai { ..., request: { allowPrivateNetwork: true } }步骤 2:启用安全调试日志
# Set debug environment variable
export DEBUG=openclaw:security:*
# Or in docker-compose.yml:
# environment:
# - DEBUG=openclaw:security:*步骤 3:执行测试转录
# Create a test audio file (silence or short recording)
ffmpeg -f lavfi -i anullsrc=r=16000:cl=mono -t 1 -acodec pcm_s16le /tmp/test.wav
# Execute transcription via OpenClaw CLI or API
openclaw media transcribe \
--provider openai \
--model parakeet \
--audio @/tmp/test.wav \
--url http://192.168.1.100:5092/v1/audio/transcriptions
# Or via API
curl -X POST http://localhost:3000/api/media/transcribe \
-H "Content-Type: application/json" \
-d '{
"provider": "openai",
"model": "parakeet",
"audioUrl": "http://192.168.1.100:5092/v1/audio/transcriptions"
}'步骤 4:验证成功(已修复行为)
预期输出(成功):
[media-understanding] audio: processing (1/1) provider=openai model=parakeet
[media-understanding] audio: completed (1/1) provider=openai model=parakeet duration=1.2s
# Transcription result should be returned without SSRF error调试日志验证(已修复):
[security] resolving request policy for provider=openai
[security] allowPrivateNetwork=true (resolved from request config)
[security] URL fetch allowed: target=http://192.168.1.100:5092/v1/audio/transcriptions
# Should see allowPrivateNetwork=true in logs, not false步骤 5:验证失败状态(基线)
如果修复未应用,你将看到:
[security] blocked URL fetch (url-fetch) target=http://192.168.1.100:5092/v1/audio/transcriptions reason=Blocked hostname or private/internal/special-use IP address
[media-understanding] audio: failed (0/1) reason=SsrFBlockedError
[security] allowPrivateNetwork=false (default fallback)
# Note: The second line shows the bug — should be true from config步骤 6:回归测试套件
创建测试脚本以验证两种场景:
#!/bin/bash
# test-allow-private-network.sh
set -e
echo "=== Test 1: Provider-level allowPrivateNetwork ==="
openclaw config set models.providers.test.request.allowPrivateNetwork true
openclaw media transcribe \
--provider test \
--model test-model \
--audio @/tmp/test.wav \
--url http://192.168.1.100:5092/v1/audio/transcriptions
if [ $? -eq 0 ]; then
echo "✅ Test 1 PASSED: Private network access granted"
else
echo "❌ Test 1 FAILED: SSRF blocked the request"
exit 1
fi
echo ""
echo "=== Test 2: Verify config is not silently dropped ==="
DEBUG=openclaw:security:* openclaw media transcribe \
--provider test \
--model test-model \
--audio @/tmp/test.wav \
--url http://192.168.1.100:5092/v1/audio/transcriptions 2>&1 | grep -i "allowPrivateNetwork"
echo ""
echo "=== All tests completed ==="⚠️ 常见陷阱
1. 误解配置层级
陷阱: 操作员将 allowPrivateNetwork 放在错误的位置,期望它能自动传播。
详情:
models.providers.<id>.request.allowPrivateNetwork— 提供商级(在 v2026.4.14 中损坏)tools.media.audio.request.allowPrivateNetwork— 工具级(有效,但必须为每个工具显式设置)tools.*.request.allowPrivateNetwork— 通配符(仅影响匹配的 tools)
正确方法: 在缺陷修复之前,始终为音频转录在工具级设置 allowPrivateNetwork。
2. 假设配置被忽略时架构验证会通过
陷阱: 架构将 allowPrivateNetwork 验证为有效字段,因此操作员认为它正在被使用。
详情: models.providers.*.request 的 JSON schema 包含 allowPrivateNetwork(在 v2026.4.12 的 #63671 中添加)。但是,代码路径从不读取音频转录的此字段。这造成了误判 — 配置看起来有效但被悄悄丢弃。
变通方案: 始终使用实际测试请求验证行为,而不仅仅是架构验证。
3. Docker 卷挂载冲突
陷阱: 使用卷挂载修补 dist 文件时,容器重启后原始文件可能会被恢复。
详情: 如果你直接在容器中修补 dist/ 文件:
yaml
docker-compose.yml
volumes:
- ./patched-runner.entries.js:/app/dist/api/worker/runner.entries-abc123.js
容器重启策略或镜像重建将覆盖你的修补。
解决方案: 使用包含修补的内置镜像: dockerfile FROM openclaw:2026.4.14 COPY patched-runner.entries.js /app/dist/api/worker/runner.entries-abc123.js COPY patched-provider-request-config.js /app/dist/api/providers/provider-request-config-xyz789.js
4. 缓存传播延迟
陷阱: 修复配置后,音频转录仍因缓存的提供商解析而失败。
详情: OpenClaw 缓存已解析的提供商配置。对 models.providers.<id>.request.allowPrivateNetwork 的修复可能不会生效,直到:
- 缓存 TTL 过期
- 应用程序重启
- 缓存被明确清除
命令: bash
Clear configuration cache
rm -rf ~/.openclaw/cache/* rm -rf /tmp/openclaw-*
Or restart the service
systemctl restart openclaw
5. 冲突的安全策略
陷阱: 即使 allowPrivateNetwork: true,全局安全策略可能会覆盖它。
详情: 检查冲突的配置: json { “security”: { “networkPolicy”: { “allowPrivate”: false // This would override provider-level settings } } }
验证: bash openclaw config show –path security
6. IPv6 专用地址
陷阱: allowPrivateNetwork 可能不涵盖所有 IPv6 专用地址范围。
详情: 以下地址即使设置 allowPrivateNetwork: true 仍可能被阻止:
::1(环回地址)fc00::/7(唯一本地地址)fe80::/10(链路本地地址)
解决方案: 如果使用 IPv6 专用地址,请验证 SSRF 策略明确包含它们: bash DEBUG=openclaw:security:* openclaw media transcribe … 2>&1 | grep -i “ipv6|private”
7. TLS/SSL 验证冲突
陷阱: 专用网络端点通常使用自签名证书。如果未设置 request.tls.rejectUnauthorized,请求可能因证书错误而失败。
详情: 完整的专用网络配置应包括: json { “models”: { “providers”: { “openai”: { “baseUrl”: “https://192.168.1.100:5092/v1”, “request”: { “allowPrivateNetwork”: true, “tls”: { “rejectUnauthorized”: false } } } } } }
🔗 相关错误
直接相关错误
SsrFBlockedError— 此回归中的主要错误。当 SSRF 策略阻止对未明确允许的主机名或 IP 地址的请求时发生。[security] blocked URL fetch (url-fetch) target=http://192.168.x.x:5092/v1/audio/transcriptions reason=Blocked hostname or private/internal/special-use IP address
UrlFetchError— 常规 URL 获取失败;如果 SSRF 不是阻止机制但 URL 无效,可能发生。ProviderRequestConfigError— 当请求配置无法解析时抛出。如果合并链遇到未定义的值,可能发生。
历史相关问题
PR #63671 (v2026.4.12) — 引入了
models.providers.*.request.allowPrivateNetworkschema 和初始实现。此 PR 添加了 v2026.4.14 回归的功能。Issue #64201 — 通过重定向绕过 SSRF — 关于允许专用网络访问的历史问题。当前回归的任何修复都应确保重定向目标也受
allowPrivateNetwork检查约束。Issue #63847 — 大文件媒体转录超时 — 与音频转录可靠性相关;可能与此回归共享错误处理路径。
Issue #64012 — 提供商配置合并优先级不明确 — 记录了关于哪个配置级别优先的困惑。因为缺陷 1 源于提供商配置未被包含在合并中,所以相关。
相似错误模式
| Error Code | Description | Distinction |
|---|---|---|
NetworkError | 通用网络故障 | 无 SSRF 组件;通常是 DNS/连接被拒绝 |
CertificateError | TLS/SSL 验证失败 | 与专用网络自签名证书相关 |
TimeoutError | 请求超时 | 如果 SSRF 快速阻止可能被误诊 |
AuthenticationError | 认证失败 | 无关;表示凭证错误 |
调试参考
报告与此回归相关的问题时,请包含:
# Environment info
openclaw --version
# Expected: v2026.4.14
# Configuration (sanitized)
cat config.json | jq '.models.providers | keys'
# Debug logs with security trace
DEBUG=openclaw:* node index.js 2>&1 | grep -E "(allowPrivateNetwork|SsrFBlocked|provider-request)"
# Provider resolution trace
DEBUG=openclaw:provider:* node index.js 2>&1 | grep -E "(resolveProvider|ExecutionContext)"