## Summary The `GET /api/v1/accounts/:id/integrations/apps` endpoint returns raw secret values (OpenAI API keys, Google service account private keys, Linear refresh tokens, etc.) in hook settings within the JSON response. Although gated behind an administrator check, these secrets are visible in the browser network tab. This PR filters hook settings through the existing `visible_properties` whitelist defined in `config/integration/apps.yml`, and adds explicit whitelists to integrations that were missing them. Closes #14042 ## Bug reproduction **Setup:** Created an OpenAI integration hook with a fake API key (`sk-test-secret-12345`). **Step 1 — Browser Network tab shows raw secrets:** <img width="1676" height="869" alt="Screenshot 2026-04-24 at 14 32 28" src="https://github.com/user-attachments/assets/6fc69463-365e-458b-b216-98a7390bf528" /> Navigate to Settings > Integrations as an admin. Open DevTools Network tab and observe the response from `GET /api/v1/accounts/:id/integrations/apps`. The hook settings contain the full API key in plaintext: ```json "hooks": [ { "id": 1, "app_id": "openai", "settings": { "api_key": "sk-test-secret-12345", "label_suggestion": false } } ] ``` **Step 2 — API call confirms the leak:** ```bash curl -s "http://localhost:3000/api/v1/accounts/2/integrations/apps" \ -H "access-token: <token>" \ -H "client: <client>" \ -H "uid: user@test.com" \ | jq '.payload[] | select(.id == "openai") | {id, name, hooks: [.hooks[] | {id, app_id, settings}]}' ``` Response: ```json { "id": "openai", "name": "OpenAI", "hooks": [ { "id": 1, "app_id": "openai", "settings": { "api_key": "sk-test-secret-12345", "label_suggestion": false } } ] } ``` Any admin user can extract the raw API key from the response. The same applies to other integrations — Dialogflow exposes full Google service account credentials, Linear exposes refresh tokens, etc. ## After fix After applying this change, the GET /api/v1/accounts/:id/integrations/apps endpoint no longer returns sensitive secret values in hook settings. Instead, the response is filtered using each integration’s visible_properties whitelist, ensuring only safe, user-facing fields are exposed. For example, OpenAI integrations return non-sensitive fields like label_suggestion while excluding raw API keys. This prevents secrets from being exposed in the browser network tab or API responses, even for authenticated admin users. ```bash curl -X GET "http://localhost:3000/api/v1/accounts/2/integrations/apps" \ -H "Accept: application/json" \ -H "Authorization: Bearer eyJhY2Nlc3MtdG9rZW4iOiJqZG5jOWg4TnljaWFJa3JlZkxGQzRnIiwidG9rZW4tdHlwZSI6IkJlYXJlciIsImNsaWVudCI6Ik81bjVnTDFEOVVhbGpwbWxjaHZNanciLCJleHBpcnkiOiIxNzgyMzMyNTA4IiwidWlkIjoidXNlckB0ZXN0LmNvbSJ9" \ -H "access-token: jdnc9h8NyciaIkrefLFC4g" \ -H "client: O5n5gL1D9UaljpmlchvMjw" \ -H "uid: user@test.com" \ | jq '.payload[] | select(.id == "openai") | {id, name, hooks: [.hooks[] | {id, app_id, settings}]}' % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 10174 100 10174 0 0 33232 0 --:--:-- --:--:-- --:--:-- 33357 { "id": "openai", "name": "OpenAI", "hooks": [ { "id": 1, "app_id": "openai", "settings": { "label_suggestion": false } } ] } ``` ## What changed - Added `visible_properties` accessor to `Integrations::App` model to expose the whitelist from config - Updated `_hook.json.jbuilder` to filter `resource.settings` through the associated app's `visible_properties` instead of returning the full hash - Added `visible_properties` to integrations that were missing it: - Linear: `[]` (settings contain refresh_token) - Notion: `[]` (OAuth-based, no user-facing settings) - Slack: `['channel_name']` (UI needs this to display connected channel) - Shopify: `[]` (no settings) App config metadata (`_app.json.jbuilder`) is left unchanged — hook_type, settings_form_schema, etc. are not secrets and the frontend depends on them. ## How to test 1. Create an OpenAI integration hook with an API key 2. As an admin, call `GET /api/v1/accounts/:id/integrations/apps` 3. Verify hook settings include `api_key` (whitelisted) but not raw credential objects 4. For Dialogflow hooks, verify `credentials` (private key JSON) is excluded while `project_id` is included 5. For Slack hooks, verify `channel_name` is still returned 6. For Linear hooks, verify `refresh_token` is not returned --------- Co-authored-by: Botshelo Nokoane (Konstruktors) <botshelo@entersekt.com> Co-authored-by: Sony Mathew <sony@chatwoot.com> Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
134 lines
3.0 KiB
Ruby
134 lines
3.0 KiB
Ruby
class Integrations::App
|
|
include Linear::IntegrationHelper
|
|
attr_accessor :params
|
|
|
|
def initialize(params)
|
|
@params = params
|
|
end
|
|
|
|
def id
|
|
params[:id]
|
|
end
|
|
|
|
def name
|
|
I18n.t("integration_apps.#{params[:i18n_key]}.name")
|
|
end
|
|
|
|
def description
|
|
I18n.t("integration_apps.#{params[:i18n_key]}.description")
|
|
end
|
|
|
|
def short_description
|
|
I18n.t("integration_apps.#{params[:i18n_key]}.short_description")
|
|
end
|
|
|
|
def logo
|
|
params[:logo]
|
|
end
|
|
|
|
def fields
|
|
params[:fields]
|
|
end
|
|
|
|
def visible_properties
|
|
Array(params[:visible_properties]).map(&:to_s)
|
|
end
|
|
|
|
# There is no way to get the account_id from the linear callback
|
|
# so we are using the generate_linear_token method to generate a token and encode it in the state parameter
|
|
def encode_state
|
|
generate_linear_token(Current.account.id)
|
|
end
|
|
|
|
def action
|
|
case params[:id]
|
|
when 'slack'
|
|
client_id = GlobalConfigService.load('SLACK_CLIENT_ID', nil)
|
|
"#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}"
|
|
when 'linear'
|
|
build_linear_action
|
|
else
|
|
params[:action]
|
|
end
|
|
end
|
|
|
|
def active?(account)
|
|
case params[:id]
|
|
when 'slack'
|
|
GlobalConfigService.load('SLACK_CLIENT_SECRET', nil).present?
|
|
when 'linear'
|
|
account.feature_enabled?('linear_integration') && GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
|
|
when 'shopify'
|
|
shopify_enabled?(account)
|
|
when 'leadsquared'
|
|
account.feature_enabled?('crm_integration')
|
|
when 'notion'
|
|
notion_enabled?(account)
|
|
else
|
|
true
|
|
end
|
|
end
|
|
|
|
def build_linear_action
|
|
app_id = GlobalConfigService.load('LINEAR_CLIENT_ID', nil)
|
|
[
|
|
"#{params[:action]}?response_type=code",
|
|
"client_id=#{app_id}",
|
|
"redirect_uri=#{self.class.linear_integration_url}",
|
|
"state=#{encode_state}",
|
|
'scope=read,write',
|
|
'prompt=consent',
|
|
'actor=app'
|
|
].join('&')
|
|
end
|
|
|
|
def enabled?(account)
|
|
case params[:id]
|
|
when 'webhook'
|
|
account.webhooks.exists?
|
|
when 'dashboard_apps'
|
|
account.dashboard_apps.exists?
|
|
else
|
|
account.hooks.exists?(app_id: id)
|
|
end
|
|
end
|
|
|
|
def hooks
|
|
Current.account.hooks.where(app_id: id)
|
|
end
|
|
|
|
def self.slack_integration_url
|
|
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/integrations/slack"
|
|
end
|
|
|
|
def self.linear_integration_url
|
|
"#{ENV.fetch('FRONTEND_URL', nil)}/linear/callback"
|
|
end
|
|
|
|
class << self
|
|
def apps
|
|
Hashie::Mash.new(APPS_CONFIG)
|
|
end
|
|
|
|
def all
|
|
apps.values.each_with_object([]) do |app, result|
|
|
result << new(app)
|
|
end
|
|
end
|
|
|
|
def find(params)
|
|
all.detect { |app| app.id == params[:id] }
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def shopify_enabled?(account)
|
|
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
|
|
end
|
|
|
|
def notion_enabled?(account)
|
|
account.feature_enabled?('notion_integration') && GlobalConfigService.load('NOTION_CLIENT_ID', nil).present?
|
|
end
|
|
end
|