mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
b9dae73789
commit
7e2ba48c18
21 changed files with 1503 additions and 137 deletions
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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-----
|
|
@ -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-----
|
|
@ -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-----
|
|
@ -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, {
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
16
x-pack/plugins/stack_connectors/common/webhook/constants.ts
Normal file
16
x-pack/plugins/stack_connectors/common/webhook/constants.ts
Normal 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',
|
||||
}
|
|
@ -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.',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
`);
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
@ -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');
|
|
@ -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',
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue