Summary
The SSRF egress guard added in PR #6646 ("fix(egress): outbound url policy across all customer-controlled egress paths", commit 18ddc621b) is not applied to the ProxyRequest that runs provider post-connection and pre-connection-deletion scripts (packages/server/lib/hooks/connection/internal-nango.ts). That ProxyRequest is constructed without outboundPolicy, while the sibling credentials-verification-script.ts — updated by that same PR — passes outboundPolicy: getServerOutboundUrlPolicy(). On this path there is no hostname denylist, no private/link-local IP classification, no DNS-pinning, and no redirect re-validation. This is an incomplete fix of #6646.
Severity
High — CVSS 3.1 8.5 AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N (CWE-918). Conservative floor 6.3 (Medium) if assessed as strictly blind.
Affected
All versions through 0.71.4 (the #6646 egress guard, shipped ~0.71.0, was never extended to this path). Verified on the official nangohq/nango-server:hosted image, version 0.71.4.
Details (root cause)
// internal-nango.ts — VULNERABLE (no outboundPolicy)
const proxyInstance = new ProxyRequest({ logger, proxyConfig: proxyConfigUnwrapped,
getConnection, getIntegrationConfig, onBytes });
// credentials-verification-script.ts:112 — sibling, guarded (updated by #6646)
const proxyInstance = new ProxyRequest({ ..., outboundPolicy: getServerOutboundUrlPolicy() });
In packages/shared/lib/services/proxy/utils.ts (getAxiosConfiguration) every protection is gated on outboundPolicy being present: assertSafeOutboundUrlSync (denylist + IP classification), the safe DNS lookup (anti-rebind), maxRedirects, and the beforeRedirect re-validation. With outboundPolicy undefined the request uses a default axios agent — no guard — and follows the default 5 redirects unchecked, including cross-scheme https → http. The second guard, enforceProxyOutboundUrlPolicy, is only called from proxy.service.ts and the runner SDK, not this path.
PoC
An authenticated tenant (an environment secret key from self-serve signup) triggers it via POST /connections. The snowflake provider's proxy.base_url is https://${connectionConfig.snowflake_account_url} (attacker-controlled host), and OAUTH2 import performs no token exchange, so a dummy access_token is accepted — no real Snowflake account or OAuth app needed:
POST /connections
Authorization: Bearer <environment secret key>
Content-Type: application/json
{"provider_config_key":"snowflake","connection_id":"poc",
"credentials":{"type":"OAUTH2","access_token":"dummy"},
"connection_config":{"snowflake_account_url":"<attacker-controlled host>"}}
The connection-created hook runs the snowflake post-connection script through the unguarded ProxyRequest, so the backend issues POST https://<attacker host>/api/v2/statements to any internal / private / cloud-metadata address. Because redirects are followed unchecked, a first-hop HTTPS host the attacker owns (any host with a valid public certificate) can 307 the backend to a plaintext http://<internal> target (method + body preserved). The snowflake script stores response.data.data[0][0] into connection_config.username, so for internal endpoints returning Snowflake-shaped JSON the response is exfiltrated back to the tenant via GET /connections (non-blind); for other shapes the SSRF is blind (reach only). Confirmed end-to-end against v0.71.4, including the cross-scheme redirect with a real (non-lab) public host + valid certificate and the response reflected into username.
Impact
Authenticated (tenant secret key) SSRF from Nango's multi-tenant backend to arbitrary internal hosts/ports and cloud-metadata endpoints (reach), with the entire egress guard bypassed and cross-scheme redirects followed; attacker-directed POST (method + body preserved) to internal endpoints; and non-blind exfiltration of the response for the Snowflake-shaped class.
The gap is systemic to the path: all 43 providers with a post/pre-connection script run through the same unguarded ProxyRequest. Audited against providers.yaml, snowflake is the one provider today whose script issues an outbound call to a fully attacker-controlled host (others are no-ops, mutate local config, hard-code baseUrlOverride to the vendor host, or are bound to a vendor subdomain/path). Any future provider or connection_config change that lets a script's outbound host be influenced inherits the SSRF automatically. Integration-level custom.baseUrl does not reach this path (only integration_config providers use it, none of which has a post/pre-connection script, and internal-nango's getIntegrationConfig returns only OAuth credentials, no custom).
Remediation
Pass outboundPolicy: getServerOutboundUrlPolicy() in internal-nango.ts, mirroring credentials-verification-script.ts. Defense in depth: make outboundPolicy a required argument of ProxyRequest (or add a lint/type guard) so no outbound proxy can be built without it, with a test asserting every construction site carries a policy.
Summary
The SSRF egress guard added in PR #6646 ("fix(egress): outbound url policy across all customer-controlled egress paths", commit
18ddc621b) is not applied to theProxyRequestthat runs provider post-connection and pre-connection-deletion scripts (packages/server/lib/hooks/connection/internal-nango.ts). ThatProxyRequestis constructed withoutoutboundPolicy, while the siblingcredentials-verification-script.ts— updated by that same PR — passesoutboundPolicy: getServerOutboundUrlPolicy(). On this path there is no hostname denylist, no private/link-local IP classification, no DNS-pinning, and no redirect re-validation. This is an incomplete fix of #6646.Severity
High — CVSS 3.1 8.5
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N(CWE-918). Conservative floor 6.3 (Medium) if assessed as strictly blind.Affected
All versions through 0.71.4 (the #6646 egress guard, shipped ~0.71.0, was never extended to this path). Verified on the official
nangohq/nango-server:hostedimage,version 0.71.4.Details (root cause)
In
packages/shared/lib/services/proxy/utils.ts(getAxiosConfiguration) every protection is gated onoutboundPolicybeing present:assertSafeOutboundUrlSync(denylist + IP classification), the safe DNSlookup(anti-rebind),maxRedirects, and thebeforeRedirectre-validation. WithoutboundPolicyundefined the request uses a default axios agent — no guard — and follows the default 5 redirects unchecked, including cross-schemehttps → http. The second guard,enforceProxyOutboundUrlPolicy, is only called fromproxy.service.tsand the runner SDK, not this path.PoC
An authenticated tenant (an environment secret key from self-serve signup) triggers it via
POST /connections. Thesnowflakeprovider'sproxy.base_urlishttps://${connectionConfig.snowflake_account_url}(attacker-controlled host), and OAUTH2 import performs no token exchange, so a dummyaccess_tokenis accepted — no real Snowflake account or OAuth app needed:The connection-created hook runs the snowflake post-connection script through the unguarded
ProxyRequest, so the backend issuesPOST https://<attacker host>/api/v2/statementsto any internal / private / cloud-metadata address. Because redirects are followed unchecked, a first-hop HTTPS host the attacker owns (any host with a valid public certificate) can307the backend to a plaintexthttp://<internal>target (method + body preserved). The snowflake script storesresponse.data.data[0][0]intoconnection_config.username, so for internal endpoints returning Snowflake-shaped JSON the response is exfiltrated back to the tenant viaGET /connections(non-blind); for other shapes the SSRF is blind (reach only). Confirmed end-to-end against v0.71.4, including the cross-scheme redirect with a real (non-lab) public host + valid certificate and the response reflected intousername.Impact
Authenticated (tenant secret key) SSRF from Nango's multi-tenant backend to arbitrary internal hosts/ports and cloud-metadata endpoints (reach), with the entire egress guard bypassed and cross-scheme redirects followed; attacker-directed POST (method + body preserved) to internal endpoints; and non-blind exfiltration of the response for the Snowflake-shaped class.
The gap is systemic to the path: all 43 providers with a post/pre-connection script run through the same unguarded
ProxyRequest. Audited againstproviders.yaml,snowflakeis the one provider today whose script issues an outbound call to a fully attacker-controlled host (others are no-ops, mutate local config, hard-codebaseUrlOverrideto the vendor host, or are bound to a vendor subdomain/path). Any future provider orconnection_configchange that lets a script's outbound host be influenced inherits the SSRF automatically. Integration-levelcustom.baseUrldoes not reach this path (onlyintegration_configproviders use it, none of which has a post/pre-connection script, andinternal-nango'sgetIntegrationConfigreturns only OAuth credentials, nocustom).Remediation
Pass
outboundPolicy: getServerOutboundUrlPolicy()ininternal-nango.ts, mirroringcredentials-verification-script.ts. Defense in depth: makeoutboundPolicya required argument ofProxyRequest(or add a lint/type guard) so no outbound proxy can be built without it, with a test asserting every construction site carries a policy.