## 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>
211 lines
8.8 KiB
Ruby
211 lines
8.8 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe 'Integration Apps API', type: :request do
|
|
let(:account) { create(:account) }
|
|
|
|
before { allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) }
|
|
|
|
describe 'GET /api/v1/integrations/apps' do
|
|
context 'when it is an unauthenticated user' do
|
|
it 'returns unauthorized' do
|
|
get api_v1_account_integrations_apps_url(account)
|
|
expect(response).to have_http_status(:unauthorized)
|
|
end
|
|
end
|
|
|
|
context 'when it is an authenticated user' do
|
|
let(:agent) { create(:user, account: account, role: :agent) }
|
|
let(:admin) { create(:user, account: account, role: :administrator) }
|
|
|
|
it 'returns all active apps without sensitive information if the user is an agent' do
|
|
first_app = Integrations::App.all.find { |app| app.active?(account) }
|
|
get api_v1_account_integrations_apps_url(account),
|
|
headers: agent.create_new_auth_token,
|
|
as: :json
|
|
|
|
expect(response).to have_http_status(:success)
|
|
apps = response.parsed_body['payload'].first
|
|
expect(apps['id']).to eql(first_app.id)
|
|
expect(apps['name']).to eql(first_app.name)
|
|
expect(apps['action']).to be_nil
|
|
end
|
|
|
|
it 'will not return sensitive information for openai app for agents' do
|
|
openai = create(:integrations_hook, :openai, account: account)
|
|
get api_v1_account_integrations_apps_url(account),
|
|
headers: agent.create_new_auth_token,
|
|
as: :json
|
|
|
|
expect(response).to have_http_status(:success)
|
|
|
|
app = response.parsed_body['payload'].find { |int_app| int_app['id'] == openai.app.id }
|
|
expect(app['hooks'].first['settings']).to be_nil
|
|
end
|
|
|
|
it 'returns all active apps with admin metadata if user is an admin' do
|
|
first_app = Integrations::App.all.find { |app| app.active?(account) }
|
|
get api_v1_account_integrations_apps_url(account),
|
|
headers: admin.create_new_auth_token,
|
|
as: :json
|
|
|
|
expect(response).to have_http_status(:success)
|
|
apps = response.parsed_body['payload'].first
|
|
expect(apps['id']).to eql(first_app.id)
|
|
expect(apps['name']).to eql(first_app.name)
|
|
expect(apps['action']).to eql(first_app.action)
|
|
end
|
|
|
|
it 'returns slack app with appropriate redirect url when configured' do
|
|
allow(GlobalConfigService).to receive(:load).and_call_original
|
|
allow(GlobalConfigService).to receive(:load).with('SLACK_CLIENT_ID', nil).and_return('client_id')
|
|
allow(GlobalConfigService).to receive(:load).with('SLACK_CLIENT_SECRET', nil).and_return('client_secret')
|
|
|
|
get api_v1_account_integrations_apps_url(account),
|
|
headers: admin.create_new_auth_token,
|
|
as: :json
|
|
|
|
expect(response).to have_http_status(:success)
|
|
apps = response.parsed_body['payload']
|
|
slack_app = apps.find { |app| app['id'] == 'slack' }
|
|
expect(slack_app['action']).to include('client_id=client_id')
|
|
end
|
|
|
|
it 'returns visible hook settings for openai app for admins' do
|
|
openai = create(:integrations_hook, :openai, account: account)
|
|
get api_v1_account_integrations_apps_url(account),
|
|
headers: admin.create_new_auth_token,
|
|
as: :json
|
|
|
|
expect(response).to have_http_status(:success)
|
|
|
|
app = response.parsed_body['payload'].find { |int_app| int_app['id'] == openai.app.id }
|
|
expect(app['hooks'].first['settings']).not_to be_nil
|
|
end
|
|
|
|
it 'redacts secrets and only returns visible settings for openai hooks' do
|
|
openai = create(
|
|
:integrations_hook,
|
|
:openai,
|
|
account: account,
|
|
settings: { api_key: 'sk-secret', label_suggestion: true }
|
|
)
|
|
get api_v1_account_integrations_apps_url(account),
|
|
headers: admin.create_new_auth_token,
|
|
as: :json
|
|
|
|
app = response.parsed_body['payload'].find { |int_app| int_app['id'] == openai.app.id }
|
|
expect(app['hooks'].first['settings']).to eq('label_suggestion' => true)
|
|
end
|
|
|
|
it 'keeps slack channel display settings while redacting unspecified settings' do
|
|
create(:integrations_hook, account: account, settings: { channel_name: 'support', signing_secret: 'secret' })
|
|
allow(GlobalConfigService).to receive(:load).and_call_original
|
|
allow(GlobalConfigService).to receive(:load).with('SLACK_CLIENT_SECRET', nil).and_return('client_secret')
|
|
|
|
get api_v1_account_integrations_apps_url(account),
|
|
headers: admin.create_new_auth_token,
|
|
as: :json
|
|
|
|
app = response.parsed_body['payload'].find { |int_app| int_app['id'] == 'slack' }
|
|
expect(app['hooks'].first['settings']).to eq('channel_name' => 'support')
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'GET /api/v1/integrations/apps/:id' do
|
|
context 'when it is an unauthenticated user' do
|
|
it 'returns unauthorized' do
|
|
get api_v1_account_integrations_app_url(account_id: account.id, id: 'slack')
|
|
expect(response).to have_http_status(:unauthorized)
|
|
end
|
|
end
|
|
|
|
context 'when it is an authenticated user' do
|
|
let(:agent) { create(:user, account: account, role: :agent) }
|
|
let(:admin) { create(:user, account: account, role: :administrator) }
|
|
|
|
it 'returns details of the app' do
|
|
get api_v1_account_integrations_app_url(account_id: account.id, id: 'slack'),
|
|
headers: agent.create_new_auth_token,
|
|
as: :json
|
|
|
|
expect(response).to have_http_status(:success)
|
|
app = response.parsed_body
|
|
expect(app['id']).to eql('slack')
|
|
expect(app['name']).to eql('Slack')
|
|
end
|
|
|
|
it 'will not return sensitive information for openai app for agents' do
|
|
openai = create(:integrations_hook, :openai, account: account)
|
|
get api_v1_account_integrations_app_url(account_id: account.id, id: openai.app.id),
|
|
headers: agent.create_new_auth_token,
|
|
as: :json
|
|
|
|
expect(response).to have_http_status(:success)
|
|
|
|
app = response.parsed_body
|
|
expect(app['hooks'].first['settings']).to be_nil
|
|
end
|
|
|
|
it 'returns visible hook settings for openai app for admins' do
|
|
openai = create(:integrations_hook, :openai, account: account)
|
|
get api_v1_account_integrations_app_url(account_id: account.id, id: openai.app.id),
|
|
headers: admin.create_new_auth_token,
|
|
as: :json
|
|
|
|
expect(response).to have_http_status(:success)
|
|
|
|
app = response.parsed_body
|
|
expect(app['hooks'].first['settings']).not_to be_nil
|
|
end
|
|
|
|
it 'hides credentials and keeps visible settings for google credential integrations' do
|
|
hook = create(:integrations_hook, :google_translate, account: account,
|
|
settings: { project_id: 'project-1',
|
|
credentials: { private_key: 'secret' } })
|
|
get api_v1_account_integrations_app_url(account_id: account.id, id: hook.app.id),
|
|
headers: admin.create_new_auth_token,
|
|
as: :json
|
|
|
|
app = response.parsed_body
|
|
expect(app['hooks'].first['settings']).to eq('project_id' => 'project-1')
|
|
end
|
|
|
|
it 'returns empty settings for oauth integrations with no visible properties' do
|
|
hook = create(
|
|
:integrations_hook,
|
|
:linear,
|
|
account: account,
|
|
settings: { token_type: 'Bearer', refresh_token: 'refresh-secret', expires_in: 7200 }
|
|
)
|
|
get api_v1_account_integrations_app_url(account_id: account.id, id: hook.app.id),
|
|
headers: admin.create_new_auth_token,
|
|
as: :json
|
|
|
|
app = response.parsed_body
|
|
expect(app['hooks'].first['settings']).to eq({})
|
|
end
|
|
|
|
it 'does not expose leadsquared credential keys in visible settings' do
|
|
account.enable_features('crm_integration')
|
|
hook = create(:integrations_hook, :leadsquared, account: account,
|
|
settings: {
|
|
'access_key' => 'access-secret',
|
|
'secret_key' => 'secret',
|
|
'endpoint_url' => 'https://api.leadsquared.com/',
|
|
'enable_conversation_activity' => true
|
|
})
|
|
get api_v1_account_integrations_app_url(account_id: account.id, id: hook.app.id),
|
|
headers: admin.create_new_auth_token,
|
|
as: :json
|
|
|
|
settings = response.parsed_body['hooks'].first['settings']
|
|
expect(settings).to eq(
|
|
'endpoint_url' => 'https://api.leadsquared.com/',
|
|
'enable_conversation_activity' => true
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|