diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.test.tsx index 6adc5d6c5f0e7..c35235491a9df 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.test.tsx @@ -5,7 +5,11 @@ * 2.0. */ -import { validateHost, validateDownloadSourceHeaders } from './use_download_source_flyout_form'; +import { + validateHost, + validateDownloadSourceHeaders, + type AuthType, +} from './use_download_source_flyout_form'; describe('Download source form validation', () => { describe('validateHost', () => { @@ -56,6 +60,65 @@ describe('Download source form validation', () => { expect(res).toBeUndefined(); }); + it('should return undefined when Authorization header is present with authType "none"', () => { + const res = validateDownloadSourceHeaders( + [{ key: 'Authorization', value: 'Bearer token' }], + 'none' + ); + + expect(res).toBeUndefined(); + }); + + it.each(['username_password', 'api_key'])( + 'should return error when Authorization header conflicts with %s authType', + (authType) => { + const res = validateDownloadSourceHeaders( + [ + { key: 'X-Custom-Header', value: 'custom-value' }, + { key: 'Authorization', value: 'Bearer token' }, + ], + authType + ); + + expect(res).toEqual([ + { + message: + 'Cannot use "Authorization" header when credentials are configured. The credentials will overwrite this header.', + index: 1, + hasKeyError: true, + hasValueError: false, + }, + ]); + } + ); + + it('should return error when Authorization header conflicts with api_key authType', () => { + const res = validateDownloadSourceHeaders( + [{ key: 'Authorization', value: 'Bearer token' }], + 'api_key' + ); + + expect(res).toEqual([ + { + message: + 'Cannot use "Authorization" header when credentials are configured. The credentials will overwrite this header.', + index: 0, + hasKeyError: true, + hasValueError: false, + }, + ]); + }); + + it('should return error for Authorization header regardless of case when credentials are set', () => { + const res = validateDownloadSourceHeaders( + [{ key: 'authorization', value: 'Bearer token' }], + 'api_key' + ); + + expect(res).toHaveLength(1); + expect(res![0]).toMatchObject({ index: 0, hasKeyError: true }); + }); + it('should return undefined for empty headers (both key and value empty)', () => { const res = validateDownloadSourceHeaders([{ key: '', value: '' }]); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.tsx index dd8861d20e4fe..f761fc82586ee 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; @@ -121,10 +121,16 @@ export function useDowloadSourceFlyoutForm(onSuccess: () => void, downloadSource isEditDisabled ); + const validateHeadersWithAuthType = useMemo( + () => (pairs: Array<{ key: string; value: string }>) => + validateDownloadSourceHeaders(pairs, authTypeInput.value as AuthType), + [authTypeInput.value] + ); + const headersInput = useKeyValueInput( 'downloadSourceHeadersInput', (downloadSource as DownloadSourceBase)?.auth?.headers ?? [{ key: '', value: '' }], - validateDownloadSourceHeaders, + validateHeadersWithAuthType, isEditDisabled ); @@ -391,7 +397,10 @@ export function validateHost(value: string) { } } -export function validateDownloadSourceHeaders(pairs: Array<{ key: string; value: string }>) { +export function validateDownloadSourceHeaders( + pairs: Array<{ key: string; value: string }>, + authType?: AuthType +) { const errors: Array<{ message: string; index: number; @@ -450,6 +459,21 @@ export function validateDownloadSourceHeaders(pairs: Array<{ key: string; value: } else { existingKeys.add(key); } + + if (authType && authType !== 'none' && key.toLowerCase() === 'authorization') { + errors.push({ + message: i18n.translate( + 'xpack.fleet.settings.dowloadSourceFlyoutForm.headersAuthorizationConflictError', + { + defaultMessage: + 'Cannot use "Authorization" header when credentials are configured. The credentials will overwrite this header.', + } + ), + index, + hasKeyError: true, + hasValueError: false, + }); + } } }); if (errors.length) { diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/download_source/handler.ts b/x-pack/platform/plugins/shared/fleet/server/routes/download_source/handler.ts index 822c0d992cefc..15f6f15cafda6 100644 --- a/x-pack/platform/plugins/shared/fleet/server/routes/download_source/handler.ts +++ b/x-pack/platform/plugins/shared/fleet/server/routes/download_source/handler.ts @@ -72,6 +72,18 @@ export function validateDownloadSource(downloadSource: DownloadSourceWithNullabl if (hasPassword && !hasUsername) { throw Boom.badRequest('Username and password must be provided together'); } + + // Disallow "Authorization" custom header when credentials (username/password or api_key) are configured, + // as the credentials would overwrite the header value. + const hasCredentials = Boolean(hasUsernameOrPassword || hasApiKey); + const hasAuthorizationHeader = downloadSource.auth?.headers?.some( + (header) => header.key.toLowerCase() === 'authorization' + ); + if (hasCredentials && hasAuthorizationHeader) { + throw Boom.badRequest( + 'Cannot use "Authorization" custom header when username/password or api_key authentication is configured' + ); + } } export const getDownloadSourcesHandler: RequestHandler = async (context, request, response) => { diff --git a/x-pack/platform/test/fleet_api_integration/apis/download_sources/crud.ts b/x-pack/platform/test/fleet_api_integration/apis/download_sources/crud.ts index ad8444757a69e..3f9f9d27c698c 100644 --- a/x-pack/platform/test/fleet_api_integration/apis/download_sources/crud.ts +++ b/x-pack/platform/test/fleet_api_integration/apis/download_sources/crud.ts @@ -97,6 +97,16 @@ export default function (providerContext: FtrProviderContext) { } }; + const authHeaderConflictCases: Array<[string, { auth?: {}; secrets?: {} }]> = [ + ['username/password', { auth: { username: 'testuser', password: 'testpassword' } }], + [ + 'username/password as secret', + { auth: { username: 'testuser' }, secrets: { auth: { password: 'testpassword' } } }, + ], + ['api_key', { auth: { api_key: 'my-api-key' } }], + ['api_key as secret', { secrets: { auth: { api_key: 'my-api-key' } } }], + ]; + describe('fleet_download_sources_crud', function () { let defaultDownloadSourceId: string; let fleetServerPolicyId: string; @@ -505,6 +515,28 @@ export default function (providerContext: FtrProviderContext) { ); }); + authHeaderConflictCases.forEach(([description, { auth, secrets }]) => { + it(`should return 400 when Authorization header is combined with ${description} credentials on create`, async function () { + const baseAuth = { + headers: [{ key: 'Authorization', value: 'Bearer token' }], + }; + + const res = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Download source auth conflict ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { ...baseAuth, ...auth }, + ...(secrets ? { secrets } : {}), + }) + .expect(400); + + expect(res.body.message).to.contain('Cannot use "Authorization" custom header'); + }); + }); + it('should store auth secrets when fleet server meets minimum version', async function () { await clearAgents(); await createFleetServerAgent(fleetServerPolicyId, 'server_1', '9.4.0'); @@ -792,6 +824,38 @@ export default function (providerContext: FtrProviderContext) { .expect(400); }); + authHeaderConflictCases.forEach(([description, { auth, secrets }]) => { + it(`should return 400 when Authorization header is combined with ${description} credentials on update`, async function () { + const { body: createRes } = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Bad auth combination test ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + }) + .expect(200); + + const baseAuth = { + headers: [{ key: 'Authorization', value: 'Bearer token' }], + }; + + const res = await supertest + .put(`/api/fleet/agent_download_sources/${createRes.item.id}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Download source auth conflict ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { ...baseAuth, ...auth }, + ...(secrets ? { secrets } : {}), + }) + .expect(400); + + expect(res.body.message).to.contain('Cannot use "Authorization" custom header'); + }); + }); + it('should delete password secret when switching from username/password to api_key', async function () { await clearAgents(); await createFleetServerAgent(fleetServerPolicyId, 'server_1', '9.4.0');