mirror of
https://github.com/chatwoot/chatwoot.git
synced 2026-06-04 21:02:35 +08:00
This PR updates two dependencies — `faraday` (2.14.1 → 2.14.2) and `jwt` (2.10.1 → 2.10.3) — to pick up security patches flagged by `bundle-audit`. Both are bumped to the minimal patched release within their existing major lines to keep the blast radius small. ### Faraday `Faraday::Connection#build_exclusive_url` still allowed a protocol-relative host override when the request target was passed as a `URI` object (rather than a `String`), bypassing the earlier fix for the string-based variant (CVE-2026-25765 / GHSA-33mh-2634-fwr2). On a fixed-base connection this could redirect a request to an attacker-controlled host while still forwarding connection-scoped headers such as `Authorization` — i.e. off-host request forgery (CVE-2026-33637 / GHSA-5rv5-xj5j-3484). The fix is a clean patch bump to `2.14.2`, within Faraday's existing version range — no API changes and no other gems affected. ### JWT `jwt` 2.10.1 accepts an empty/`nil` HMAC key during verification: `JWT.decode(token, "", true, algorithm: 'HS256')` (and keyfinder paths returning `""`/`nil`) verify a forged token, because the empty-key HMAC digest is treated as valid and `enforce_hmac_key_length` defaults to `false` (CVE-2026-45363, High). The advisory offers two fixes — `~> 2.10.3` or `>= 3.2.0`. We chose **2.10.3** deliberately: jumping to 3.x cascaded into upgrading `oauth2`, `twilio-ruby`, `googleauth`, `web-push`, and `signet` (all pinned `jwt < 3.0`), and `jwt` is used directly in 8+ places here (token services, OAuth callbacks, integration helpers), so a major bump carries real breakage risk for no extra security benefit. The Gemfile is pinned `'~> 2.10', '>= 2.10.3'` to hold the 2.x line. **Spec changes.** 2.10.3 tightens key handling: HMAC sign/verify now raises on a `nil`, empty, or non-`String` key instead of silently coercing it. A few specs relied on the old lax behaviour and needed updating: - `microsoft` / `google` callback specs built unsigned ID tokens via `JWT.encode(payload, false)`. Replaced with the correct unsigned form, `JWT.encode(payload, nil, 'none')`. - `instagram` / `linear` / `shopify` helper specs have a "client secret not configured" context where `client_secret` is `nil`. Their shared `valid_token` `let` signed with that `nil` secret, which Ruby evaluates before the helper runs — now raising. Since the helper short-circuits on the blank secret and never decodes the token, those contexts now override `valid_token` with a throwaway string. **Production is unaffected.** Every production HMAC path uses a real, non-empty key — `Rails.application.secret_key_base` (`BaseTokenService`, `Widget::TokenService`) or a client secret guarded by `return if client_secret.blank?` (Instagram/TikTok/Shopify/Linear helpers). The one `nil`-key call, `JWT.decode(id_token, nil, false)` in `OauthCallbackController`, runs with verification disabled, so the key is never inspected. Twilio voice tokens use `Twilio::JWT::AccessToken` from `twilio-ruby`, not this gem. The specs failed precisely because they exercised the unsafe empty-key pattern the patch now blocks — production never did.
82 lines
4.2 KiB
Ruby
82 lines
4.2 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe 'Google::CallbacksController', type: :request do
|
|
let(:account) { create(:account) }
|
|
let(:code) { SecureRandom.hex(10) }
|
|
let(:email) { Faker::Internet.email }
|
|
let(:state) { account.to_sgid(expires_in: 15.minutes).to_s }
|
|
|
|
describe 'GET /google/callback' do
|
|
let(:response_body_success) do
|
|
{ id_token: JWT.encode({ email: email, name: 'test' }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer',
|
|
refresh_token: SecureRandom.hex(10) }
|
|
end
|
|
|
|
let(:response_body_success_without_name) do
|
|
{ id_token: JWT.encode({ email: email }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer',
|
|
refresh_token: SecureRandom.hex(10) }
|
|
end
|
|
|
|
it 'creates inboxes if authentication is successful' do
|
|
stub_request(:post, 'https://accounts.google.com/o/oauth2/token')
|
|
.with(body: { 'code' => code, 'grant_type' => 'authorization_code',
|
|
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback" })
|
|
.to_return(status: 200, body: response_body_success.to_json, headers: { 'Content-Type' => 'application/json' })
|
|
|
|
get google_callback_url, params: { code: code, state: state }
|
|
|
|
expect(response).to redirect_to app_email_inbox_agents_url(account_id: account.id, inbox_id: account.inboxes.last.id)
|
|
expect(account.inboxes.count).to be 1
|
|
inbox = account.inboxes.last
|
|
expect(inbox.name).to eq 'test'
|
|
expect(inbox.channel.reload.provider_config.keys).to include('access_token', 'refresh_token', 'expires_on')
|
|
expect(inbox.channel.reload.provider_config['access_token']).to eq response_body_success[:access_token]
|
|
expect(inbox.channel.imap_address).to eq 'imap.gmail.com'
|
|
end
|
|
|
|
it 'updates inbox channel config if inbox exists with imap_login and authentication is successful' do
|
|
channel_email = create(:channel_email, account: account, imap_login: email)
|
|
inbox = channel_email.inbox
|
|
expect(inbox.channel.provider_config).to eq({})
|
|
|
|
stub_request(:post, 'https://accounts.google.com/o/oauth2/token')
|
|
.with(body: { 'code' => code, 'grant_type' => 'authorization_code',
|
|
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback" })
|
|
.to_return(status: 200, body: response_body_success.to_json, headers: { 'Content-Type' => 'application/json' })
|
|
|
|
get google_callback_url, params: { code: code, state: state }
|
|
|
|
expect(response).to redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
|
|
expect(account.inboxes.count).to be 1
|
|
expect(inbox.channel.reload.provider_config.keys).to include('access_token', 'refresh_token', 'expires_on')
|
|
expect(inbox.channel.reload.provider_config['access_token']).to eq response_body_success[:access_token]
|
|
expect(inbox.channel.imap_address).to eq 'imap.gmail.com'
|
|
end
|
|
|
|
it 'creates inboxes with fallback_name when account name is not present in id_token' do
|
|
stub_request(:post, 'https://accounts.google.com/o/oauth2/token')
|
|
.with(body: { 'code' => code, 'grant_type' => 'authorization_code',
|
|
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback" })
|
|
.to_return(status: 200, body: response_body_success_without_name.to_json, headers: { 'Content-Type' => 'application/json' })
|
|
|
|
get google_callback_url, params: { code: code, state: state }
|
|
|
|
expect(response).to redirect_to app_email_inbox_agents_url(account_id: account.id, inbox_id: account.inboxes.last.id)
|
|
expect(account.inboxes.count).to be 1
|
|
inbox = account.inboxes.last
|
|
expect(inbox.name).to eq email.split('@').first.parameterize.titleize
|
|
end
|
|
|
|
it 'redirects to google app in case of error' do
|
|
stub_request(:post, 'https://accounts.google.com/o/oauth2/token')
|
|
.with(body: { 'code' => code, 'grant_type' => 'authorization_code',
|
|
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/google/callback" })
|
|
.to_return(status: 401)
|
|
|
|
get google_callback_url, params: { code: code, state: state }
|
|
|
|
expect(response).to redirect_to '/'
|
|
end
|
|
end
|
|
end
|