[RAM] Add support for self-signed SSL certificate auth in webhook connector (#161894)

## Summary

Closes #160812 

Adds UI for CA and client-side SSL certificate auth to webhook
connector, and adds these properties to the webhook's HttpsAgent on
execution:

<img width="912" alt="Screenshot 2023-07-18 at 11 59 33 AM"
src="b1986c8d-3632-4663-80ff-3fb37f706d07">

### When Editing
<img width="868" alt="Screenshot 2023-07-14 at 3 34 57 PM"
src="e3e0f450-1196-45fd-88a6-1e15b6f2fa36">


Also creates a Field helper for EuiFilePicker

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Zacqary Adam Xeper 2023-08-03 11:46:40 -05:00 committed by GitHub
parent b9dae73789
commit 7e2ba48c18
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 1503 additions and 137 deletions

View file

@ -0,0 +1,68 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { Fragment, ReactChildren } from 'react';
import { EuiFormRow, EuiSpacer, EuiCheckableCard, useGeneratedHtmlId } from '@elastic/eui';
import { FieldHook, getFieldValidityAndErrorMessage } from '../../hook_form_lib';
interface Props {
field: FieldHook;
options: Array<{
label: string;
value: string;
children: ReactChildren;
'data-test-subj'?: string;
}>;
euiFieldProps?: Record<string, any>;
idAria?: string;
[key: string]: any;
}
export const CardRadioGroupField = ({
field,
options,
euiFieldProps = {},
idAria,
...rest
}: Props) => {
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
const radioGroupId = useGeneratedHtmlId({ prefix: 'radioGroup' });
return (
<EuiFormRow
label={field.label}
labelType="legend"
helpText={typeof field.helpText === 'function' ? field.helpText() : field.helpText}
error={errorMessage}
isInvalid={isInvalid}
fullWidth
describedByIds={idAria ? [idAria] : undefined}
{...rest}
>
<>
{options.map(({ label, value, children, 'data-test-subj': dataTestSubj }) => (
<Fragment key={`${radioGroupId}-${value}`}>
<EuiCheckableCard
id={`${radioGroupId}-${value}`}
label={label}
value={value}
name={radioGroupId}
checked={field.value === value}
onChange={() => field.setValue(value)}
data-test-subj={dataTestSubj}
>
{children}
</EuiCheckableCard>
<EuiSpacer size="s" />
</Fragment>
))}
</>
</EuiFormRow>
);
};

View file

@ -0,0 +1,80 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useCallback } from 'react';
import { EuiFormRow, EuiFilePicker } from '@elastic/eui';
import { FieldHook, getFieldValidityAndErrorMessage } from '../../hook_form_lib';
interface Props {
field: FieldHook;
euiFieldProps?: Record<string, any>;
idAria?: string;
maxFileSize?: number;
[key: string]: any;
}
const ONE_MEGABYTE = 1048576;
export const FilePickerField = ({
field,
euiFieldProps = {},
idAria,
maxFileSize = ONE_MEGABYTE,
...rest
}: Props) => {
const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field);
const onChange = useCallback(
async (files: FileList | null) => {
if (files) {
const filesArr = [];
try {
for (const file of files) {
if (file.size > maxFileSize)
throw new Error(
`${file.name} is too large, maximum size is ${Math.floor(maxFileSize) / 1024}kb`
);
const fileBuffer = await file.arrayBuffer();
// Base64 encode the file
const fileData = window.btoa(String.fromCharCode(...new Uint8Array(fileBuffer)));
filesArr.push(fileData);
}
if (!euiFieldProps.multiple) field.setValue(filesArr[0]);
else field.setValue(filesArr);
} catch (e) {
field.setErrors([e]);
}
} else {
field.setValue(null);
}
},
[field, maxFileSize, euiFieldProps]
);
return (
<EuiFormRow
label={field.label}
labelAppend={field.labelAppend}
helpText={typeof field.helpText === 'function' ? field.helpText() : field.helpText}
error={errorMessage}
isInvalid={isInvalid}
fullWidth
describedByIds={idAria ? [idAria] : undefined}
{...rest}
>
<EuiFilePicker
isInvalid={isInvalid}
onChange={onChange}
isLoading={field.isValidating}
data-test-subj="input"
{...euiFieldProps}
/>
</EuiFormRow>
);
};

View file

@ -23,3 +23,5 @@ export * from './multi_button_group_field';
export * from './date_picker_field';
export * from './password_field';
export * from './hidden_field';
export * from './file_picker_field';
export * from './card_radio_group_field';

View file

@ -27,15 +27,29 @@ import { resolveCustomHosts } from '../lib/custom_host_settings';
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
const CERT_DIR = '../../../../../../packages/kbn-dev-utils/certs';
const MOCK_CERT_DIR = '../mock_certs';
const KIBANA_CRT_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'kibana.crt'));
const KIBANA_KEY_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'kibana.key'));
const KIBANA_P12_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'kibana.p12'));
const CA_FILE = pathResolve(__filename, pathJoin(CERT_DIR, 'ca.crt'));
const UNAUTHORIZED_CA_FILE = pathResolve(
__filename,
pathJoin(MOCK_CERT_DIR, 'unauthorized_ca.crt')
);
const UNAUTHORIZED_CRT_FILE = pathResolve(__filename, pathJoin(MOCK_CERT_DIR, 'unauthorized.crt'));
const UNAUTHORIZED_KEY_FILE = pathResolve(__filename, pathJoin(MOCK_CERT_DIR, 'unauthorized.key'));
const KIBANA_KEY = fsReadFileSync(KIBANA_KEY_FILE, 'utf8');
const KIBANA_CRT = fsReadFileSync(KIBANA_CRT_FILE, 'utf8');
const KIBANA_P12 = fsReadFileSync(KIBANA_P12_FILE);
const CA = fsReadFileSync(CA_FILE, 'utf8');
const UNAUTHORIZED_KEY = fsReadFileSync(UNAUTHORIZED_KEY_FILE);
const UNAUTHORIZED_CRT = fsReadFileSync(UNAUTHORIZED_CRT_FILE);
const UNAUTHORIZED_CA = fsReadFileSync(UNAUTHORIZED_CA_FILE);
const Auth = 'elastic:changeme';
const AuthB64 = Buffer.from(Auth).toString('base64');
@ -208,6 +222,96 @@ describe('axios connections', () => {
const fn = async () => await request({ axios, url, logger, configurationUtilities });
await expect(fn()).rejects.toThrow('certificate');
});
test('it works with ca in SSL overrides', async () => {
const { url, server } = await createServer({ useHttps: true });
testServer = server;
const configurationUtilities = getACUfromConfig();
const sslOverrides = {
ca: Buffer.from(CA),
};
const res = await request({ axios, url, logger, configurationUtilities, sslOverrides });
expect(res.status).toBe(200);
});
test('it works with cert, key, and ca in SSL overrides', async () => {
const { url, server } = await createServer({ useHttps: true, requestCert: true });
testServer = server;
const configurationUtilities = getACUfromConfig();
const sslOverrides = {
ca: Buffer.from(CA),
cert: Buffer.from(KIBANA_CRT),
key: Buffer.from(KIBANA_KEY),
};
const res = await request({ axios, url, logger, configurationUtilities, sslOverrides });
expect(res.status).toBe(200);
});
test('it works with pfx and passphrase in SSL overrides', async () => {
const { url, server } = await createServer({ useHttps: true, requestCert: true });
testServer = server;
const configurationUtilities = getACUfromConfig();
const sslOverrides = {
pfx: KIBANA_P12,
passphrase: 'storepass',
};
const res = await request({ axios, url, logger, configurationUtilities, sslOverrides });
expect(res.status).toBe(200);
});
test('it fails with cert and key but no ca in SSL overrides', async () => {
const { url, server } = await createServer({ useHttps: true, requestCert: true });
testServer = server;
const configurationUtilities = getACUfromConfig();
const sslOverrides = {
cert: Buffer.from(KIBANA_CRT),
key: Buffer.from(KIBANA_KEY),
};
const fn = async () =>
await request({ axios, url, logger, configurationUtilities, sslOverrides });
await expect(fn()).rejects.toThrow('certificate');
});
test('it fails with pfx but no passphrase in SSL overrides', async () => {
const { url, server } = await createServer({ useHttps: true, requestCert: true });
testServer = server;
const configurationUtilities = getACUfromConfig();
const sslOverrides = {
pfx: KIBANA_P12,
};
const fn = async () =>
await request({ axios, url, logger, configurationUtilities, sslOverrides });
await expect(fn()).rejects.toThrow('mac verify');
});
test('it fails with a client-side certificate issued by an invalid ca', async () => {
const { url, server } = await createServer({ useHttps: true, requestCert: true });
testServer = server;
const configurationUtilities = getACUfromConfig();
const sslOverrides = {
ca: UNAUTHORIZED_CA,
cert: UNAUTHORIZED_CRT,
key: UNAUTHORIZED_KEY,
};
const fn = async () =>
await request({ axios, url, logger, configurationUtilities, sslOverrides });
await expect(fn()).rejects.toThrow('certificate');
});
test('it fails when requesting a client-side cert and none is provided', async () => {
const { url, server } = await createServer({ useHttps: true, requestCert: true });
testServer = server;
const configurationUtilities = getACUfromConfig();
const fn = async () => await request({ axios, url, logger, configurationUtilities });
await expect(fn()).rejects.toThrow('certificate');
});
});
// targetHttps, proxyHttps, and proxyAuth should all range over [false, true], but
@ -458,11 +562,13 @@ function removePassword(url: string) {
const TlsOptions = {
cert: KIBANA_CRT,
key: KIBANA_KEY,
ca: CA,
};
interface CreateServerOptions {
useHttps: boolean;
requireAuth?: boolean;
requestCert?: boolean;
}
interface CreateServerResult {
@ -471,7 +577,7 @@ interface CreateServerResult {
}
async function createServer(options: CreateServerOptions): Promise<CreateServerResult> {
const { useHttps, requireAuth = false } = options;
const { useHttps, requireAuth = false, requestCert = false } = options;
const port = await getPort();
const url = `http${useHttps ? 's' : ''}://${requireAuth ? `${Auth}@` : ''}localhost:${port}`;
@ -499,7 +605,7 @@ async function createServer(options: CreateServerOptions): Promise<CreateServerR
if (!useHttps) {
server = http.createServer(requestHandler);
} else {
server = https.createServer(TlsOptions, requestHandler);
server = https.createServer({ ...TlsOptions, requestCert }, requestHandler);
}
server.unref();

View file

@ -0,0 +1,21 @@
-----BEGIN CERTIFICATE-----
MIIDdTCCAl2gAwIBAgIBATANBgkqhkiG9w0BAQsFADASMRAwDgYDVQQDDAdkZXZj
ZXJ0MB4XDTIzMDcxMzE4MDcwMFoXDTI1MTAxNTE4MDcwMFowGDEWMBQGA1UEAwwN
c3NsdGVzdC5sb2NhbDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAN2D
3pdJVnV7KRX5yhIHqjtuUV+6CIBEK6Wql0TAmT8F+ozzim91/Mq7Obg1WLO/058Z
f2aI9R51x7P7K9FLaWNpLgHRQmdbDnCsR0dpQRjcXYmy7y7+ZAup4Znt2a681MfT
MOo9s4ZsJmMYOhIHMk0UcQAjD2IGIgH/MkGEyZE0eA8gfuGUoqDD3mYC+vOagsNJ
XqYlTXmUkM03vqVKFJA6hkUezCtPLS71k1onZXrc0JT3Ud5GIBbvjV25N+/RcbTT
mWD2QjGbfBt1jT6YyijIB5izbQYTVCrIMhNT/wZTZfpdqzG6yY+i5+JSt6BabyWs
hqBNHQYKsOXMpHkymCMCAwEAAaOBzzCBzDAMBgNVHRMBAf8EAjAAMB0GA1UdDgQW
BBROlmmvlcKSBaUy6CIo/8A9Zx0RAjBNBgNVHSMERjBEgBTxgr+THWxX0sCc9JFL
uksdr+blNKEWpBQwEjEQMA4GA1UEAwwHZGV2Y2VydIIUHMaZNUybVUTMzHN+fYrM
Pm1AaFEwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMCkGA1Ud
EQQiMCCCDXNzbHRlc3QubG9jYWyCDyouc3NsdGVzdC5sb2NhbDANBgkqhkiG9w0B
AQsFAAOCAQEAguQX03RKRo0HzGkQNS8Xr+SBVP0RY7XPePzeBJZ/XyY3BSYqX2ae
93NzmLDh4J+m3u/0JfrLAe5a3GdIc++DtPjvEwflr5aMwLu0LHqVA3JNxUE6GcdS
5hU40QDE8axeTj00pxtGxoxqdXvELUdz+SvPbUpZx1mV7DLL6wDk6RX8pjV8n/em
5lwbgT5RF0saIMKQaEXu9E8G/3u/xMMGl6lx/Mv7n5mb/n0pxxm4+TnjTZW0I0Rx
FobqC7VPmYic9MpePZUPGSHtK/yrrACLpHhpWp8a9tcl+RysF2ZDx28rwaRT+3SW
uLr5bh1E9iT6XQt8xdwf9YUheQcRHoMPDg==
-----END CERTIFICATE-----

View file

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDdg96XSVZ1eykV
+coSB6o7blFfugiARCulqpdEwJk/BfqM84pvdfzKuzm4NVizv9OfGX9miPUedcez
+yvRS2ljaS4B0UJnWw5wrEdHaUEY3F2Jsu8u/mQLqeGZ7dmuvNTH0zDqPbOGbCZj
GDoSBzJNFHEAIw9iBiIB/zJBhMmRNHgPIH7hlKKgw95mAvrzmoLDSV6mJU15lJDN
N76lShSQOoZFHswrTy0u9ZNaJ2V63NCU91HeRiAW741duTfv0XG005lg9kIxm3wb
dY0+mMooyAeYs20GE1QqyDITU/8GU2X6XasxusmPoufiUregWm8lrIagTR0GCrDl
zKR5MpgjAgMBAAECggEAbh7UVVk8BgNIFYisD/KHiiv5gCE3gKxjFmSL9r3YcSBD
wjaAJ5D8LryMoSrEAffm+DzwvMRxNsdOlAbVbaKTrbvoBzdET6VQtGqwtKt6lSVX
ytCNX0tP6Qx2/tLjWJ6/GOfAdXJfAQBaxQCSPcweEXuMAYlsaEqsNVnsXe3pVqlV
4HuJ8asEa9Co6dqa2OXJbiAtkWyLllVxpvlfVdCwk2EKdK94tKHWdPO/in8aHWP3
jaKU0tiWcZXUHXJaAvYnleYsYa1fXvPbjZMukJU5UbgAsAOpJzI1vI+WU2g1TYAE
wreyKJAvtMby6gK+FEE+9jdinW9dRemlzXU+EnKQwQKBgQD3502YI8F2XmjXl34r
tlefVMFdq9IYoJ0IDzcdwD/69/lVkLRh40PV4rxE73mEX6KLNYGcoBnbphMEUQxb
/h6tJoYqmq4wWcl32SqKozX/bFc9UZY/d5QpAjkT1AsMm7hJTz/7m0h/mjxvzc0J
JMD0f33mTPYuNxuEw5L6PwPAewKBgQDkv+973nEoHpxdUIzBgha149EM4JkxnneM
yx3XLmXLfWaD2xddhRfrmHTc53fgkXO/6DpRGDm1mrl6KEBtxUEomSHSKIzk1Q+B
j3bixRh5x6acg922ATDmVe8deCdnu39HRDWhAIFfkzj121Zg04wAa7wWbyvaHf1x
qiMyKnZ6eQKBgBv76lBwSNh508/yc/WvQbjksmdGjBLnnpJYVVpwZ0iHUYgNK6+Z
HgE49RO7DLaarRiV06nAkOqwlpj4JTMFPqVBIggRKfSfThTPWPQJdID+0+JCIXnM
n9b5P0QzvYOQ2H6+CXT3lHmDCat9SdXsZjOzaJzkty2EXwDfqunAz0WdAoGAB8bO
qXNifW6s+i8m2d1GUGCyVrG8A4fToKG3Hf2h0E0vEwR7wt4ndgb00h28YZIQUVHf
yan9LENaUuDTb+fo0yyBjdd7Erx7jngGHqd8sYcsDt4cx3c65lm9i07uaARjy1Ry
TkrqGwmyQgl24kvO0qTW/BxDbWLfnuGd2LLA3GECgYBIteOrRxuy6PJ/gC3IoPTg
iTiVFzprjrCBpRUTj6Nkbe+pXryzjTGn6VHGD/fPwvGGSEkNARfAGWCvwFzYXWC2
HpnNJrXUIKWfQ6/lU9ExBp6eWWJpVBJ10hEqoMBwXGlur7/cmtRF93J8UfMLcmrx
RLWHaG+M4c7zW/38z30OtA==
-----END PRIVATE KEY-----

View file

@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDGDCCAgCgAwIBAgIUHMaZNUybVUTMzHN+fYrMPm1AaFEwDQYJKoZIhvcNAQEL
BQAwEjEQMA4GA1UEAwwHZGV2Y2VydDAeFw0yMzA3MTMxODA2NTFaFw0yNTEwMTUx
ODA2NTFaMBIxEDAOBgNVBAMMB2RldmNlcnQwggEiMA0GCSqGSIb3DQEBAQUAA4IB
DwAwggEKAoIBAQC5XWJOQa7krq4JdXhoRLYmL0fkO8EEfrde5UYAS8fWgHwZP6Ij
X7muZZWs3jDnhw9Hqyv1X8thyIPSUjczYRwPKpx2VC67IDJQ0Dr+ZfQG4xlmU67l
ZzS2z5O5PJT8d9qRgpW9229Sm7CotQrBSKhhcqtTKENXnXx/pYmakYuoHatG5xin
2/p+WF4rUyJFrAU6TflLBpaM5NjiY9ZsfiE2ifPYbyOOjpnnbO4+5wLMa0sA7WUZ
hl4KRVNK3ygAqRhWEaWLepyBQfbwcCw0LOS8Fo6rc40QmhMsRsTRhY60Jo6N/HUH
R7PSPXtccTnwyepKxMYEsfQqJADgigCQW5N5AgMBAAGjZjBkMB0GA1UdDgQWBBTx
gr+THWxX0sCc9JFLuksdr+blNDAfBgNVHSMEGDAWgBTxgr+THWxX0sCc9JFLuksd
r+blNDASBgNVHRMBAf8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG
9w0BAQsFAAOCAQEAPAZ5G813nIMELPYpXNdaL8kTJX0GKR8bWjQ9GwZ3oxmJtSDE
siaT8yTXekwJDiz8nA6Hs7DCxY2rPQbSpBqhBv/Nww3XoFYVuMUSW14EVPiHMLdg
WUkzD74Junt416jO2MsvngFu+mzPcx1NGU7dVGtAiMWBnf7cA/jN3V7kh02cTn8t
oM10SFtJFpc56esuaICDZ4SfpG/JqT64AsuFdHGUkONEueUDxnH0YXOOSjbVRzCB
qvJgiqDcvs/uhndH4lAsPn40Yh3LoUEJ2ZpN8kcUy5B3ojKvT4RhwcsNmNHDMtzN
p6vOS0tFB8iniSNJIKYs/mOfLMHO1EArFwv+aQ==
-----END CERTIFICATE-----

View file

@ -10,6 +10,7 @@ import { AxiosInstance, Method, AxiosResponse, AxiosRequestConfig } from 'axios'
import { Logger } from '@kbn/core/server';
import { getCustomAgents } from './get_custom_agents';
import { ActionsConfigurationUtilities } from '../actions_config';
import { SSLSettings } from '../types';
export const request = async <T = unknown>({
axios,
@ -19,6 +20,7 @@ export const request = async <T = unknown>({
data,
configurationUtilities,
headers,
sslOverrides,
...config
}: {
axios: AxiosInstance;
@ -28,8 +30,14 @@ export const request = async <T = unknown>({
data?: T;
configurationUtilities: ActionsConfigurationUtilities;
headers?: Record<string, string> | null;
sslOverrides?: SSLSettings;
} & AxiosRequestConfig): Promise<AxiosResponse> => {
const { httpAgent, httpsAgent } = getCustomAgents(configurationUtilities, logger, url);
const { httpAgent, httpsAgent } = getCustomAgents(
configurationUtilities,
logger,
url,
sslOverrides
);
const { maxContentLength, timeout } = configurationUtilities.getResponseSettings();
return await axios(url, {

View file

@ -12,6 +12,7 @@ import { HttpsProxyAgent } from 'https-proxy-agent';
import { Logger } from '@kbn/core/server';
import { ActionsConfigurationUtilities } from '../actions_config';
import { getNodeSSLOptions, getSSLSettingsFromConfig } from './get_node_ssl_options';
import { SSLSettings } from '../types';
interface GetCustomAgentsResponse {
httpAgent: HttpAgent | undefined;
@ -21,10 +22,15 @@ interface GetCustomAgentsResponse {
export function getCustomAgents(
configurationUtilities: ActionsConfigurationUtilities,
logger: Logger,
url: string
url: string,
sslOverrides?: SSLSettings
): GetCustomAgentsResponse {
const generalSSLSettings = configurationUtilities.getSSLSettings();
const agentSSLOptions = getNodeSSLOptions(logger, generalSSLSettings.verificationMode);
const agentSSLOptions = getNodeSSLOptions(
logger,
sslOverrides?.verificationMode ?? generalSSLSettings.verificationMode,
sslOverrides
);
// the default for rejectUnauthorized is the global setting, which can
// be overridden (below) with a custom host setting
const defaultAgents = {

View file

@ -34,16 +34,130 @@ describe('getNodeSSLOptions', () => {
test('get node.js SSL options: rejectUnauthorized eql true for the verification mode value which does not exist, the logger called with the proper warning message', () => {
const nodeOption = getNodeSSLOptions(logger, 'notexist');
expect(loggingSystemMock.collect(logger).warn).toMatchInlineSnapshot(`
Array [
Array [
"Unknown ssl verificationMode: notexist",
],
]
`);
Array [
Array [
"Unknown ssl verificationMode: notexist",
],
]
`);
expect(nodeOption).toMatchObject({
rejectUnauthorized: true,
});
});
test('appends SSL overrides', () => {
const nodeOptionPFX = getNodeSSLOptions(logger, 'none', {
pfx: Buffer.from("Hi i'm a pfx"),
ca: Buffer.from("Hi i'm a ca"),
passphrase: 'aaaaaaa',
});
expect(nodeOptionPFX).toMatchInlineSnapshot(`
Object {
"ca": Object {
"data": Array [
72,
105,
32,
105,
39,
109,
32,
97,
32,
99,
97,
],
"type": "Buffer",
},
"cert": undefined,
"key": undefined,
"passphrase": "aaaaaaa",
"pfx": Object {
"data": Array [
72,
105,
32,
105,
39,
109,
32,
97,
32,
112,
102,
120,
],
"type": "Buffer",
},
"rejectUnauthorized": false,
}
`);
const nodeOptionCert = getNodeSSLOptions(logger, 'none', {
cert: Buffer.from("Hi i'm a cert"),
key: Buffer.from("Hi i'm a key"),
ca: Buffer.from("Hi i'm a ca"),
passphrase: 'aaaaaaa',
});
expect(nodeOptionCert).toMatchInlineSnapshot(`
Object {
"ca": Object {
"data": Array [
72,
105,
32,
105,
39,
109,
32,
97,
32,
99,
97,
],
"type": "Buffer",
},
"cert": Object {
"data": Array [
72,
105,
32,
105,
39,
109,
32,
97,
32,
99,
101,
114,
116,
],
"type": "Buffer",
},
"key": Object {
"data": Array [
72,
105,
32,
105,
39,
109,
32,
97,
32,
107,
101,
121,
],
"type": "Buffer",
},
"passphrase": "aaaaaaa",
"pfx": undefined,
"rejectUnauthorized": false,
}
`);
});
});
describe('getSSLSettingsFromConfig', () => {

View file

@ -11,14 +11,25 @@ import { SSLSettings } from '../types';
export function getNodeSSLOptions(
logger: Logger,
verificationMode?: string
verificationMode?: string,
sslOverrides?: SSLSettings
): {
rejectUnauthorized?: boolean;
checkServerIdentity?: ((host: string, cert: PeerCertificate) => Error | undefined) | undefined;
cert?: Buffer;
key?: Buffer;
pfx?: Buffer;
passphrase?: string;
ca?: Buffer;
} {
const agentOptions: {
rejectUnauthorized?: boolean;
checkServerIdentity?: ((host: string, cert: PeerCertificate) => Error | undefined) | undefined;
cert?: Buffer;
key?: Buffer;
pfx?: Buffer;
passphrase?: string;
ca?: Buffer;
} = {};
if (!!verificationMode) {
switch (verificationMode) {
@ -41,6 +52,15 @@ export function getNodeSSLOptions(
// see: src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts
// This is where the global rejectUnauthorized is overridden by a custom host
}
if (sslOverrides) {
Object.assign(agentOptions, {
cert: sslOverrides.cert,
key: sslOverrides.key,
pfx: sslOverrides.pfx,
passphrase: sslOverrides.passphrase,
ca: sslOverrides.ca,
});
}
return agentOptions;
}

View file

@ -222,6 +222,11 @@ export interface ResponseSettings {
export interface SSLSettings {
verificationMode?: 'none' | 'certificate' | 'full';
pfx?: Buffer;
cert?: Buffer;
key?: Buffer;
passphrase?: string;
ca?: Buffer;
}
export interface ConnectorToken extends SavedObjectAttributes {

View file

@ -0,0 +1,16 @@
/*
* 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 enum WebhookAuthType {
Basic = 'webhook-authentication-basic',
SSL = 'webhook-authentication-ssl',
}
export enum SSLCertType {
CRT = 'ssl-crt-key',
PFX = 'ssl-pfx',
}

View file

@ -42,6 +42,13 @@ export const PASSWORD_LABEL = i18n.translate(
}
);
export const PASSPHRASE_LABEL = i18n.translate(
'xpack.stackConnectors.components.webhook.passphraseTextFieldLabel',
{
defaultMessage: 'Passphrase',
}
);
export const ADD_HEADERS_LABEL = i18n.translate(
'xpack.stackConnectors.components.webhook.viewHeadersSwitch',
{
@ -111,3 +118,85 @@ export const PASSWORD_REQUIRED = i18n.translate(
defaultMessage: 'Password is required.',
}
);
export const AUTHENTICATION_NONE = i18n.translate(
'xpack.stackConnectors.components.webhook.authenticationMethodNoneLabel',
{
defaultMessage: 'None',
}
);
export const AUTHENTICATION_BASIC = i18n.translate(
'xpack.stackConnectors.components.webhook.authenticationMethodBasicLabel',
{
defaultMessage: 'Basic authentication',
}
);
export const AUTHENTICATION_SSL = i18n.translate(
'xpack.stackConnectors.components.webhook.authenticationMethodSSLLabel',
{
defaultMessage: 'SSL authentication',
}
);
export const CERT_TYPE_CRT_KEY = i18n.translate(
'xpack.stackConnectors.components.webhook.certTypeCrtKeyLabel',
{
defaultMessage: 'CRT and KEY file',
}
);
export const CERT_TYPE_PFX = i18n.translate(
'xpack.stackConnectors.components.webhook.certTypePfxLabel',
{
defaultMessage: 'PFX file',
}
);
export const CRT_REQUIRED = i18n.translate(
'xpack.stackConnectors.components.webhook.error.requiredWebhookCRTText',
{
defaultMessage: 'CRT file is required.',
}
);
export const KEY_REQUIRED = i18n.translate(
'xpack.stackConnectors.components.webhook.error.requiredWebhookKEYText',
{
defaultMessage: 'KEY file is required.',
}
);
export const PFX_REQUIRED = i18n.translate(
'xpack.stackConnectors.components.webhook.error.requiredWebhookPFXText',
{
defaultMessage: 'PFX file is required.',
}
);
export const CA_REQUIRED = i18n.translate(
'xpack.stackConnectors.components.webhook.error.requiredWebhookCAText',
{
defaultMessage: 'CA file is required.',
}
);
export const ADD_CA_LABEL = i18n.translate(
'xpack.stackConnectors.components.webhook.viewCertificateAuthoritySwitch',
{
defaultMessage: 'Add certificate authority',
}
);
export const VERIFICATION_MODE_LABEL = i18n.translate(
'xpack.stackConnectors.components.webhook.verificationModeFieldLabel',
{ defaultMessage: 'Verification mode' }
);
export const EDIT_CA_CALLOUT = i18n.translate(
'xpack.stackConnectors.components.webhook.editCACallout',
{
defaultMessage:
'This webhook has an existing certificate authority file. Upload a new one to replace it.',
}
);

View file

@ -11,6 +11,7 @@ import WebhookActionConnectorFields from './webhook_connectors';
import { ConnectorFormTestProvider, waitForComponentToUpdate } from '../lib/test_utils';
import { act, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { WebhookAuthType, SSLCertType } from '../../../common/webhook/constants';
describe('WebhookActionConnectorFields renders', () => {
test('all connector fields is rendered', async () => {
@ -22,6 +23,7 @@ describe('WebhookActionConnectorFields renders', () => {
url: 'https://test.com',
headers: [{ key: 'content-type', value: 'text' }],
hasAuth: true,
authType: WebhookAuthType.Basic,
},
secrets: {
user: 'user',
@ -102,6 +104,7 @@ describe('WebhookActionConnectorFields renders', () => {
url: 'https://test.com',
headers: [{ key: 'content-type', value: 'text' }],
hasAuth: true,
authType: WebhookAuthType.Basic,
},
secrets: {
user: 'user',
@ -109,6 +112,7 @@ describe('WebhookActionConnectorFields renders', () => {
},
__internal__: {
hasHeaders: true,
hasCA: false,
},
isDeprecated: false,
},
@ -148,9 +152,11 @@ describe('WebhookActionConnectorFields renders', () => {
url: 'https://test.com',
headers: [{ key: 'content-type', value: 'text' }],
hasAuth: false,
authType: null,
},
__internal__: {
hasHeaders: true,
hasCA: false,
},
isDeprecated: false,
},
@ -165,6 +171,7 @@ describe('WebhookActionConnectorFields renders', () => {
method: 'PUT',
url: 'https://test.com',
hasAuth: true,
authType: WebhookAuthType.Basic,
},
};
@ -190,6 +197,7 @@ describe('WebhookActionConnectorFields renders', () => {
method: 'PUT',
url: 'https://test.com',
hasAuth: true,
authType: WebhookAuthType.Basic,
},
secrets: {
user: 'user',
@ -197,6 +205,7 @@ describe('WebhookActionConnectorFields renders', () => {
},
__internal__: {
hasHeaders: false,
hasCA: false,
},
isDeprecated: false,
},
@ -261,5 +270,195 @@ describe('WebhookActionConnectorFields renders', () => {
expect(onSubmit).toHaveBeenCalledWith({ data: {}, isValid: false });
});
it('validates correctly with a CA and verificationMode', async () => {
const connector = {
...actionConnector,
config: {
...actionConnector.config,
ca: Buffer.from('some binary string').toString('base64'),
verificationMode: 'full',
},
};
const res = render(
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
<WebhookActionConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
userEvent.click(res.getByTestId('form-test-provide-submit'));
});
expect(onSubmit).toHaveBeenCalledWith({
data: {
actionTypeId: '.webhook',
name: 'webhook',
config: {
method: 'PUT',
url: 'https://test.com',
hasAuth: true,
authType: WebhookAuthType.Basic,
ca: Buffer.from('some binary string').toString('base64'),
verificationMode: 'full',
headers: [{ key: 'content-type', value: 'text' }],
},
secrets: {
user: 'user',
password: 'pass',
},
__internal__: {
hasHeaders: true,
hasCA: true,
},
isDeprecated: false,
},
isValid: true,
});
});
it('validates correctly with a CRT and KEY', async () => {
const connector = {
...actionConnector,
config: {
...actionConnector.config,
authType: WebhookAuthType.SSL,
certType: SSLCertType.CRT,
},
secrets: {
crt: Buffer.from('some binary string').toString('base64'),
key: Buffer.from('some binary string').toString('base64'),
},
};
const res = render(
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
<WebhookActionConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
userEvent.click(res.getByTestId('form-test-provide-submit'));
});
expect(onSubmit).toHaveBeenCalledWith({
data: {
actionTypeId: '.webhook',
name: 'webhook',
config: {
method: 'PUT',
url: 'https://test.com',
hasAuth: true,
authType: WebhookAuthType.SSL,
certType: SSLCertType.CRT,
headers: [{ key: 'content-type', value: 'text' }],
},
secrets: {
crt: Buffer.from('some binary string').toString('base64'),
key: Buffer.from('some binary string').toString('base64'),
},
__internal__: {
hasHeaders: true,
hasCA: false,
},
isDeprecated: false,
},
isValid: true,
});
});
it('validates correctly with a PFX', async () => {
const connector = {
...actionConnector,
config: {
...actionConnector.config,
authType: WebhookAuthType.SSL,
certType: SSLCertType.PFX,
},
secrets: {
pfx: Buffer.from('some binary string').toString('base64'),
},
};
const res = render(
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
<WebhookActionConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
userEvent.click(res.getByTestId('form-test-provide-submit'));
});
expect(onSubmit).toHaveBeenCalledWith({
data: {
actionTypeId: '.webhook',
name: 'webhook',
config: {
method: 'PUT',
url: 'https://test.com',
hasAuth: true,
authType: WebhookAuthType.SSL,
certType: SSLCertType.PFX,
headers: [{ key: 'content-type', value: 'text' }],
},
secrets: {
pfx: Buffer.from('some binary string').toString('base64'),
},
__internal__: {
hasHeaders: true,
hasCA: false,
},
isDeprecated: false,
},
isValid: true,
});
});
it('fails to validate with a CRT but no KEY', async () => {
const connector = {
...actionConnector,
config: {
...actionConnector.config,
authType: WebhookAuthType.SSL,
certType: SSLCertType.CRT,
},
secrets: {
crt: Buffer.from('some binary string').toString('base64'),
},
};
const res = render(
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
<WebhookActionConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
userEvent.click(res.getByTestId('form-test-provide-submit'));
});
expect(onSubmit).toHaveBeenCalledWith({
data: {},
isValid: false,
});
});
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import React, { useEffect } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import {
@ -15,6 +15,9 @@ import {
EuiButtonIcon,
EuiTitle,
EuiButtonEmpty,
EuiCallOut,
EuiTabs,
EuiTab,
} from '@elastic/eui';
import {
UseArray,
@ -28,29 +31,211 @@ import {
TextField,
ToggleField,
PasswordField,
FilePickerField,
CardRadioGroupField,
HiddenField,
} from '@kbn/es-ui-shared-plugin/static/forms/components';
import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers';
import type { ActionConnectorFieldsProps } from '@kbn/triggers-actions-ui-plugin/public';
import { WebhookAuthType, SSLCertType } from '../../../common/webhook/constants';
import * as i18n from './translations';
const HTTP_VERBS = ['post', 'put'];
const { emptyField, urlField } = fieldValidators;
const VERIFICATION_MODE_DEFAULT = 'full';
const WebhookActionConnectorFields: React.FunctionComponent<ActionConnectorFieldsProps> = ({
readOnly,
}) => {
const { getFieldDefaultValue } = useFormContext();
const { setFieldValue, getFieldDefaultValue } = useFormContext();
const [{ config, __internal__ }] = useFormData({
watch: ['config.hasAuth', '__internal__.hasHeaders'],
watch: [
'config.hasAuth',
'config.authType',
'config.certType',
'config.verificationMode',
'__internal__.hasHeaders',
'__internal__.hasCA',
],
});
const hasHeadersDefaultValue = !!getFieldDefaultValue<boolean | undefined>('config.headers');
const authTypeDefaultValue =
getFieldDefaultValue('config.hasAuth') === false
? null
: getFieldDefaultValue('config.authType') ?? WebhookAuthType.Basic;
const certTypeDefaultValue = getFieldDefaultValue('config.certType') ?? SSLCertType.CRT;
const hasCADefaultValue =
!!getFieldDefaultValue<boolean | undefined>('config.ca') ||
getFieldDefaultValue('config.verificationMode') === 'none';
const hasAuth = config == null ? true : config.hasAuth;
const hasHeaders = __internal__ != null ? __internal__.hasHeaders : false;
const hasCA = __internal__ != null ? __internal__.hasCA : false;
const authType = config == null ? WebhookAuthType.Basic : config.authType;
const certType = config == null ? SSLCertType.CRT : config.certType;
const hasInitialCA = !!getFieldDefaultValue<boolean | undefined>('config.ca');
useEffect(() => setFieldValue('config.hasAuth', Boolean(authType)), [authType, setFieldValue]);
const basicAuthFields = (
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<UseField
path="secrets.user"
config={{
label: i18n.USERNAME_LABEL,
validations: [
{
validator: emptyField(i18n.USERNAME_REQUIRED),
},
],
}}
component={Field}
componentProps={{
euiFieldProps: {
readOnly,
'data-test-subj': 'webhookUserInput',
fullWidth: true,
},
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path="secrets.password"
config={{
label: i18n.PASSWORD_LABEL,
validations: [
{
validator: emptyField(i18n.PASSWORD_REQUIRED),
},
],
}}
component={PasswordField}
componentProps={{
euiFieldProps: {
'data-test-subj': 'webhookPasswordInput',
readOnly,
},
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
const sslCertAuthFields = (
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<UseField
path="secrets.password"
config={{
label: i18n.PASSPHRASE_LABEL,
}}
component={PasswordField}
componentProps={{
euiFieldProps: {
'data-test-subj': 'webhookSSLPassphraseInput',
readOnly,
},
}}
/>
<EuiSpacer size="s" />
<UseField
path="config.certType"
defaultValue={certTypeDefaultValue}
component={({ field }) => (
<EuiTabs size="s">
<EuiTab
onClick={() => field.setValue(SSLCertType.CRT)}
isSelected={field.value === SSLCertType.CRT}
>
{i18n.CERT_TYPE_CRT_KEY}
</EuiTab>
<EuiTab
onClick={() => field.setValue(SSLCertType.PFX)}
isSelected={field.value === SSLCertType.PFX}
>
{i18n.CERT_TYPE_PFX}
</EuiTab>
</EuiTabs>
)}
/>
<EuiSpacer size="s" />
{certType === SSLCertType.CRT && (
<EuiFlexGroup>
<EuiFlexItem>
<UseField
path="secrets.crt"
config={{
label: 'CRT file',
validations: [
{
validator: emptyField(i18n.CRT_REQUIRED),
},
],
}}
component={FilePickerField}
componentProps={{
euiFieldProps: {
'data-test-subj': 'webhookSSLCRTInput',
display: 'default',
accept: '.crt,.cert,.cer,.pem',
},
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path="secrets.key"
config={{
label: 'KEY file',
validations: [
{
validator: emptyField(i18n.KEY_REQUIRED),
},
],
}}
component={FilePickerField}
componentProps={{
euiFieldProps: {
'data-test-subj': 'webhookSSLKEYInput',
display: 'default',
accept: '.key,.pem',
},
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
)}
{certType === SSLCertType.PFX && (
<UseField
path="secrets.pfx"
config={{
label: 'PFX file',
validations: [
{
validator: emptyField(i18n.PFX_REQUIRED),
},
],
}}
component={FilePickerField}
componentProps={{
euiFieldProps: {
'data-test-subj': 'webhookSSLPFXInput',
display: 'default',
accept: '.pfx,.p12',
},
}}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
);
return (
<>
<UseField path="config.hasAuth" component={HiddenField} />
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<UseField
@ -106,59 +291,31 @@ const WebhookActionConnectorFields: React.FunctionComponent<ActionConnectorField
</EuiTitle>
<EuiSpacer size="s" />
<UseField
path="config.hasAuth"
component={ToggleField}
config={{ defaultValue: true }}
path="config.authType"
defaultValue={authTypeDefaultValue}
component={CardRadioGroupField}
componentProps={{
euiFieldProps: {
label: i18n.HAS_AUTH_LABEL,
disabled: readOnly,
},
options: [
{
value: null,
label: i18n.AUTHENTICATION_NONE,
},
{
value: WebhookAuthType.Basic,
label: i18n.AUTHENTICATION_BASIC,
children: authType === WebhookAuthType.Basic && basicAuthFields,
},
{
value: WebhookAuthType.SSL,
label: i18n.AUTHENTICATION_SSL,
children: authType === WebhookAuthType.SSL && sslCertAuthFields,
},
],
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
{hasAuth ? (
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<UseField
path="secrets.user"
config={{
label: i18n.USERNAME_LABEL,
validations: [
{
validator: emptyField(i18n.USERNAME_REQUIRED),
},
],
}}
component={Field}
componentProps={{
euiFieldProps: { readOnly, 'data-test-subj': 'webhookUserInput', fullWidth: true },
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path="secrets.password"
config={{
label: i18n.PASSWORD_LABEL,
validations: [
{
validator: emptyField(i18n.PASSWORD_REQUIRED),
},
],
}}
component={PasswordField}
componentProps={{
euiFieldProps: {
'data-test-subj': 'webhookPasswordInput',
readOnly,
},
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
) : null}
<EuiSpacer size="m" />
<UseField
path="__internal__.hasHeaders"
@ -171,61 +328,139 @@ const WebhookActionConnectorFields: React.FunctionComponent<ActionConnectorField
},
}}
/>
<EuiSpacer size="m" />
{hasHeaders ? (
<UseArray path="config.headers" initialNumberOfItems={1}>
{({ items, addItem, removeItem }) => {
return (
<>
{items.map((item) => (
<EuiFlexGroup key={item.id}>
<EuiFlexItem>
<UseField
path={`${item.path}.key`}
config={{
label: i18n.HEADER_KEY_LABEL,
}}
component={TextField}
// This is needed because when you delete
// a row and add a new one, the stale values will appear
readDefaultValueOnForm={!item.isNew}
componentProps={{
euiFieldProps: { readOnly },
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path={`${item.path}.value`}
config={{ label: i18n.HEADER_VALUE_LABEL }}
component={TextField}
readDefaultValueOnForm={!item.isNew}
componentProps={{
euiFieldProps: { readOnly },
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
color="danger"
onClick={() => removeItem(item.id)}
iconType="minusInCircle"
aria-label={i18n.REMOVE_ITEM_LABEL}
style={{ marginTop: '28px' }}
/>
</EuiFlexItem>
</EuiFlexGroup>
))}
<EuiSpacer size="m" />
<EuiButtonEmpty iconType="plusInCircle" onClick={addItem}>
{i18n.ADD_HEADER_BTN}
</EuiButtonEmpty>
<EuiSpacer />
</>
);
}}
</UseArray>
<>
<EuiSpacer size="m" />
<UseArray path="config.headers" initialNumberOfItems={1}>
{({ items, addItem, removeItem }) => {
return (
<>
{items.map((item) => (
<EuiFlexGroup key={item.id}>
<EuiFlexItem>
<UseField
path={`${item.path}.key`}
config={{
label: i18n.HEADER_KEY_LABEL,
}}
component={TextField}
// This is needed because when you delete
// a row and add a new one, the stale values will appear
readDefaultValueOnForm={!item.isNew}
componentProps={{
euiFieldProps: { readOnly },
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path={`${item.path}.value`}
config={{ label: i18n.HEADER_VALUE_LABEL }}
component={TextField}
readDefaultValueOnForm={!item.isNew}
componentProps={{
euiFieldProps: { readOnly },
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
color="danger"
onClick={() => removeItem(item.id)}
iconType="minusInCircle"
aria-label={i18n.REMOVE_ITEM_LABEL}
style={{ marginTop: '28px' }}
/>
</EuiFlexItem>
</EuiFlexGroup>
))}
<EuiSpacer size="m" />
<EuiButtonEmpty iconType="plusInCircle" onClick={addItem}>
{i18n.ADD_HEADER_BTN}
</EuiButtonEmpty>
<EuiSpacer />
</>
);
}}
</UseArray>
</>
) : null}
<EuiSpacer size="m" />
<UseField
path="__internal__.hasCA"
component={ToggleField}
config={{ defaultValue: hasCADefaultValue, label: i18n.ADD_CA_LABEL }}
componentProps={{
euiFieldProps: {
disabled: readOnly,
'data-test-subj': 'webhookViewCASwitch',
},
}}
/>
<EuiSpacer size="m" />
{hasCA && (
<>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<UseField
path="config.ca"
config={{
label: 'CA file',
validations: [
{
validator:
config?.verificationMode !== 'none'
? emptyField(i18n.CA_REQUIRED)
: () => {},
},
],
}}
component={FilePickerField}
componentProps={{
euiFieldProps: {
display: 'default',
'data-test-subj': 'webhookCAInput',
accept: '.ca,.pem',
},
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<UseField
path="config.verificationMode"
component={SelectField}
config={{
label: i18n.VERIFICATION_MODE_LABEL,
defaultValue: VERIFICATION_MODE_DEFAULT,
validations: [
{
validator: emptyField(i18n.VERIFICATION_MODE_LABEL),
},
],
}}
componentProps={{
euiFieldProps: {
'data-test-subj': 'webhookVerificationModeSelect',
options: [
{ text: 'None', value: 'none' },
{ text: 'Certificate', value: 'certificate' },
{ text: 'Full', value: 'full' },
],
fullWidth: true,
readOnly,
},
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
{hasInitialCA && (
<>
<EuiSpacer size="s" />
<EuiCallOut size="s" iconType="document" title={i18n.EDIT_CA_CALLOUT} />
</>
)}
</>
)}
</>
);
};

View file

@ -22,6 +22,8 @@ import {
import * as utils from '@kbn/actions-plugin/server/lib/axios_utils';
import { loggerMock } from '@kbn/logging-mocks';
import { SSLCertType, WebhookAuthType } from '../../../common/webhook/constants';
import { PFX_FILE, CRT_FILE, KEY_FILE } from './mocks';
jest.mock('axios');
jest.mock('@kbn/actions-plugin/server/lib/axios_utils', () => {
@ -58,9 +60,12 @@ describe('connectorType', () => {
describe('secrets validation', () => {
test('succeeds when secrets is valid', () => {
const secrets: Record<string, string> = {
const secrets: Record<string, string | null> = {
user: 'bob',
password: 'supersecret',
crt: null,
key: null,
pfx: null,
};
expect(validateSecrets(connectorType, secrets, { configurationUtilities })).toEqual(secrets);
});
@ -69,16 +74,78 @@ describe('secrets validation', () => {
expect(() => {
validateSecrets(connectorType, { user: 'bob' }, { configurationUtilities });
}).toThrowErrorMatchingInlineSnapshot(
`"error validating action type secrets: both user and password must be specified"`
`"error validating action type secrets: must specify one of the following schemas: user and password; crt and key (with optional password); or pfx (with optional password)"`
);
});
test('succeeds when basic authentication credentials are omitted', () => {
test('succeeds when authentication credentials are omitted', () => {
expect(validateSecrets(connectorType, {}, { configurationUtilities })).toEqual({
crt: null,
key: null,
password: null,
pfx: null,
user: null,
});
});
test('succeeds when secrets contains a certificate and keyfile', () => {
const secrets: Record<string, string | null> = {
password: 'supersecret',
crt: CRT_FILE,
key: KEY_FILE,
pfx: null,
user: null,
};
expect(validateSecrets(connectorType, secrets, { configurationUtilities })).toEqual(secrets);
const secretsWithoutPassword: Record<string, string | null> = {
crt: CRT_FILE,
key: KEY_FILE,
pfx: null,
user: null,
password: null,
};
expect(
validateSecrets(connectorType, secretsWithoutPassword, { configurationUtilities })
).toEqual(secretsWithoutPassword);
});
test('succeeds when secrets contains a pfx', () => {
const secrets: Record<string, string | null> = {
password: 'supersecret',
pfx: PFX_FILE,
user: null,
crt: null,
key: null,
};
expect(validateSecrets(connectorType, secrets, { configurationUtilities })).toEqual(secrets);
const secretsWithoutPassword: Record<string, string | null> = {
pfx: PFX_FILE,
user: null,
password: null,
crt: null,
key: null,
};
expect(
validateSecrets(connectorType, secretsWithoutPassword, { configurationUtilities })
).toEqual(secretsWithoutPassword);
});
test('fails when secret crt is provided but key omitted, or vice versa', () => {
expect(() => {
validateSecrets(connectorType, { crt: CRT_FILE }, { configurationUtilities });
}).toThrowErrorMatchingInlineSnapshot(
`"error validating action type secrets: must specify one of the following schemas: user and password; crt and key (with optional password); or pfx (with optional password)"`
);
expect(() => {
validateSecrets(connectorType, { key: KEY_FILE }, { configurationUtilities });
}).toThrowErrorMatchingInlineSnapshot(
`"error validating action type secrets: must specify one of the following schemas: user and password; crt and key (with optional password); or pfx (with optional password)"`
);
});
});
describe('config validation', () => {
@ -90,6 +157,7 @@ describe('config validation', () => {
test('config validation passes when only required fields are provided', () => {
const config: Record<string, string | boolean> = {
url: 'http://mylisteningserver:9200/endpoint',
authType: WebhookAuthType.Basic,
hasAuth: true,
};
expect(validateConfig(connectorType, config, { configurationUtilities })).toEqual({
@ -103,6 +171,7 @@ describe('config validation', () => {
const config: Record<string, string | boolean> = {
url: 'http://mylisteningserver:9200/endpoint',
method,
authType: WebhookAuthType.Basic,
hasAuth: true,
};
expect(validateConfig(connectorType, config, { configurationUtilities })).toEqual({
@ -120,15 +189,16 @@ describe('config validation', () => {
expect(() => {
validateConfig(connectorType, config, { configurationUtilities });
}).toThrowErrorMatchingInlineSnapshot(`
"error validating action type config: [method]: types that failed validation:
- [method.0]: expected value to equal [post]
- [method.1]: expected value to equal [put]"
`);
"error validating action type config: [method]: types that failed validation:
- [method.0]: expected value to equal [post]
- [method.1]: expected value to equal [put]"
`);
});
test('config validation passes when a url is specified', () => {
const config: Record<string, string | boolean> = {
url: 'http://mylisteningserver:9200/endpoint',
authType: WebhookAuthType.Basic,
hasAuth: true,
};
expect(validateConfig(connectorType, config, { configurationUtilities })).toEqual({
@ -156,6 +226,7 @@ describe('config validation', () => {
headers: {
'Content-Type': 'application/json',
},
authType: WebhookAuthType.Basic,
hasAuth: true,
};
expect(validateConfig(connectorType, config, { configurationUtilities })).toEqual({
@ -172,10 +243,10 @@ describe('config validation', () => {
expect(() => {
validateConfig(connectorType, config, { configurationUtilities });
}).toThrowErrorMatchingInlineSnapshot(`
"error validating action type config: [headers]: types that failed validation:
- [headers.0]: could not parse record value from json input
- [headers.1]: expected value to equal [null]"
`);
"error validating action type config: [headers]: types that failed validation:
- [headers.0]: could not parse record value from json input
- [headers.1]: expected value to equal [null]"
`);
});
test('config validation passes when kibana config url does not present in allowedHosts', () => {
@ -186,6 +257,7 @@ describe('config validation', () => {
headers: {
'Content-Type': 'application/json',
},
authType: WebhookAuthType.Basic,
hasAuth: true,
};
@ -260,13 +332,14 @@ describe('execute()', () => {
headers: {
aheader: 'a value',
},
authType: WebhookAuthType.Basic,
hasAuth: true,
};
await connectorType.executor({
actionId: 'some-id',
services,
config,
secrets: { user: 'abc', password: '123' },
secrets: { user: 'abc', password: '123', key: null, crt: null, pfx: null },
params: { body: 'some data' },
configurationUtilities,
logger: mockedLogger,
@ -309,6 +382,190 @@ describe('execute()', () => {
"warn": [MockFunction],
},
"method": "post",
"sslOverrides": Object {},
"url": "https://abc.def/my-webhook",
}
`);
});
test('execute with ssl adds ssl settings to sslOverrides', async () => {
const config: ConnectorTypeConfigType = {
url: 'https://abc.def/my-webhook',
method: WebhookMethods.POST,
headers: {
aheader: 'a value',
},
authType: WebhookAuthType.SSL,
certType: SSLCertType.CRT,
hasAuth: true,
};
await connectorType.executor({
actionId: 'some-id',
services,
config,
secrets: { crt: CRT_FILE, key: KEY_FILE, password: 'passss', user: null, pfx: null },
params: { body: 'some data' },
configurationUtilities,
logger: mockedLogger,
});
delete requestMock.mock.calls[0][0].configurationUtilities;
expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"axios": undefined,
"data": "some data",
"headers": Object {
"aheader": "a value",
},
"logger": Object {
"context": Array [],
"debug": [MockFunction] {
"calls": Array [
Array [
"response from webhook action \\"some-id\\": [HTTP 200] ",
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
},
"error": [MockFunction],
"fatal": [MockFunction],
"get": [MockFunction],
"info": [MockFunction],
"isLevelEnabled": [MockFunction],
"log": [MockFunction],
"trace": [MockFunction],
"warn": [MockFunction],
},
"method": "post",
"sslOverrides": Object {
"cert": Object {
"data": Array [
10,
45,
45,
45,
45,
45,
66,
69,
71,
73,
78,
32,
67,
69,
82,
84,
73,
70,
73,
67,
65,
84,
69,
45,
45,
45,
45,
45,
10,
45,
45,
45,
45,
45,
69,
78,
68,
32,
67,
69,
82,
84,
73,
70,
73,
67,
65,
84,
69,
45,
45,
45,
45,
45,
10,
],
"type": "Buffer",
},
"key": Object {
"data": Array [
10,
45,
45,
45,
45,
45,
66,
69,
71,
73,
78,
32,
80,
82,
73,
86,
65,
84,
69,
32,
75,
69,
89,
45,
45,
45,
45,
45,
10,
45,
45,
45,
45,
45,
69,
78,
68,
32,
80,
82,
73,
86,
65,
84,
69,
32,
75,
69,
89,
45,
45,
45,
45,
45,
10,
],
"type": "Buffer",
},
"passphrase": "passss",
},
"url": "https://abc.def/my-webhook",
}
`);
@ -321,6 +578,7 @@ describe('execute()', () => {
headers: {
aheader: 'a value',
},
authType: WebhookAuthType.Basic,
hasAuth: true,
};
requestMock.mockReset();
@ -333,7 +591,7 @@ describe('execute()', () => {
actionId: 'some-id',
services,
config,
secrets: { user: 'abc', password: '123' },
secrets: { user: 'abc', password: '123', key: null, crt: null, pfx: null },
params: { body: 'some data' },
configurationUtilities,
logger: mockedLogger,
@ -352,7 +610,13 @@ describe('execute()', () => {
},
hasAuth: false,
};
const secrets: ConnectorTypeSecretsType = { user: null, password: null };
const secrets: ConnectorTypeSecretsType = {
user: null,
password: null,
pfx: null,
crt: null,
key: null,
};
await connectorType.executor({
actionId: 'some-id',
services,
@ -396,6 +660,7 @@ describe('execute()', () => {
"warn": [MockFunction],
},
"method": "post",
"sslOverrides": Object {},
"url": "https://abc.def/my-webhook",
}
`);

View file

@ -24,6 +24,7 @@ import {
SecurityConnectorFeatureId,
} from '@kbn/actions-plugin/common/types';
import { renderMustacheString } from '@kbn/actions-plugin/server/lib/mustache_renderer';
import { SSLCertType, WebhookAuthType } from '../../../common/webhook/constants';
import { getRetryAfterIntervalFromHeaders } from '../lib/http_response_retry_header';
import { nullableType } from '../lib/nullable';
import { isOk, promiseResult, Result } from '../lib/result_type';
@ -54,6 +55,25 @@ const configSchemaProps = {
}),
headers: nullableType(HeadersSchema),
hasAuth: schema.boolean({ defaultValue: true }),
authType: schema.maybe(
schema.oneOf(
[
schema.literal(WebhookAuthType.Basic),
schema.literal(WebhookAuthType.SSL),
schema.literal(null),
],
{
defaultValue: WebhookAuthType.Basic,
}
)
),
certType: schema.maybe(
schema.oneOf([schema.literal(SSLCertType.CRT), schema.literal(SSLCertType.PFX)])
),
ca: schema.maybe(schema.string()),
verificationMode: schema.maybe(
schema.oneOf([schema.literal('none'), schema.literal('certificate'), schema.literal('full')])
),
};
const ConfigSchema = schema.object(configSchemaProps);
export type ConnectorTypeConfigType = TypeOf<typeof ConfigSchema>;
@ -63,14 +83,20 @@ export type ConnectorTypeSecretsType = TypeOf<typeof SecretsSchema>;
const secretSchemaProps = {
user: schema.nullable(schema.string()),
password: schema.nullable(schema.string()),
crt: schema.nullable(schema.string()),
key: schema.nullable(schema.string()),
pfx: schema.nullable(schema.string()),
};
const SecretsSchema = schema.object(secretSchemaProps, {
validate: (secrets) => {
// user and password must be set together (or not at all)
if (!secrets.password && !secrets.user) return;
if (secrets.password && secrets.user) return;
if (!secrets.password && !secrets.user && !secrets.crt && !secrets.key && !secrets.pfx) return;
if (secrets.password && secrets.user && !secrets.crt && !secrets.key && !secrets.pfx) return;
if (secrets.crt && secrets.key && !secrets.user && !secrets.pfx) return;
if (!secrets.crt && !secrets.key && !secrets.user && secrets.pfx) return;
return i18n.translate('xpack.stackConnectors.webhook.invalidUsernamePassword', {
defaultMessage: 'both user and password must be specified',
defaultMessage:
'must specify one of the following schemas: user and password; crt and key (with optional password); or pfx (with optional password)',
});
},
});
@ -153,6 +179,15 @@ function validateConnectorTypeConfig(
})
);
}
if (Boolean(configObject.authType) && !configObject.hasAuth) {
throw new Error(
i18n.translate('xpack.stackConnectors.webhook.authConfigurationError', {
defaultMessage:
'error configuring webhook action: authType must be null or undefined if hasAuth is false',
})
);
}
}
// action executor
@ -160,17 +195,42 @@ export async function executor(
execOptions: WebhookConnectorTypeExecutorOptions
): Promise<ConnectorTypeExecutorResult<unknown>> {
const { actionId, config, params, configurationUtilities, logger } = execOptions;
const { method, url, headers = {}, hasAuth } = config;
const { method, url, headers = {}, hasAuth, authType, ca, verificationMode } = config;
const { body: data } = params;
const secrets: ConnectorTypeSecretsType = execOptions.secrets;
// For backwards compatibility with connectors created before authType was added, interpret a
// hasAuth: true and undefined authType as basic auth
const basicAuth =
hasAuth && isString(secrets.user) && isString(secrets.password)
hasAuth &&
(authType === WebhookAuthType.Basic || !authType) &&
isString(secrets.user) &&
isString(secrets.password)
? { auth: { username: secrets.user, password: secrets.password } }
: {};
const sslCertificate =
authType === WebhookAuthType.SSL &&
((isString(secrets.crt) && isString(secrets.key)) || isString(secrets.pfx))
? isString(secrets.pfx)
? {
pfx: Buffer.from(secrets.pfx, 'base64'),
...(isString(secrets.password) ? { passphrase: secrets.password } : {}),
}
: {
cert: Buffer.from(secrets.crt!, 'base64'),
key: Buffer.from(secrets.key!, 'base64'),
...(isString(secrets.password) ? { passphrase: secrets.password } : {}),
}
: {};
const axiosInstance = axios.create();
const sslOverrides = {
...sslCertificate,
...(verificationMode ? { verificationMode } : {}),
...(ca ? { ca: Buffer.from(ca, 'base64') } : {}),
};
const result: Result<AxiosResponse, AxiosError<{ message: string }>> = await promiseResult(
request({
axios: axiosInstance,
@ -181,6 +241,7 @@ export async function executor(
headers: headers ? headers : {},
data,
configurationUtilities,
sslOverrides,
})
);

View file

@ -0,0 +1,21 @@
/*
* 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 PFX_FILE = Buffer.from('a bunch of binary gibberish').toString('base64');
export const CRT_FILE = Buffer.from(
`
-----BEGIN CERTIFICATE-----
-----END CERTIFICATE-----
`
).toString('base64');
export const KEY_FILE = Buffer.from(
`
-----BEGIN PRIVATE KEY-----
-----END PRIVATE KEY-----
`
).toString('base64');

View file

@ -19,6 +19,7 @@ import {
import { getCreateExceptionListDetectionSchemaMock } from '@kbn/lists-plugin/common/schemas/request/create_exception_list_schema.mock';
import { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants';
import { getCreateExceptionListItemMinimalSchemaMock } from '@kbn/lists-plugin/common/schemas/request/create_exception_list_item_schema.mock';
import { WebhookAuthType } from '@kbn/stack-connectors-plugin/common/webhook/constants';
import { deleteAllExceptions } from '../../../lists_api_integration/utils';
import {
binaryToString,
@ -142,6 +143,7 @@ export default ({ getService }: FtrProviderContext): void => {
attributes: {
actionTypeId: '.webhook',
config: {
authType: WebhookAuthType.Basic,
hasAuth: true,
method: 'post',
url: 'http://localhost',

View file

@ -136,6 +136,7 @@
"@kbn/bfetch-plugin",
"@kbn/uptime-plugin",
"@kbn/ml-category-validator",
"@kbn/observability-ai-assistant-plugin"
"@kbn/observability-ai-assistant-plugin",
"@kbn/stack-connectors-plugin"
]
}