| Título | https://github.com/chatwoot/ chatwoot 4.18.0 Insufficient Session Expiration |
|---|
| Descrição | Shopify OAuth callback SSRF + client credential exfiltration via unvalidated shop parameter and a non-expiring state token
Description
### Summary
`GET /shopify/callback` (`app/controllers/shopify/callbacks_controller.rb`) builds an `OAuth2::Client` using the caller-supplied `params[:shop]` as the OAuth `site:` hostname with no validation, then exchanges the caller-supplied `code` against that host, sending the app's `SHOPIFY_CLIENT_ID`/`SHOPIFY_CLIENT_SECRET` in the exchange request (in the `Authorization: Basic` header). The only gate on the request is a `state` JWT that ties the callback to an account id, and that token never expires: `token_payload` (`app/helpers/shopify/integration_helper.rb`) only sets `sub` and `iat`, never `exp`, so `JWT.decode(..., verify_expiration: true)` has nothing to check and always succeeds regardless of the token's age. Any authenticated agent can obtain their own account's state token once via the normal Shopify-connect flow and then replay it indefinitely with an attacker-chosen `shop` value, causing the Chatwoot server to make an outbound HTTP request to any host the attacker controls, exfiltrating the Shopify app's client secret to that host and providing a general internal-network SSRF primitive (arbitrary `Host`/port under the attacker's control).
### Details
Sink: `app/controllers/shopify/callbacks_controller.rb`:
```ruby
def show
verify_account!
@response = oauth_client.auth_code.get_token(params[:code], redirect_uri: '/shopify/callback')
handle_response
end
def oauth_client
OAuth2::Client.new(
client_id,
client_secret,
{ site: "https://#{params[:shop]}", authorize_url: '/admin/oauth/authorize', token_url: '/admin/oauth/access_token' }
)
end
```
`verify_account!` only calls `verify_shopify_token(params[:state])`, which decodes the JWT and returns the embedded `sub` (account id); it never touches or constrains `params[:shop]`. `oauth_client` interpolates `params[:shop]` directly into the `site:` URL with no hostname allowlist, no scheme restriction, and no check that it resolves to `*.myshopify.com` or any Shopify-owned domain. The subsequent `auth_code.get_token` call (from the `oauth2` gem) performs an HTTP POST to `{site}/admin/oauth/access_token` with `client_id`/`client_secret` sent via HTTP Basic auth (and, depending on gem configuration, also in the body).
Non-expiring state token: `app/helpers/shopify/integration_helper.rb`:
```ruby
def generate_shopify_token(account_id)
return if client_secret.blank?
JWT.encode(token_payload(account_id), client_secret, 'HS256')
end
def token_payload(account_id)
{ sub: account_id, iat: Time.current.to_i }
end
def decode_token(token, secret)
JWT.decode(token, secret, true, { algorithm: 'HS256', verify_expiration: true }).first['sub']
end
```
`token_payload` never includes an `exp` claim. `JWT.decode` with `verify_expiration: true` only rejects a token if it HAS an `exp` claim that has passed; with no `exp` claim present there is nothing to verify, so the token is valid forever. Any agent who connects the Shopify integration once (the normal, intended flow, via `Api::V1::Accounts::Integrations::ShopifyController#connect`, which calls `generate_shopify_token(Current.account.id)`) can save that token and replay `GET /shopify/callback?state=<saved token>&shop=<attacker host>&code=<anything>` at any later time.
### PoC
(available upon request)
### Impact
Any authenticated agent (any account, since the state token only needs to be valid for the attacker's own account) can, at any time after connecting the Shopify integration once, cause the Chatwoot server to:
- exfiltrate the deployment's Shopify app `client_secret` to an attacker-controlled host, enabling the attacker to impersonate the app in future Shopify OAuth flows against Shopify merchants, and
- issue outbound HTTP requests to arbitrary hosts and ports chosen via `params[:shop]` (SSRF against internal network services, cloud metadata endpoints, etc., limited to what the `oauth2` gem's request shape allows).
### Recommended Fix
Validate `params[:shop]` against Shopify's own hostname pattern (`^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$`) before constructing `OAuth2::Client`, rejecting any other value. Separately, add an `exp` claim to `token_payload` (e.g. a few minutes' TTL, matching the OAuth flow's expected round-trip time) so the state token cannot be replayed long after the legitimate flow completes.
|
|---|
| Fonte | ⚠️ https://github.com/chatwoot/chatwoot/issues/16206 |
|---|
| Utilizador | carfeii (UID 97470) |
|---|
| Submissão | 14/10/2026 14h47 (há 23 dias) |
|---|
| Moderação | 06/11/2026 10h35 (23 days later) |
|---|
| Estado | Duplicado |
|---|
| Entrada VulDB | 405756 [chatwoot até 4.17.1 Shopify OAuth callbacks_controller.rb Elevação de Privilégios] |
|---|
| Pontos | 0 |
|---|