fix: redact sensitive integration secrets from API responses (#14147)

## 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>
This commit is contained in:
Botshxlo
2026-06-14 06:53:13 +05:30
committed by GitHub
co-authored by Botshelo Nokoane Sony Mathew Sony Mathew
parent 006b529918
commit 27404b4a27
5 changed files with 134 additions and 25 deletions
+4
View File
@@ -30,6 +30,10 @@ class Integrations::App
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
+7 -2
View File
@@ -5,5 +5,10 @@ json.inbox resource.inbox&.slice(:id, :name)
json.account_id resource.account_id
json.hook_type resource.hook_type
json.settings resource.settings if Current.account_user&.administrator?
json.reference_id resource.reference_id if Current.account_user&.administrator?
if Current.account_user&.administrator?
visible_properties = resource.app&.visible_properties || []
settings = (resource.settings || {}).select { |key, _| visible_properties.include?(key.to_s) }
json.settings settings
json.reference_id resource.reference_id
end
+6 -2
View File
@@ -6,6 +6,7 @@
# hook_type: ( account / inbox )
# feature_flag: (string) feature flag to enable/disable the integration
# allow_multiple_hooks: whether multiple hooks can be created for the integration
# visible_properties: hook setting keys safe to return in API responses and show in the UI
# settings_json_schema: the json schema used to validate the settings hash (https://json-schema.org/)
# settings_form_schema: the formulate schema used in frontend to render settings form (https://vueformulate.com/)
########################################################
@@ -55,7 +56,7 @@ openai:
'validation': '',
},
]
visible_properties: ['api_key', 'label_suggestion']
visible_properties: ['label_suggestion']
linear:
id: linear
logo: linear.png
@@ -63,12 +64,14 @@ linear:
action: https://linear.app/oauth/authorize
hook_type: account
allow_multiple_hooks: false
visible_properties: []
notion:
id: notion
logo: notion.png
i18n_key: notion
hook_type: account
allow_multiple_hooks: false
visible_properties: []
slack:
id: slack
logo: slack.png
@@ -76,6 +79,7 @@ slack:
action: https://slack.com/oauth/v2/authorize?scope=commands,chat:write,channels:read,channels:manage,channels:join,groups:read,groups:write,im:write,mpim:write,users:read,users:read.email,chat:write.customize,channels:history,groups:history,mpim:history,im:history,files:read,files:write
hook_type: account
allow_multiple_hooks: false
visible_properties: ['channel_name']
dialogflow:
id: dialogflow
logo: dialogflow.png
@@ -240,6 +244,7 @@ shopify:
i18n_key: shopify
hook_type: account
allow_multiple_hooks: false
visible_properties: []
leadsquared:
id: leadsquared
@@ -310,7 +315,6 @@ leadsquared:
]
visible_properties:
[
'access_key',
'endpoint_url',
'enable_conversation_activity',
'enable_transcript_activity',
@@ -42,7 +42,7 @@ RSpec.describe 'Integration Apps API', type: :request do
expect(app['hooks'].first['settings']).to be_nil
end
it 'returns all active apps with sensitive information if user is an admin' do
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,
@@ -56,19 +56,21 @@ RSpec.describe 'Integration Apps API', type: :request do
end
it 'returns slack app with appropriate redirect url when configured' do
with_modified_env SLACK_CLIENT_ID: 'client_id', SLACK_CLIENT_SECRET: 'client_secret' do
get api_v1_account_integrations_apps_url(account),
headers: admin.create_new_auth_token,
as: :json
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')
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
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 'will return sensitive information for openai app for admins' do
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,
@@ -79,6 +81,34 @@ RSpec.describe 'Integration Apps API', type: :request do
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
@@ -117,7 +147,7 @@ RSpec.describe 'Integration Apps API', type: :request do
expect(app['hooks'].first['settings']).to be_nil
end
it 'will return sensitive information for openai app for admins' do
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,
@@ -128,6 +158,53 @@ RSpec.describe 'Integration Apps API', type: :request do
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
+28 -9
View File
@@ -23,6 +23,24 @@ RSpec.describe Integrations::App do
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' }
@@ -32,12 +50,13 @@ RSpec.describe Integrations::App do
context 'when the app is slack' do
it 'returns the action URL with client_id and redirect_uri' do
with_modified_env SLACK_CLIENT_ID: 'dummy_client_id' do
expect(app.action).to include('client_id=dummy_client_id')
expect(app.action).to include(
"/app/accounts/#{account.id}/settings/integrations/slack"
)
end
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
@@ -47,9 +66,9 @@ RSpec.describe Integrations::App do
context 'when the app is slack' do
it 'returns true if SLACK_CLIENT_SECRET is present' do
with_modified_env SLACK_CLIENT_SECRET: 'random_secret' do
expect(app.active?(account)).to be true
end
allow(GlobalConfigService).to receive(:load).with('SLACK_CLIENT_SECRET', nil).and_return('random_secret')
expect(app.active?(account)).to be true
end
end