feat: Secure external credentials with database encryption (#12648)
## Changelog
- Added conditional Active Record encryption to every external
credential we store (SMTP/IMAP passwords, Twilio tokens,
Slack/OpenAI hook tokens, Facebook/Instagram tokens, LINE/Telegram keys,
Twitter secrets) so new writes are encrypted
whenever Chatwoot.encryption_configured? is true; legacy installs still
receive plaintext until their secrets are
updated.
- Tuned encryption settings in config/application.rb to allow legacy
reads (support_unencrypted_data) and to extend
deterministic queries so lookups continue to match plaintext rows during
the rollout; added TODOs to retire the
fallback once encryption becomes mandatory.
- Introduced an MFA-pipeline test suite
(spec/models/external_credentials_encryption_spec.rb) plus shared
examples to
verify each attribute encrypts at rest and that plaintext records
re-encrypt on update, with a dedicated Telegram case.
The existing MFA GitHub workflow now runs these tests using the
preconfigured encryption keys.
fixes:
https://linear.app/chatwoot/issue/CW-5453/encrypt-sensitive-credentials-stored-in-plain-text-in-database
## Testing Instructions
1. Instance without encryption keys
- Unset ACTIVE_RECORD_ENCRYPTION_* vars (or run in an environment where
they’re absent).
- Create at least one credentialed channel (e.g., Email SMTP).
- Confirm workflows still function (send/receive mail or a similar
sanity check).
- In the DB you should still see plaintext values—this confirms the
guard prevents encryption when keys are missing.
2. Instance with encryption keys
- Configure the three encryption env vars and restart.
- Pick a couple of representative integrations (e.g., Email SMTP +
Twilio SMS).
- Legacy channel check:
- Use existing records created before enabling keys. Trigger their
workflow (send an email / SMS, or hit the
webhook) to ensure they still authenticate.
- Inspect the raw column—value remains plaintext until changed.
- Update legacy channel:
- Edit one legacy channel’s credential (e.g., change SMTP password).
- Verify the operation still works and the stored value is now encrypted
(raw column differs, accessor returns
original).
- New channel creation:
- Create a new channel of the same type; confirm functionality and that
the stored credential is encrypted from
the start.
---------
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
@@ -40,6 +40,12 @@ class Channel::Email < ApplicationRecord
|
||||
|
||||
AUTHORIZATION_ERROR_THRESHOLD = 10
|
||||
|
||||
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||
if Chatwoot.encryption_configured?
|
||||
encrypts :imap_password
|
||||
encrypts :smtp_password
|
||||
end
|
||||
|
||||
self.table_name = 'channel_email'
|
||||
EDITABLE_ATTRS = [:email, :imap_enabled, :imap_login, :imap_password, :imap_address, :imap_port, :imap_enable_ssl,
|
||||
:smtp_enabled, :smtp_login, :smtp_password, :smtp_address, :smtp_port, :smtp_domain, :smtp_enable_starttls_auto,
|
||||
|
||||
@@ -21,6 +21,12 @@ class Channel::FacebookPage < ApplicationRecord
|
||||
include Channelable
|
||||
include Reauthorizable
|
||||
|
||||
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||
if Chatwoot.encryption_configured?
|
||||
encrypts :page_access_token
|
||||
encrypts :user_access_token
|
||||
end
|
||||
|
||||
self.table_name = 'channel_facebook_pages'
|
||||
|
||||
validates :page_id, uniqueness: { scope: :account_id }
|
||||
|
||||
@@ -19,6 +19,9 @@ class Channel::Instagram < ApplicationRecord
|
||||
include Reauthorizable
|
||||
self.table_name = 'channel_instagram'
|
||||
|
||||
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||
encrypts :access_token if Chatwoot.encryption_configured?
|
||||
|
||||
AUTHORIZATION_ERROR_THRESHOLD = 1
|
||||
|
||||
validates :access_token, presence: true
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
class Channel::Line < ApplicationRecord
|
||||
include Channelable
|
||||
|
||||
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||
if Chatwoot.encryption_configured?
|
||||
encrypts :line_channel_secret
|
||||
encrypts :line_channel_token
|
||||
end
|
||||
|
||||
self.table_name = 'channel_line'
|
||||
EDITABLE_ATTRS = [:line_channel_id, :line_channel_secret, :line_channel_token].freeze
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
class Channel::Telegram < ApplicationRecord
|
||||
include Channelable
|
||||
|
||||
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||
encrypts :bot_token, deterministic: true if Chatwoot.encryption_configured?
|
||||
|
||||
self.table_name = 'channel_telegram'
|
||||
EDITABLE_ATTRS = [:bot_token].freeze
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ class Channel::TwilioSms < ApplicationRecord
|
||||
|
||||
self.table_name = 'channel_twilio_sms'
|
||||
|
||||
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||
encrypts :auth_token if Chatwoot.encryption_configured?
|
||||
|
||||
validates :account_sid, presence: true
|
||||
# The same parameter is used to store api_key_secret if api_key authentication is opted
|
||||
validates :auth_token, presence: true
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
class Channel::TwitterProfile < ApplicationRecord
|
||||
include Channelable
|
||||
|
||||
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||
if Chatwoot.encryption_configured?
|
||||
encrypts :twitter_access_token
|
||||
encrypts :twitter_access_token_secret
|
||||
end
|
||||
|
||||
self.table_name = 'channel_twitter_profiles'
|
||||
|
||||
validates :profile_id, uniqueness: { scope: :account_id }
|
||||
|
||||
@@ -21,6 +21,9 @@ class Integrations::Hook < ApplicationRecord
|
||||
before_validation :ensure_hook_type
|
||||
after_create :trigger_setup_if_crm
|
||||
|
||||
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||
encrypts :access_token, deterministic: true if Chatwoot.encryption_configured?
|
||||
|
||||
validates :account_id, presence: true
|
||||
validates :app_id, presence: true
|
||||
validates :inbox_id, presence: true, if: -> { hook_type == 'inbox' }
|
||||
|
||||
Reference in New Issue
Block a user