제출 #1033598: https://github.com/chatwoot/ chatwoot 4.18.0 Insufficient Session Expiration정보

제목https://github.com/chatwoot/ chatwoot 4.18.0 Insufficient Session Expiration
설명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.
원천⚠️ https://github.com/chatwoot/chatwoot/issues/16206
사용자
 carfeii (UID 97470)
제출2026. 10. 14. PM 02:47 (23 날 ago)
모더레이션2026. 11. 06. AM 10:35 (23 days later)
상태중복
VulDB 항목405756 [chatwoot 까지 4.17.1 Shopify OAuth callbacks_controller.rb 권한 상승]
포인트들0

Want to stay up to date on a daily basis?

Enable the mail alert feature now!