Summary
The dbt-mcp OAuth helper exposed dbt platform OAuth tokens to local processes through two related defects:
- POST /selected_projects returned the full DbtPlatformContext, including decoded_access_token with the plaintext access and refresh tokens, with no authentication and no redaction. The bundled frontend never read the response body, so the credential served no purpose there.
- The persisted context file (~/.dbt/mcp.yml) was created with permissions following the process umask, typically 0644, leaving the same tokens readable by other local users.
This is an incomplete fix of CVE-2026-55837. That patch (b8bdb40, v1.20.0) removed GET /dbt_platform_context and added TrustedHostMiddleware, closing the remote DNS-rebinding vector, but did not address the sibling endpoint returning the same token-bearing object.
Root cause
POST /selected_projects constructed a new DbtPlatformContext with decoded_access_token set to the live token captured by the OAuth callback, then returned that context unmodified. DbtPlatformContext.decoded_access_token is a plain Pydantic field, and DecodedAccessToken.access_token_response held access_token and refresh_token as plain str with no SecretStr and no Field(exclude=...), so FastAPI serialized both into the response body. The route's only guard confirmed that the OAuth flow had completed, not that the caller was authorized: there was no Depends(), session, or origin check.
Separately, the context file was created via Path.touch() with no mode argument, so its permissions followed the process umask.
Maintainer assessment
Exploitation requires the ability to execute code on the developer's machine and is limited to an in-progress OAuth login session. The helper binds 127.0.0.1 and TrustedHostMiddleware rejects non-loopback Host headers, so the endpoint is not reachable from the network and the DNS-rebinding vector described in CVE-2026-55837 remains closed. The listener exists only while a login is in flight: it is started by the login flow, is not used during token refresh, and returns HTTP 500 with no token before the OAuth callback populates it. An attacker's request also overwrites the victim's stored project selection, so exploitation is not silent.
In affected versions the persisted context file was readable by other local users, so for most local attackers the endpoint offered no capability they did not already have. The case the endpoint uniquely enables is a sandboxed attacker that can reach loopback but cannot read that file, such as a malicious browser extension holding http://127.0.0.1:*/* permissions.
Assessed as CVSS:3.1/AV:L/AC:L/PR:L/UI:R/S:U/C:H/I:N/A:N (5.0, Moderate).
Reporter's assessment
Persistent dbt platform account takeover. The leaked refresh_token (offline_access grant) lets the attacker mint fresh access tokens indefinitely. Full read/write over the victim's dbt platform account: source code, warehouse credentials/connections, production job runs, environment settings. Exploit requires no victim interaction and no credentials — only the ability to make loopback HTTP requests (any process co-located on the host). The original remote (DNS-rebinding) vector is correctly closed by TrustedHostMiddleware; this is the residual local vector the fix never addressed.
Proof of concept
Running the live create_app() with a seeded token and Host 127.0.0.1 (stubbing only the external dbt platform account fetch):
POST /selected_projects Host: 127.0.0.1 body {"account_id": 42, "project_ids": [1, 2]}
-> 200 OK
-> response JSON contains:
decoded_access_token.access_token_response.access_token = eyJhbG...TURE
decoded_access_token.access_token_response.refresh_token = dbt-platform-offline-refresh-SUPERSECRET-abc123
Two-request exfil chain for a co-located attacker (helper on 127.0.0.1:6785):
1. curl -s http://127.0.0.1:6785/projects -H 'Host: localhost' # enumerate account ids
2. curl -s http://127.0.0.1:6785/selected_projects -H 'Host: localhost' \
-H 'Content-Type: application/json' -d '{"account_id": ACCOUNT_ID, "project_ids": [1, 2]}'
# two arbitrary project ids skip the single-project environment-fetch branch
Affected versions
Affected: >= 1.15.0, < 2.1.2. Introduced in 1.15.0 (commit 296cd48, #717), the first release containing /selected_projects. Patched in 2.1.2.
Remediation
Upgrade to dbt-mcp 2.1.2. That release:
- Returns only a status object from /selected_projects, so no token can appear in the response body.
- Creates and maintains ~/.dbt/mcp.yml with 0600 permissions.
Summary
The dbt-mcp OAuth helper exposed dbt platform OAuth tokens to local processes through two related defects:
This is an incomplete fix of CVE-2026-55837. That patch (b8bdb40, v1.20.0) removed GET /dbt_platform_context and added TrustedHostMiddleware, closing the remote DNS-rebinding vector, but did not address the sibling endpoint returning the same token-bearing object.
Root cause
POST /selected_projects constructed a new DbtPlatformContext with decoded_access_token set to the live token captured by the OAuth callback, then returned that context unmodified. DbtPlatformContext.decoded_access_token is a plain Pydantic field, and DecodedAccessToken.access_token_response held access_token and refresh_token as plain str with no SecretStr and no Field(exclude=...), so FastAPI serialized both into the response body. The route's only guard confirmed that the OAuth flow had completed, not that the caller was authorized: there was no Depends(), session, or origin check.
Separately, the context file was created via Path.touch() with no mode argument, so its permissions followed the process umask.
Maintainer assessment
Exploitation requires the ability to execute code on the developer's machine and is limited to an in-progress OAuth login session. The helper binds 127.0.0.1 and TrustedHostMiddleware rejects non-loopback Host headers, so the endpoint is not reachable from the network and the DNS-rebinding vector described in CVE-2026-55837 remains closed. The listener exists only while a login is in flight: it is started by the login flow, is not used during token refresh, and returns HTTP 500 with no token before the OAuth callback populates it. An attacker's request also overwrites the victim's stored project selection, so exploitation is not silent.
In affected versions the persisted context file was readable by other local users, so for most local attackers the endpoint offered no capability they did not already have. The case the endpoint uniquely enables is a sandboxed attacker that can reach loopback but cannot read that file, such as a malicious browser extension holding
http://127.0.0.1:*/*permissions.Assessed as CVSS:3.1/AV:L/AC:L/PR:L/UI:R/S:U/C:H/I:N/A:N (5.0, Moderate).
Reporter's assessment
Persistent dbt platform account takeover. The leaked refresh_token (offline_access grant) lets the attacker mint fresh access tokens indefinitely. Full read/write over the victim's dbt platform account: source code, warehouse credentials/connections, production job runs, environment settings. Exploit requires no victim interaction and no credentials — only the ability to make loopback HTTP requests (any process co-located on the host). The original remote (DNS-rebinding) vector is correctly closed by TrustedHostMiddleware; this is the residual local vector the fix never addressed.
Proof of concept
Running the live create_app() with a seeded token and Host 127.0.0.1 (stubbing only the external dbt platform account fetch):
Two-request exfil chain for a co-located attacker (helper on 127.0.0.1:6785):
Affected versions
Affected: >= 1.15.0, < 2.1.2. Introduced in 1.15.0 (commit 296cd48, #717), the first release containing /selected_projects. Patched in 2.1.2.
Remediation
Upgrade to dbt-mcp 2.1.2. That release: