## 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>
148 lines
4.4 KiB
Ruby
148 lines
4.4 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe Integrations::App do
|
|
let(:apps) { described_class }
|
|
let(:app) { apps.find(id: app_name) }
|
|
let(:account) { create(:account) }
|
|
|
|
before { allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true) }
|
|
|
|
describe '#name' do
|
|
let(:app_name) { 'slack' }
|
|
|
|
it 'returns the name' do
|
|
expect(app.name).to eq('Slack')
|
|
end
|
|
end
|
|
|
|
describe '#logo' do
|
|
let(:app_name) { 'slack' }
|
|
|
|
it 'returns the logo' do
|
|
expect(app.logo).to eq('slack.png')
|
|
end
|
|
end
|
|
|
|
describe '#visible_properties' do
|
|
context 'when the app has visible properties' do
|
|
let(:app_name) { 'dialogflow' }
|
|
|
|
it 'returns the configured property names as strings' do
|
|
expect(app.visible_properties).to contain_exactly('project_id', 'region', 'language_code')
|
|
end
|
|
end
|
|
|
|
context 'when the app has no visible properties configured' do
|
|
let(:app_name) { 'webhook' }
|
|
|
|
it 'defaults to an empty list' do
|
|
expect(app.visible_properties).to eq([])
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#action' do
|
|
let(:app_name) { 'slack' }
|
|
|
|
before do
|
|
allow(Current).to receive(:account).and_return(account)
|
|
end
|
|
|
|
context 'when the app is slack' do
|
|
it 'returns the action URL with client_id and redirect_uri' do
|
|
allow(GlobalConfigService).to receive(:load).and_call_original
|
|
allow(GlobalConfigService).to receive(:load).with('SLACK_CLIENT_ID', nil).and_return('dummy_client_id')
|
|
|
|
expect(app.action).to include('client_id=dummy_client_id')
|
|
expect(app.action).to include(
|
|
"/app/accounts/#{account.id}/settings/integrations/slack"
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#active?' do
|
|
let(:app_name) { 'slack' }
|
|
|
|
context 'when the app is slack' do
|
|
it 'returns true if SLACK_CLIENT_SECRET is present' do
|
|
allow(GlobalConfigService).to receive(:load).with('SLACK_CLIENT_SECRET', nil).and_return('random_secret')
|
|
|
|
expect(app.active?(account)).to be true
|
|
end
|
|
end
|
|
|
|
context 'when the app is shopify' do
|
|
let(:app_name) { 'shopify' }
|
|
|
|
it 'returns true if the shopify integration feature is enabled' do
|
|
account.enable_features('shopify_integration')
|
|
allow(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_ID', nil).and_return('client_id')
|
|
expect(app.active?(account)).to be true
|
|
end
|
|
|
|
it 'returns false if the shopify integration feature is disabled' do
|
|
allow(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_ID', nil).and_return('client_id')
|
|
expect(app.active?(account)).to be false
|
|
end
|
|
|
|
it 'returns false if SHOPIFY_CLIENT_ID is not present, even if feature is enabled' do
|
|
account.enable_features('shopify_integration')
|
|
allow(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_ID', nil).and_return(nil)
|
|
expect(app.active?(account)).to be false
|
|
end
|
|
end
|
|
|
|
context 'when the app is linear' do
|
|
let(:app_name) { 'linear' }
|
|
|
|
it 'returns false if the linear integration feature is disabled' do
|
|
expect(app.active?(account)).to be false
|
|
end
|
|
|
|
it 'returns true if the linear integration feature is enabled' do
|
|
account.enable_features('linear_integration')
|
|
account.save!
|
|
allow(GlobalConfigService).to receive(:load).with('LINEAR_CLIENT_ID', nil).and_return('client_id')
|
|
expect(app.active?(account)).to be true
|
|
end
|
|
end
|
|
|
|
context 'when other apps are queried' do
|
|
let(:app_name) { 'webhook' }
|
|
|
|
it 'returns true' do
|
|
expect(app.active?(account)).to be true
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#enabled?' do
|
|
context 'when the app is webhook' do
|
|
let(:app_name) { 'webhook' }
|
|
|
|
it 'returns false if the account does not have any webhooks' do
|
|
expect(app.enabled?(account)).to be false
|
|
end
|
|
|
|
it 'returns true if the account has webhooks' do
|
|
create(:webhook, account: account)
|
|
expect(app.enabled?(account)).to be true
|
|
end
|
|
end
|
|
|
|
context 'when the app is anything other than webhook' do
|
|
let(:app_name) { 'openai' }
|
|
|
|
it 'returns false if the account does not have any hooks for the app' do
|
|
expect(app.enabled?(account)).to be false
|
|
end
|
|
|
|
it 'returns true if the account has hooks for the app' do
|
|
create(:integrations_hook, :openai, account: account)
|
|
expect(app.enabled?(account)).to be true
|
|
end
|
|
end
|
|
end
|
|
end
|