Skip to content
Closed
16 changes: 15 additions & 1 deletion docs/reference/configuration-reference/alerting-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ $$$action-config-email-domain-allowlist$$$

If your proxy is using the https protocol (vs the http protocol), the setting `xpack.actions.ssl.proxyVerificationMode: none` will likely be needed, unless your proxy’s certificates are signed using a publicly available certificate authority.

There is currently no support for using basic authentication with a proxy (authentication for the proxy itself, not the URL being requested through the proxy).
You can supply proxy credentials in the URL (`http://user:password@proxy-host:8080`) or use [`xpack.actions.proxyUser`](#action-config-proxy-user) and [`xpack.actions.proxyPassword`](#action-config-proxy-password). If the URL already includes a username and password, those take precedence over the separate settings.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
You can supply proxy credentials in the URL (`http://user:password@proxy-host:8080`) or use [`xpack.actions.proxyUser`](#action-config-proxy-user) and [`xpack.actions.proxyPassword`](#action-config-proxy-password). If the URL already includes a username and password, those take precedence over the separate settings.
{applies_to}`stack: ga 9.4+` You can supply proxy credentials in the URL (`http://user:password@proxy-host:8080`) or use [`xpack.actions.proxyUser`](#action-config-proxy-user) and [`xpack.actions.proxyPassword`](#action-config-proxy-password). If the URL already includes a username and password, those take precedence over the separate settings.


Data type: `string`

Expand All @@ -230,6 +230,20 @@ $$$action-config-email-domain-allowlist$$$
curl --verbose --proxytunnel --proxy http://localhost:8080 <EXAMPLE_URL>
```

$$$action-config-proxy-user$$$

`xpack.actions.proxyUser` ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg 'Supported on {{ech}}')

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`xpack.actions.proxyUser` ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg 'Supported on {{ech}}')
`xpack.actions.proxyUser` ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg 'Supported on {{ech}}') {applies_to}`stack: ga 9.4+`

: Username for HTTP basic authentication with the proxy when [`xpack.actions.proxyUrl`](#action-settings) is set. Use with `xpack.actions.proxyPassword`. Ignored if the proxy URL already includes credentials. Store the password in the [Kibana keystore](docs-content://deploy-manage/security/secure-settings.md) when possible.

Data type: `string`

$$$action-config-proxy-password$$$

`xpack.actions.proxyPassword` ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg 'Supported on {{ech}}')

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`xpack.actions.proxyPassword` ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg 'Supported on {{ech}}')
`xpack.actions.proxyPassword` ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg 'Supported on {{ech}}') {applies_to}`stack: ga 9.4+`

: Password for HTTP basic authentication with the proxy when [`xpack.actions.proxyUrl`](#action-settings) is set. Use with `xpack.actions.proxyUser`.

Data type: `string`

`xpack.actions.proxyBypassHosts` ![logo cloud](https://doc-icons.s3.us-east-2.amazonaws.com/logo_cloud.svg 'Supported on {{ech}}')
: Specifies hostnames which should not use the proxy, if using a proxy for actions. The value is an array of hostnames as strings.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,9 @@ kibana_vars=(
xpack.actions.proxyBypassHosts
xpack.actions.proxyHeaders
xpack.actions.proxyOnlyHosts
xpack.actions.proxyPassword
xpack.actions.proxyUrl
xpack.actions.proxyUser
xpack.actions.responseTimeout
xpack.actions.ssl.proxyVerificationMode
xpack.actions.ssl.verificationMode
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,47 @@ describe('getCustomAgents', () => {
expect(httpsAgent?.options.rejectUnauthorized).toBeTruthy();
});

test('applies proxyUser and proxyPassword when proxy URL has no credentials', () => {
const proxySettings = {
proxyUrl: 'https://someproxyhost:8080',
proxyUser: 'cfgUser',
proxyPassword: 'cfgPass:with:colons',
proxySSLSettings: {
verificationMode: 'none',
},
proxyBypassHosts: undefined,
proxyOnlyHosts: undefined,
} as ProxySettings;
const { httpsAgent } = getCustomAgents({
logger,
proxySettings,
sslSettings: defaultSSLSettings,
url: targetUrl,
});
expect(httpsAgent instanceof HttpsProxyAgent).toBeTruthy();
expect((httpsAgent as any).proxy.auth).toBe('cfgUser:cfgPass:with:colons');
});

test('URL-embedded proxy credentials override proxyUser and proxyPassword', () => {
const proxySettings = {
proxyUrl: 'https://urlUser:urlPass@someproxyhost:8080',
proxyUser: 'cfgUser',
proxyPassword: 'cfgPass',
proxySSLSettings: {
verificationMode: 'none',
},
proxyBypassHosts: undefined,
proxyOnlyHosts: undefined,
} as ProxySettings;
const { httpsAgent } = getCustomAgents({
logger,
proxySettings,
sslSettings: defaultSSLSettings,
url: targetUrl,
});
expect((httpsAgent as any).proxy.auth).toBe('urlUser:urlPass');
});

test('handles overriding global verificationMode "full" with a proxy', () => {
const sslSettings = {
verificationMode: 'full',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,17 +133,39 @@ export function getCustomAgents(opts: GetCustomAgentsOpts): GetCustomAgentsRespo
proxySettings.proxySSLSettings.verificationMode,
sslOverrides
);

const hasUrlAuth = Boolean(proxyUrl.username && proxyUrl.password);
const hasConfigAuth = Boolean(
proxySettings.proxyUser &&
proxySettings.proxyPassword &&
proxySettings.proxyUser !== '' &&
proxySettings.proxyPassword !== ''

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Boolean('') is already false.

);
let proxyAuth: string | undefined;
if (hasUrlAuth) {
proxyAuth = `${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password)}`;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was surprised to learn that the username/password from URL are encoded!

$ node -p 'new URL("https://elas<tic:changeme@example.com")'
URL {
  ...
  username: 'elas%3Ctic',
  ...
}

} else if (hasConfigAuth) {
proxyAuth = `${proxySettings.proxyUser}:${proxySettings.proxyPassword}`;
}

let httpProxyAgentUrl = proxySettings.proxyUrl;
if (!hasUrlAuth && hasConfigAuth) {
const withAuth = new URL(proxySettings.proxyUrl);
withAuth.username = proxySettings.proxyUser as string;
withAuth.password = proxySettings.proxyPassword as string;
httpProxyAgentUrl = withAuth.toString();

@pmuellr pmuellr Mar 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds the auth info to the URL. Which the http agents we use check for explicitly (I don't remember them doing that when we first started using it), and send the appropriate auth headers for.

You'll also see we set the auth option for the HttpsProxyAgent, to the same value. It looks like the HttpProxyAgent also accepts options now (I think it didn't use to, except maybe timeout?). So seems like these should be aligned.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, should we align them with this PR?
Because proxyAuth is decoded but httpProxyAgentUrl is not, they may send different credentials.

}

// At this point, we are going to use a proxy, so we need new agents.
// We will though, copy over the calculated ssl options from above, into
// the https agent.
const httpAgent = new HttpProxyAgent(proxySettings.proxyUrl) as unknown as HttpAgent;
const httpAgent = new HttpProxyAgent(httpProxyAgentUrl) as unknown as HttpAgent;
const httpsAgent = new HttpsProxyAgent({
host: proxyUrl.hostname,
port: Number(proxyUrl.port),
port: Number(proxyUrl.port) || (proxyUrl.protocol === 'https:' ? 443 : 80),
protocol: proxyUrl.protocol,
headers: proxySettings.proxyHeaders,
...(proxyUrl.username &&
proxyUrl.password && { auth: `${proxyUrl.username}:${proxyUrl.password}` }),
...(proxyAuth && { auth: proxyAuth }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be a breaking change for the existing users if they have special characters in their creds?

// do not fail on invalid certs if value is false
...proxyNodeSSLOptions,
}) as unknown as HttpsAgent;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,7 @@ export interface ProxySettings {
proxyOnlyHosts: Set<string> | undefined;
proxyHeaders?: Record<string, string>;
proxySSLSettings: SSLSettings;
/** When set with proxyPassword, used for proxy HTTP basic auth if proxyUrl has no credentials. */
proxyUser?: string;
proxyPassword?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,18 @@ describe('getProxySettings', () => {
expect(proxySettings?.proxyOnlyHosts).toEqual(new Set(proxyOnlyHosts));
});

test('returns proxyUser and proxyPassword when set', () => {
const config: ActionsConfig = {
...defaultActionsConfig,
proxyUrl: 'https://proxy.elastic.co',
proxyUser: 'proxy_user',
proxyPassword: 'proxy_secret',
};
const proxySettings = getActionsConfigurationUtilities(config).getProxySettings();
expect(proxySettings?.proxyUser).toBe('proxy_user');
expect(proxySettings?.proxyPassword).toBe('proxy_secret');
});

test('getCustomHostSettings() returns undefined when no matching config', () => {
const httpsUrl = 'https://elastic.co/foo/bar';
const smtpUrl = 'smtp://elastic.co';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ function getProxySettingsFromConfig(config: ActionsConfig): undefined | ProxySet
proxyOnlyHosts: arrayAsSet(config.proxyOnlyHosts),
proxyHeaders: config.proxyHeaders,
proxySSLSettings: getSSLSettingsFromConfig(config.ssl?.proxyVerificationMode),
...(config.proxyUser != null && { proxyUser: config.proxyUser }),
...(config.proxyPassword != null && { proxyPassword: config.proxyPassword }),
};
}

Expand Down
2 changes: 2 additions & 0 deletions x-pack/platform/plugins/shared/actions/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ export const configSchema = schema.object({
validate: validatePreconfigured,
}),
proxyUrl: schema.maybe(schema.string()),
proxyUser: schema.maybe(schema.string()),
proxyPassword: schema.maybe(schema.string()),
proxyHeaders: schema.maybe(schema.recordOf(schema.string(), schema.string())),
proxyBypassHosts: schema.maybe(schema.arrayOf(schema.string({ hostname: true }))),
proxyOnlyHosts: schema.maybe(schema.arrayOf(schema.string({ hostname: true }))),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,32 @@ describe('request', () => {
expect(httpsAgent.proxy.auth).toBe('proxyuser:proxypass');
});

test('it passes proxy auth from proxyUser and proxyPassword when URL has no credentials', async () => {
configurationUtilities.getProxySettings.mockReturnValue({
proxySSLSettings: {
verificationMode: 'full',
},
proxyUrl: 'https://myproxy:8080',
proxyUser: 'fromConfig',
proxyPassword: 'fromSecret',
proxyBypassHosts: undefined,
proxyOnlyHosts: undefined,
});

await request({
axios,
url: TestUrl,
logger,
configurationUtilities,
});

expect(axiosMock.mock.calls.length).toBe(1);
// @ts-expect-error Auto-mocked axios has unknown request config type
const { httpsAgent } = axiosMock.mock.calls[0][1];
expect(httpsAgent instanceof HttpsProxyAgent).toBe(true);
expect(httpsAgent.proxy.auth).toBe('fromConfig:fromSecret');
});

test('it does not set proxy auth on HttpsProxyAgent when proxySettings has no credentials', async () => {
await request({
axios,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ interface CreateTestConfigOptions {
disabledPlugins?: string[];
ssl?: boolean;
enableActionsProxy: boolean;
actionsProxyBasicAuth?: { user: string; password: string };
verificationMode?: 'full' | 'none' | 'certificate';
publicBaseUrl?: boolean;
preconfiguredAlertHistoryEsIndex?: boolean;
Expand Down Expand Up @@ -265,6 +266,12 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions)
? [
`--xpack.actions.proxyUrl=http://localhost:${proxyPort}`,
`--xpack.actions.proxyOnlyHosts=${JSON.stringify(proxyHosts)}`,
...(options.actionsProxyBasicAuth
? [
`--xpack.actions.proxyUser=${options.actionsProxyBasicAuth.user}`,
`--xpack.actions.proxyPassword=${options.actionsProxyBasicAuth.password}`,
]
: []),
]
: [
`--xpack.actions.proxyUrl=http://elastic.co`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ export { checkAAD } from './check_aad';
export { getEventLog } from './get_event_log';
export { createWaitForExecutionCount } from './wait_for_execution_count';
export { resetRulesSettings } from './reset_rules_settings';
export { ProxyAuthUser, ProxyAuthPassword } from './proxy';
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

export const ProxyAuthUser = 'ftr_proxy_user';
export const ProxyAuthPassword = 'ftr_proxy_pass';
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { getProxyPort } from '@kbn/alerting-api-integration-helpers';
import { getDataFromRequest } from './data_handler';

export interface ProxyArgs {
config: string;
config: string[];
proxyHandler?: (proxyRes?: unknown, req?: unknown, res?: unknown) => void;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,43 +5,111 @@
* 2.0.
*/

import http from 'http';
import httpProxy from 'http-proxy';

function getProxyBasicAuthFromServerArgs(
kbnTestServerConfig: string[]
): { user: string; password: string } | undefined {
const userLine = kbnTestServerConfig.find((val: string) =>
val.startsWith('--xpack.actions.proxyUser=')
);
const passLine = kbnTestServerConfig.find((val: string) =>
val.startsWith('--xpack.actions.proxyPassword=')
);
if (!userLine || !passLine) {
return undefined;
}

const result = {
user: userLine.replace('--xpack.actions.proxyUser=', ''),
password: passLine.replace('--xpack.actions.proxyPassword=', ''),
};
return result;
}

type ProxyReqHandler = (
proxyReq?: http.ClientRequest,
req?: http.IncomingMessage,
res?: http.ServerResponse
) => void;

type ProxyResHandler = (
proxyRes?: http.IncomingMessage,
req?: http.IncomingMessage,
res?: http.ServerResponse
) => void;

export const getHttpProxyServer = async (
targetUrl: string,
kbnTestServerConfig: any,
onProxyResHandler: (proxyRes?: unknown, req?: unknown, res?: unknown) => void
kbnTestServerConfig: string[],
onProxyResHandler: ProxyResHandler,
onProxyReqHandler?: ProxyReqHandler
): Promise<httpProxy> => {
const proxyServer = httpProxy.createProxyServer({
target: targetUrl,
secure: false,
selfHandleResponse: false,
});

proxyServer.on('proxyRes', (proxyRes: unknown, req: unknown, res: unknown) => {
proxyServer.on('proxyRes', (proxyRes, req, res) => {
onProxyResHandler(proxyRes, req, res);
});

// http-proxy doesn't propagate client disconnects to the target server.
// Tear down the proxied request when the client disconnects early (e.g. when the request is aborted).
proxyServer.on('proxyReq', (proxyReq, req, res) => {
res.on('close', () => {
if (!res.writableFinished) {
proxyReq.destroy();
}
});
onProxyReqHandler?.(proxyReq, req, res);
});

const proxyPort = getProxyPort(kbnTestServerConfig);
const basicAuth = getProxyBasicAuthFromServerArgs(kbnTestServerConfig);

if (basicAuth) {
const expectedAuth = `Basic ${Buffer.from(
`${basicAuth.user}:${basicAuth.password}`,
'utf8'
).toString('base64')}`;
const server = http.createServer((req, res) => {
const proxyAuthHeader = req.headers['proxy-authorization'];

if (proxyAuthHeader !== expectedAuth) {
if (proxyAuthHeader == null) {
// eslint-disable-next-line no-console
console.log('Proxy-Authorization header is missing');
} else {
const encodedCreds = proxyAuthHeader.replace('Basic ', '');
const decodedAuth = Buffer.from(encodedCreds, 'base64').toString('utf8');
// eslint-disable-next-line no-console
console.log(`Proxy-Authorization header is using credentials ${decodedAuth}`);
}

res.writeHead(407, { 'Proxy-Authenticate': 'Basic realm="proxy"' });
res.end();
return;
}
proxyServer.web(req, res);
});
server.listen(proxyPort);
return server as unknown as httpProxy;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would changing the return type to httpProxy | http.Server fix this?

}

proxyServer.listen(proxyPort);

return proxyServer;
};

export const getProxyPort = (kbnTestServerConfig: any): number => {
export const getProxyPort = (kbnTestServerConfig: string[]): number => {
const proxyUrl = kbnTestServerConfig
.find((val: string) => val.startsWith('--xpack.actions.proxyUrl='))
.replace('--xpack.actions.proxyUrl=', '');
?.replace('--xpack.actions.proxyUrl=', '');

if (!proxyUrl) {
throw new Error('Expected --xpack.actions.proxyUrl= in kbn test server args');
}

const urlObject = new URL(proxyUrl);
return Number(urlObject.port);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@

import { createTestConfig } from '../../common/config';
import { EmailMaximumBodyLength } from '../group2/config';
import { ProxyAuthUser, ProxyAuthPassword } from '../../common/lib';

export default createTestConfig('security_and_spaces', {
disabledPlugins: [],
license: 'trial',
ssl: true,
enableActionsProxy: true,
actionsProxyBasicAuth: { user: ProxyAuthUser, password: ProxyAuthPassword },
publicBaseUrl: true,
testFiles: [require.resolve('./tests')],
useDedicatedTaskRunner: true,
Expand Down
Loading
Loading