From 6ad418b275da3f57b26c0b3e62781a5bc7ad0f9a Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Tue, 26 Apr 2022 10:05:16 -0400 Subject: [PATCH] [ResponseOps][actions] add config for allow-listing email address domains (#129001) resolves https://github.com/elastic/kibana/issues/126944 Adds a new configuration setting for the actions plugin, xpack.actions.email.domain_allowlist, which is an array of domain name strings which are allowed to be sent emails by the email connector. --- .../connectors/action-types/email.asciidoc | 2 + docs/settings/alert-action-settings.asciidoc | 5 + package.json | 1 + packages/kbn-optimizer/limits.yml | 1 + .../resources/base/bin/kibana-docker | 1 + .../test_suites/core_plugins/rendering.ts | 1 + x-pack/plugins/actions/common/index.ts | 3 + .../actions/common/mustache_template.test.ts | 33 ++ .../actions/common/mustache_template.ts | 18 ++ .../actions/common/servicenow_config.ts | 56 ++++ x-pack/plugins/actions/common/types.ts | 15 + .../common/validate_email_addresses.test.ts | 284 ++++++++++++++++++ .../common/validate_email_addresses.ts | 114 +++++++ x-pack/plugins/actions/kibana.json | 3 +- x-pack/plugins/actions/public/index.ts | 15 + x-pack/plugins/actions/public/plugin.test.ts | 65 ++++ x-pack/plugins/actions/public/plugin.ts | 44 +++ .../actions/server/actions_config.mock.ts | 1 + .../actions/server/actions_config.test.ts | 44 +++ .../plugins/actions/server/actions_config.ts | 26 +- .../server/builtin_action_types/email.test.ts | 134 ++++++--- .../server/builtin_action_types/email.ts | 74 +++-- .../lib/send_email.test.ts | 264 +--------------- .../builtin_action_types/servicenow/types.ts | 14 +- .../server/builtin_action_types/teams.test.ts | 28 +- .../builtin_action_types/webhook.test.ts | 28 +- x-pack/plugins/actions/server/config.test.ts | 19 ++ x-pack/plugins/actions/server/config.ts | 5 + x-pack/plugins/actions/server/index.ts | 3 + x-pack/plugins/actions/server/plugin.ts | 1 + x-pack/plugins/actions/server/routes/index.ts | 2 + x-pack/plugins/actions/tsconfig.json | 3 +- .../plugins/triggers_actions_ui/kibana.json | 6 +- .../builtin_action_types/email/email.test.tsx | 54 +++- .../builtin_action_types/email/email.tsx | 82 ++++- .../email/translations.ts | 17 ++ .../es_index/es_index.test.tsx | 3 +- .../components/builtin_action_types/index.ts | 12 +- .../builtin_action_types/jira/jira.test.tsx | 3 +- .../pagerduty/pagerduty.test.tsx | 3 +- .../resilient/resilient.test.tsx | 3 +- .../server_log/server_log.test.tsx | 3 +- .../builtin_action_types/servicenow/api.ts | 4 +- .../servicenow/servicenow.test.tsx | 3 +- .../servicenow/servicenow_connectors.tsx | 3 +- .../servicenow/update_connector.tsx | 3 +- .../builtin_action_types/slack/slack.test.tsx | 3 +- .../swimlane/swimlane.test.tsx | 3 +- .../builtin_action_types/teams/teams.test.tsx | 3 +- .../webhook/webhook.test.tsx | 3 +- .../xmatters/xmatters.test.tsx | 3 +- .../triggers_actions_ui/public/mocks.ts | 9 +- .../triggers_actions_ui/public/plugin.ts | 5 + .../alerting_api_integration/common/config.ts | 7 + .../spaces_only/config.ts | 3 + .../actions/builtin_action_types/email.ts | 175 +++++++++++ .../spaces_only/tests/actions/index.ts | 1 + yarn.lock | 5 + 58 files changed, 1299 insertions(+), 427 deletions(-) create mode 100644 x-pack/plugins/actions/common/mustache_template.test.ts create mode 100644 x-pack/plugins/actions/common/mustache_template.ts create mode 100644 x-pack/plugins/actions/common/servicenow_config.ts create mode 100644 x-pack/plugins/actions/common/validate_email_addresses.test.ts create mode 100644 x-pack/plugins/actions/common/validate_email_addresses.ts create mode 100644 x-pack/plugins/actions/public/index.ts create mode 100644 x-pack/plugins/actions/public/plugin.test.ts create mode 100644 x-pack/plugins/actions/public/plugin.ts create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/email.ts diff --git a/docs/management/connectors/action-types/email.asciidoc b/docs/management/connectors/action-types/email.asciidoc index c080c412f0f6b..af408fd36a23c 100644 --- a/docs/management/connectors/action-types/email.asciidoc +++ b/docs/management/connectors/action-types/email.asciidoc @@ -9,6 +9,8 @@ The email connector uses the SMTP protocol to send mail messages, using an integ NOTE: For emails to have a footer with a link back to {kib}, set the <> configuration setting. +NOTE: When the <> configuration setting is used, the email addresses used for all of the Sender (from), To, CC, and BCC properties must have email domains specified in the configuration setting. + [float] [[email-connector-configuration]] ==== Connector configuration diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index 384c4b696521d..7579ec207c835 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -125,6 +125,11 @@ The contents of a PEM-encoded certificate file, or multiple files appended into a single string. This configuration can be used for environments where the files cannot be made available. +[[action-config-email-domain-allowlist]] `xpack.actions.email.domain_allowlist` {ess-icon}:: +A list of allowed email domains which can be used with the email connector. When this setting is not used, all email domains are allowed. When this setting is used, if any email is attempted to be sent that includes an addressee with an email domain that is not in the allowlist, or the from address domain is not in the allowlist, the run of the connector will fail with a message indicating the emails not allowed. + +WARNING: This feature is available in {kib} 7.17.4 and 8.3.0 onwards but is not supported in {kib} 8.0, 8.1 or 8.2. As such this settings should be removed before upgrading from 7.17 to 8.0, 8.1 or 8.2. It is possible to configure the settings in 7.17.4 and then upgrade to 8.3.0 directly. + `xpack.actions.enabledActionTypes` {ess-icon}:: A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.email`, `.index`, `.jira`, `.pagerduty`, `.resilient`, `.server-log`, `.servicenow`, .`servicenow-itom`, `.servicenow-sir`, `.slack`, `.swimlane`, `.teams`, `.xmatters`, and `.webhook`. An empty list `[]` will disable all action types. + diff --git a/package.json b/package.json index 8f30166439b8b..4c7db193368a6 100644 --- a/package.json +++ b/package.json @@ -250,6 +250,7 @@ "deepmerge": "^4.2.2", "del": "^5.1.0", "elastic-apm-node": "^3.31.0", + "email-addresses": "^5.0.0", "execa": "^4.0.2", "exit-hook": "^2.2.0", "expiry-js": "0.1.7", diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 98089f1f1c5fb..dc16c080306ad 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -1,5 +1,6 @@ pageLoadAssetSize: advancedSettings: 27596 + actions: 20000 alerting: 106936 apm: 64385 canvas: 1066647 diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index 6ec12aab3c002..7b569f8d02068 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -188,6 +188,7 @@ kibana_vars=( vis_type_vega.enableExternalUrls xpack.actions.allowedHosts xpack.actions.customHostSettings + xpack.actions.email.domain_allowlist xpack.actions.enabledActionTypes xpack.actions.maxResponseContentLength xpack.actions.preconfigured diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index 5f124adc92960..b7adbfa17301c 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -136,6 +136,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'usageCollection.uiCounters.debug (boolean)', 'usageCollection.uiCounters.enabled (boolean)', 'vis_type_vega.enableExternalUrls (boolean)', + 'xpack.actions.email.domain_allowlist (array)', 'xpack.apm.profilingEnabled (boolean)', 'xpack.apm.serviceMapEnabled (boolean)', 'xpack.apm.ui.enabled (boolean)', diff --git a/x-pack/plugins/actions/common/index.ts b/x-pack/plugins/actions/common/index.ts index 1e51adf3e9d09..1b26f04e72265 100644 --- a/x-pack/plugins/actions/common/index.ts +++ b/x-pack/plugins/actions/common/index.ts @@ -11,6 +11,9 @@ export * from './types'; export * from './alert_history_schema'; export * from './rewrite_request_case'; +export * from './mustache_template'; +export * from './validate_email_addresses'; +export * from './servicenow_config'; export const BASE_ACTION_API_PATH = '/api/actions'; export const INTERNAL_BASE_ACTION_API_PATH = '/internal/actions'; diff --git a/x-pack/plugins/actions/common/mustache_template.test.ts b/x-pack/plugins/actions/common/mustache_template.test.ts new file mode 100644 index 0000000000000..2d35c00a8efa1 --- /dev/null +++ b/x-pack/plugins/actions/common/mustache_template.test.ts @@ -0,0 +1,33 @@ +/* + * 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. + */ + +import { hasMustacheTemplate, withoutMustacheTemplate } from './mustache_template'; + +const nonMustacheEmails = ['', 'zero@a.b.c', '}}{{']; +const mustacheEmails = ['{{}}', '"bob" {{}}@elastic.co', 'sneaky{{\n}}pete']; + +describe('mustache_template', () => { + it('hasMustacheTemplate', () => { + for (const email of nonMustacheEmails) { + expect(hasMustacheTemplate(email)).toBe(false); + } + for (const email of mustacheEmails) { + expect(hasMustacheTemplate(email)).toBe(true); + } + }); + + it('withoutMustacheTemplate', () => { + let result = withoutMustacheTemplate(nonMustacheEmails); + expect(result).toEqual(nonMustacheEmails); + + result = withoutMustacheTemplate(mustacheEmails); + expect(result).toEqual([]); + + result = withoutMustacheTemplate(mustacheEmails.concat(nonMustacheEmails)); + expect(result).toEqual(nonMustacheEmails); + }); +}); diff --git a/x-pack/plugins/actions/common/mustache_template.ts b/x-pack/plugins/actions/common/mustache_template.ts new file mode 100644 index 0000000000000..45e4b6d86ce66 --- /dev/null +++ b/x-pack/plugins/actions/common/mustache_template.ts @@ -0,0 +1,18 @@ +/* + * 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 MustacheInEmailRegExp = /\{\{((.|\n)*)\}\}/; + +/** does the string contain `{{.*}}`? */ +export function hasMustacheTemplate(string: string): boolean { + return !!string.match(MustacheInEmailRegExp); +} + +/** filter strings that do not contain `{{.*}}` */ +export function withoutMustacheTemplate(strings: string[]): string[] { + return strings.filter((string) => !hasMustacheTemplate(string)); +} diff --git a/x-pack/plugins/actions/common/servicenow_config.ts b/x-pack/plugins/actions/common/servicenow_config.ts new file mode 100644 index 0000000000000..994f6cb33524f --- /dev/null +++ b/x-pack/plugins/actions/common/servicenow_config.ts @@ -0,0 +1,56 @@ +/* + * 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 serviceNowITSMTable = 'incident'; +export const serviceNowSIRTable = 'sn_si_incident'; + +export const ServiceNowITSMActionTypeId = '.servicenow'; +export const ServiceNowSIRActionTypeId = '.servicenow-sir'; +export const ServiceNowITOMActionTypeId = '.servicenow-itom'; + +const SN_ITSM_APP_ID = '7148dbc91bf1f450ced060a7234bcb88'; +const SN_SIR_APP_ID = '2f0746801baeb01019ae54e4604bcb0f'; + +export interface SNProductsConfigValue { + table: string; + appScope: string; + useImportAPI: boolean; + importSetTable: string; + commentFieldKey: string; + appId?: string; +} + +export type SNProductsConfig = Record; + +export const snExternalServiceConfig: SNProductsConfig = { + '.servicenow': { + importSetTable: 'x_elas2_inc_int_elastic_incident', + appScope: 'x_elas2_inc_int', + table: 'incident', + useImportAPI: true, + commentFieldKey: 'work_notes', + appId: SN_ITSM_APP_ID, + }, + '.servicenow-sir': { + importSetTable: 'x_elas2_sir_int_elastic_si_incident', + appScope: 'x_elas2_sir_int', + table: 'sn_si_incident', + useImportAPI: true, + commentFieldKey: 'work_notes', + appId: SN_SIR_APP_ID, + }, + '.servicenow-itom': { + importSetTable: 'x_elas2_inc_int_elastic_incident', + appScope: 'x_elas2_inc_int', + table: 'em_event', + useImportAPI: false, + commentFieldKey: 'work_notes', + }, +}; + +export const FIELD_PREFIX = 'u_'; +export const DEFAULT_ALERTS_GROUPING_KEY = '{{rule.id}}:{{alert.id}}'; diff --git a/x-pack/plugins/actions/common/types.ts b/x-pack/plugins/actions/common/types.ts index 20de64a7cc3b5..751b403780080 100644 --- a/x-pack/plugins/actions/common/types.ts +++ b/x-pack/plugins/actions/common/types.ts @@ -16,6 +16,17 @@ export interface ActionType { minimumLicenseRequired: LicenseType; } +export enum InvalidEmailReason { + invalid = 'invalid', + notAllowed = 'notAllowed', +} + +export interface ValidatedEmail { + address: string; + valid: boolean; + reason?: InvalidEmailReason; +} + export interface ActionResult { id: string; actionTypeId: string; @@ -49,3 +60,7 @@ export function isActionTypeExecutorResult( ActionTypeExecutorResultStatusValues.includes(unsafeResult?.status) ); } + +export interface ActionsPublicConfigType { + allowedEmailDomains: string[]; +} diff --git a/x-pack/plugins/actions/common/validate_email_addresses.test.ts b/x-pack/plugins/actions/common/validate_email_addresses.test.ts new file mode 100644 index 0000000000000..af76cdef72966 --- /dev/null +++ b/x-pack/plugins/actions/common/validate_email_addresses.test.ts @@ -0,0 +1,284 @@ +/* + * 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. + */ + +import { ValidatedEmail, InvalidEmailReason } from './types'; +import { + validateEmailAddressesAsAlwaysValid, + validateEmailAddresses, + invalidEmailsAsMessage, +} from './validate_email_addresses'; + +const AllowedDomains = ['elastic.co', 'dev.elastic.co', 'found.no']; +const Emails = [ + 'bob@elastic.co', + '"Dr Tom" ', + 'jim@dev.elastic.co', + 'rex@found.no', + 'sal@alerting.dev.elastic.co', + 'nancy@example.com', + '"Dr RFC 5322" ', + 'totally invalid', + '{{sneaky}}', +]; + +describe('validate_email_address', () => { + test('validateEmailAddressesAsAlwaysValid()', () => { + const emails = ['bob@example.com', 'invalid-email', '']; + const validatedEmails = validateEmailAddressesAsAlwaysValid(emails); + + expect(validatedEmails).toMatchInlineSnapshot(` + Array [ + Object { + "address": "bob@example.com", + "valid": true, + }, + Object { + "address": "invalid-email", + "valid": true, + }, + Object { + "address": "", + "valid": true, + }, + ] + `); + }); + + describe('validateEmailAddresses()', () => { + test('with configured allowlist and no mustache filtering', () => { + const result = validateEmailAddresses(AllowedDomains, Emails); + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "address": "bob@elastic.co", + "valid": true, + }, + Object { + "address": "\\"Dr Tom\\" ", + "valid": true, + }, + Object { + "address": "jim@dev.elastic.co", + "valid": true, + }, + Object { + "address": "rex@found.no", + "valid": true, + }, + Object { + "address": "sal@alerting.dev.elastic.co", + "reason": "notAllowed", + "valid": false, + }, + Object { + "address": "nancy@example.com", + "reason": "notAllowed", + "valid": false, + }, + Object { + "address": "\\"Dr RFC 5322\\" ", + "reason": "notAllowed", + "valid": false, + }, + Object { + "address": "totally invalid", + "reason": "invalid", + "valid": false, + }, + Object { + "address": "{{sneaky}}", + "reason": "invalid", + "valid": false, + }, + ] + `); + }); + + test('with configured allowlist and mustache filtering', () => { + const result = validateEmailAddresses(AllowedDomains, Emails, { + treatMustacheTemplatesAsValid: true, + }); + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "address": "bob@elastic.co", + "valid": true, + }, + Object { + "address": "\\"Dr Tom\\" ", + "valid": true, + }, + Object { + "address": "jim@dev.elastic.co", + "valid": true, + }, + Object { + "address": "rex@found.no", + "valid": true, + }, + Object { + "address": "sal@alerting.dev.elastic.co", + "reason": "notAllowed", + "valid": false, + }, + Object { + "address": "nancy@example.com", + "reason": "notAllowed", + "valid": false, + }, + Object { + "address": "\\"Dr RFC 5322\\" ", + "reason": "notAllowed", + "valid": false, + }, + Object { + "address": "totally invalid", + "reason": "invalid", + "valid": false, + }, + Object { + "address": "{{sneaky}}", + "valid": true, + }, + ] + `); + }); + + test('with no configured allowlist and no mustache filtering', () => { + const result = validateEmailAddresses(null, Emails); + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "address": "bob@elastic.co", + "valid": true, + }, + Object { + "address": "\\"Dr Tom\\" ", + "valid": true, + }, + Object { + "address": "jim@dev.elastic.co", + "valid": true, + }, + Object { + "address": "rex@found.no", + "valid": true, + }, + Object { + "address": "sal@alerting.dev.elastic.co", + "valid": true, + }, + Object { + "address": "nancy@example.com", + "valid": true, + }, + Object { + "address": "\\"Dr RFC 5322\\" ", + "valid": true, + }, + Object { + "address": "totally invalid", + "valid": true, + }, + Object { + "address": "{{sneaky}}", + "valid": true, + }, + ] + `); + }); + + test('with no configured allowlist and mustache filtering', () => { + const result = validateEmailAddresses(null, Emails, { treatMustacheTemplatesAsValid: true }); + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "address": "bob@elastic.co", + "valid": true, + }, + Object { + "address": "\\"Dr Tom\\" ", + "valid": true, + }, + Object { + "address": "jim@dev.elastic.co", + "valid": true, + }, + Object { + "address": "rex@found.no", + "valid": true, + }, + Object { + "address": "sal@alerting.dev.elastic.co", + "valid": true, + }, + Object { + "address": "nancy@example.com", + "valid": true, + }, + Object { + "address": "\\"Dr RFC 5322\\" ", + "valid": true, + }, + Object { + "address": "totally invalid", + "valid": true, + }, + Object { + "address": "{{sneaky}}", + "valid": true, + }, + ] + `); + }); + }); + + const entriesGood: ValidatedEmail[] = [ + { address: 'a', valid: true }, + { address: 'b', valid: true }, + ]; + + const entriesInvalid: ValidatedEmail[] = [ + { address: 'c', valid: false, reason: InvalidEmailReason.invalid }, + { address: 'd', valid: false, reason: InvalidEmailReason.invalid }, + ]; + + const entriesNotAllowed: ValidatedEmail[] = [ + { address: 'e', valid: false, reason: InvalidEmailReason.notAllowed }, + { address: 'f', valid: false, reason: InvalidEmailReason.notAllowed }, + ]; + + describe('invalidEmailsAsMessage()', () => { + test('with all valid entries', () => { + expect(invalidEmailsAsMessage(entriesGood)).toMatchInlineSnapshot(`undefined`); + expect(invalidEmailsAsMessage([entriesGood[0]])).toMatchInlineSnapshot(`undefined`); + }); + + test('with some invalid entries', () => { + let entries = entriesGood.concat(entriesInvalid); + expect(invalidEmailsAsMessage(entries)).toMatchInlineSnapshot(`"not valid emails: c, d"`); + + entries = entriesGood.concat(entriesInvalid[0]); + expect(invalidEmailsAsMessage(entries)).toMatchInlineSnapshot(`"not valid emails: c"`); + }); + + test('with some not allowed entries', () => { + let entries = entriesGood.concat(entriesNotAllowed); + expect(invalidEmailsAsMessage(entries)).toMatchInlineSnapshot(`"not allowed emails: e, f"`); + + entries = entriesGood.concat(entriesNotAllowed[0]); + expect(invalidEmailsAsMessage(entries)).toMatchInlineSnapshot(`"not allowed emails: e"`); + }); + + test('with some invalid and not allowed entries', () => { + const entries = entriesGood.concat(entriesInvalid).concat(entriesNotAllowed); + expect(invalidEmailsAsMessage(entries)).toMatchInlineSnapshot( + `"not valid emails: c, d; not allowed emails: e, f"` + ); + }); + }); +}); diff --git a/x-pack/plugins/actions/common/validate_email_addresses.ts b/x-pack/plugins/actions/common/validate_email_addresses.ts new file mode 100644 index 0000000000000..9a1d6f0e1405a --- /dev/null +++ b/x-pack/plugins/actions/common/validate_email_addresses.ts @@ -0,0 +1,114 @@ +/* + * 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. + */ + +import { parseAddressList } from 'email-addresses'; +import { ValidatedEmail, InvalidEmailReason } from './types'; +import { hasMustacheTemplate } from './mustache_template'; + +/** Options that can be used when validating email addresses */ +export interface ValidateEmailAddressesOptions { + /** treat any address which contains a mustache template as valid */ + treatMustacheTemplatesAsValid?: boolean; +} + +// this can be useful for cases where a plugin needs this function, +// but the actions plugin may not be available. This could be used +// as a stub for the real implementation. +export function validateEmailAddressesAsAlwaysValid(addresses: string[]): ValidatedEmail[] { + return addresses.map((address) => ({ address, valid: true })); +} + +export function validateEmailAddresses( + allowedDomains: string[] | null, + addresses: string[], + options: ValidateEmailAddressesOptions = {} +): ValidatedEmail[] { + // note: this is the legacy default, which would in theory allow + // mustache strings, so options.allowMustache is ignored in this + // case - everything is valid! + if (allowedDomains == null) { + return validateEmailAddressesAsAlwaysValid(addresses); + } + + return addresses.map((address) => validateEmailAddress(allowedDomains, address, options)); +} + +export function invalidEmailsAsMessage(validatedEmails: ValidatedEmail[]): string | undefined { + const invalid = validatedEmails.filter( + (validated) => !validated.valid && validated.reason === InvalidEmailReason.invalid + ); + const notAllowed = validatedEmails.filter( + (validated) => !validated.valid && validated.reason === InvalidEmailReason.notAllowed + ); + + const messages: string[] = []; + if (invalid.length !== 0) { + messages.push(`not valid emails: ${addressesFromValidatedEmails(invalid).join(', ')}`); + } + if (notAllowed.length !== 0) { + messages.push(`not allowed emails: ${addressesFromValidatedEmails(notAllowed).join(', ')}`); + } + + if (messages.length === 0) return; + + return messages.join('; '); +} + +// in case the npm email-addresses returns unexpected things ... +function validateEmailAddress( + allowedDomains: string[], + address: string, + options: ValidateEmailAddressesOptions +): ValidatedEmail { + // The reason we bypass the validation in this case, is that email addresses + // used in an alerting action could contain mustache templates which render + // as the actual values. So we can't really validate them. Fear not! + // We always do a final validation in the executor where we do NOT + // have this flag on. + if (options.treatMustacheTemplatesAsValid && hasMustacheTemplate(address)) { + return { address, valid: true }; + } + + try { + return validateEmailAddress_(allowedDomains, address); + } catch (err) { + return { address, valid: false, reason: InvalidEmailReason.invalid }; + } +} + +function validateEmailAddress_(allowedDomains: string[], address: string): ValidatedEmail { + const emailAddresses = parseAddressList(address); + if (emailAddresses == null) { + return { address, valid: false, reason: InvalidEmailReason.invalid }; + } + + const allowedDomainsSet = new Set(allowedDomains); + + for (const emailAddress of emailAddresses) { + let domains: string[] = []; + + if (emailAddress.type === 'group') { + domains = emailAddress.addresses.map((groupAddress) => groupAddress.domain); + } else if (emailAddress.type === 'mailbox') { + domains = [emailAddress.domain]; + } else { + return { address, valid: false, reason: InvalidEmailReason.invalid }; + } + + for (const domain of domains) { + if (!allowedDomainsSet.has(domain)) { + return { address, valid: false, reason: InvalidEmailReason.notAllowed }; + } + } + } + + return { address, valid: true }; +} + +function addressesFromValidatedEmails(validatedEmails: ValidatedEmail[]) { + return validatedEmails.map((validatedEmail) => validatedEmail.address); +} diff --git a/x-pack/plugins/actions/kibana.json b/x-pack/plugins/actions/kibana.json index 4970d2b0870c8..ad5ca87949848 100644 --- a/x-pack/plugins/actions/kibana.json +++ b/x-pack/plugins/actions/kibana.json @@ -10,5 +10,6 @@ "configPath": ["xpack", "actions"], "requiredPlugins": ["licensing", "taskManager", "encryptedSavedObjects", "eventLog", "features"], "optionalPlugins": ["usageCollection", "spaces", "security", "monitoringCollection"], - "ui": false + "extraPublicDirs": ["common"], + "ui": true } diff --git a/x-pack/plugins/actions/public/index.ts b/x-pack/plugins/actions/public/index.ts new file mode 100644 index 0000000000000..31d4d08b0dbe2 --- /dev/null +++ b/x-pack/plugins/actions/public/index.ts @@ -0,0 +1,15 @@ +/* + * 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. + */ + +import { PluginInitializerContext } from '@kbn/core/public'; +import { Plugin, ActionsPublicPluginSetup } from './plugin'; + +export type { ActionsPublicPluginSetup }; +export { Plugin }; +export function plugin(context: PluginInitializerContext) { + return new Plugin(context); +} diff --git a/x-pack/plugins/actions/public/plugin.test.ts b/x-pack/plugins/actions/public/plugin.test.ts new file mode 100644 index 0000000000000..903fe917f32fa --- /dev/null +++ b/x-pack/plugins/actions/public/plugin.test.ts @@ -0,0 +1,65 @@ +/* + * 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. + */ + +import { coreMock } from '@kbn/core/public/mocks'; +import { Plugin } from './plugin'; + +describe('Actions Plugin', () => { + describe('setup()', () => { + const emails = ['bob@elastic.co', 'jim@somewhere.org', 'not an email']; + + it('should allow all emails when not using email allowlist config', async () => { + const context = coreMock.createPluginInitializerContext({}); + const plugin = new Plugin(context); + const pluginSetup = plugin.setup(); + const validated = pluginSetup.validateEmailAddresses(emails); + expect(validated).toMatchInlineSnapshot(` + Array [ + Object { + "address": "bob@elastic.co", + "valid": true, + }, + Object { + "address": "jim@somewhere.org", + "valid": true, + }, + Object { + "address": "not an email", + "valid": true, + }, + ] + `); + }); + + it('should validate correctly when using email allowlist config', async () => { + const context = coreMock.createPluginInitializerContext({ + email: { domain_allowlist: ['elastic.co'] }, + }); + const plugin = new Plugin(context); + const pluginSetup = plugin.setup(); + const validated = pluginSetup.validateEmailAddresses(emails); + expect(validated).toMatchInlineSnapshot(` + Array [ + Object { + "address": "bob@elastic.co", + "valid": true, + }, + Object { + "address": "jim@somewhere.org", + "reason": "notAllowed", + "valid": false, + }, + Object { + "address": "not an email", + "reason": "invalid", + "valid": false, + }, + ] + `); + }); + }); +}); diff --git a/x-pack/plugins/actions/public/plugin.ts b/x-pack/plugins/actions/public/plugin.ts new file mode 100644 index 0000000000000..7a64d1796fbec --- /dev/null +++ b/x-pack/plugins/actions/public/plugin.ts @@ -0,0 +1,44 @@ +/* + * 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. + */ + +import { Plugin as CorePlugin, PluginInitializerContext } from '@kbn/core/public'; +import { + ValidatedEmail, + validateEmailAddresses as validateEmails, + ValidateEmailAddressesOptions, +} from '../common'; + +export interface ActionsPublicPluginSetup { + validateEmailAddresses( + emails: string[], + options?: ValidateEmailAddressesOptions + ): ValidatedEmail[]; +} + +export interface Config { + email: { + domain_allowlist: string[]; + }; +} + +export class Plugin implements CorePlugin { + private readonly allowedEmailDomains: string[] | null = null; + + constructor(ctx: PluginInitializerContext) { + const config = ctx.config.get(); + this.allowedEmailDomains = config.email?.domain_allowlist || null; + } + + public setup(): ActionsPublicPluginSetup { + return { + validateEmailAddresses: (emails: string[], options: ValidateEmailAddressesOptions) => + validateEmails(this.allowedEmailDomains, emails, options), + }; + } + + public start(): void {} +} diff --git a/x-pack/plugins/actions/server/actions_config.mock.ts b/x-pack/plugins/actions/server/actions_config.mock.ts index f6110a48f785a..bf0ebb4e4791d 100644 --- a/x-pack/plugins/actions/server/actions_config.mock.ts +++ b/x-pack/plugins/actions/server/actions_config.mock.ts @@ -25,6 +25,7 @@ const createActionsConfigMock = () => { }), getCustomHostSettings: jest.fn().mockReturnValue(undefined), getMicrosoftGraphApiUrl: jest.fn().mockReturnValue(undefined), + validateEmailAddresses: jest.fn().mockReturnValue(undefined), }; return mocked; }; diff --git a/x-pack/plugins/actions/server/actions_config.test.ts b/x-pack/plugins/actions/server/actions_config.test.ts index 15261da6fd952..470e6ce8cdc8e 100644 --- a/x-pack/plugins/actions/server/actions_config.test.ts +++ b/x-pack/plugins/actions/server/actions_config.test.ts @@ -470,3 +470,47 @@ describe('getSSLSettings', () => { expect(sslSettings.verificationMode).toBe('none'); }); }); + +const testEmailsOk = ['bob@elastic.co', 'jim@elastic.co']; +const testEmailsNotAllowed = ['hal@bad.com', 'lou@notgood.org']; +const testEmailsInvalid = ['invalid-email-address', '(garbage)']; +const testEmailsAll = testEmailsOk.concat(testEmailsNotAllowed).concat(testEmailsInvalid); + +describe('validateEmailAddresses()', () => { + test('all domains allowed if config not set', () => { + const acu = getActionsConfigurationUtilities(defaultActionsConfig); + const message = acu.validateEmailAddresses(testEmailsAll); + expect(message).toEqual(undefined); + }); + + test('only filtered domains allowed if config set', () => { + const acu = getActionsConfigurationUtilities({ + ...defaultActionsConfig, + email: { + domain_allowlist: ['elastic.co'], + }, + }); + + let message = acu.validateEmailAddresses(testEmailsOk); + expect(message).toBe(undefined); + + message = acu.validateEmailAddresses(testEmailsAll); + expect(message).toMatchInlineSnapshot( + `"not valid emails: invalid-email-address, (garbage); not allowed emails: hal@bad.com, lou@notgood.org"` + ); + }); + + test('no domains allowed if config set to empty array', () => { + const acu = getActionsConfigurationUtilities({ + ...defaultActionsConfig, + email: { + domain_allowlist: [], + }, + }); + + const message = acu.validateEmailAddresses(testEmailsAll); + expect(message).toMatchInlineSnapshot( + `"not valid emails: invalid-email-address, (garbage); not allowed emails: bob@elastic.co, jim@elastic.co, hal@bad.com, lou@notgood.org"` + ); + }); +}); diff --git a/x-pack/plugins/actions/server/actions_config.ts b/x-pack/plugins/actions/server/actions_config.ts index f3f5216c2c3b5..35e08bb5cfe66 100644 --- a/x-pack/plugins/actions/server/actions_config.ts +++ b/x-pack/plugins/actions/server/actions_config.ts @@ -16,7 +16,11 @@ import { getCanonicalCustomHostUrl } from './lib/custom_host_settings'; import { ActionTypeDisabledError } from './lib'; import { ProxySettings, ResponseSettings, SSLSettings } from './types'; import { getSSLSettingsFromConfig } from './builtin_action_types/lib/get_node_ssl_options'; - +import { + ValidateEmailAddressesOptions, + validateEmailAddresses, + invalidEmailsAsMessage, +} from '../common'; export { AllowedHosts, EnabledActionTypes } from './config'; enum AllowListingField { @@ -36,6 +40,10 @@ export interface ActionsConfigurationUtilities { getResponseSettings: () => ResponseSettings; getCustomHostSettings: (targetUrl: string) => CustomHostSettings | undefined; getMicrosoftGraphApiUrl: () => undefined | string; + validateEmailAddresses( + addresses: string[], + options?: ValidateEmailAddressesOptions + ): string | undefined; } function allowListErrorMessage(field: AllowListingField, value: string) { @@ -139,12 +147,26 @@ function getCustomHostSettings( return customHostSettings.find((settings) => settings.url === canonicalUrl); } +function validateEmails( + config: ActionsConfig, + addresses: string[], + options: ValidateEmailAddressesOptions +): string | undefined { + if (config.email == null) { + return; + } + + const validated = validateEmailAddresses(config.email.domain_allowlist, addresses, options); + return invalidEmailsAsMessage(validated); +} + export function getActionsConfigurationUtilities( config: ActionsConfig ): ActionsConfigurationUtilities { const isHostnameAllowed = curry(isAllowed)(config); const isUriAllowed = curry(isHostnameAllowedInUri)(config); const isActionTypeEnabled = curry(isActionTypeEnabledInConfig)(config); + const validatedEmailCurried = curry(validateEmails)(config); return { isHostnameAllowed, isUriAllowed, @@ -170,5 +192,7 @@ export function getActionsConfigurationUtilities( }, getCustomHostSettings: (targetUrl: string) => getCustomHostSettings(config, targetUrl), getMicrosoftGraphApiUrl: () => getMicrosoftGraphApiUrlFromConfig(config), + validateEmailAddresses: (addresses: string[], options: ValidateEmailAddressesOptions) => + validatedEmailCurried(addresses, options), }; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts index 729e1e8c9ac2f..55f0fc0cb5f70 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts @@ -24,6 +24,7 @@ import { EmailActionType, EmailActionTypeExecutorOptions, } from './email'; +import { ValidateEmailAddressesOptions } from '../../common'; const sendEmailMock = sendEmail as jest.Mock; @@ -269,6 +270,26 @@ describe('config validation', () => { `"error validating action type config: [host] value 'smtp.gmail.com' is not in the allowedHosts configuration"` ); }); + + test('config validation for emails calls validateEmailAddresses', async () => { + const configurationUtilities = actionsConfigMock.create(); + configurationUtilities.validateEmailAddresses.mockImplementation(validateEmailAddressesImpl); + + const basicActionType = getActionType({ + logger: mockedLogger, + configurationUtilities, + }); + + expect(() => { + validateConfig(basicActionType, { + from: 'badmail', + service: 'gmail', + }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type config: [from]: stub for actual message"` + ); + expect(configurationUtilities.validateEmailAddresses).toHaveBeenNthCalledWith(1, ['badmail']); + }); }); describe('secrets validation', () => { @@ -404,6 +425,33 @@ describe('params validation', () => { `"error validating action params: [subject]: expected value of type [string] but got [undefined]"` ); }); + + test('params validation for emails calls validateEmailAddresses', async () => { + const configurationUtilities = actionsConfigMock.create(); + configurationUtilities.validateEmailAddresses.mockImplementation(validateEmailAddressesImpl); + + const basicActionType = getActionType({ + logger: mockedLogger, + configurationUtilities, + }); + + expect(() => { + validateParams(basicActionType, { + to: ['to@example.com'], + cc: ['cc@example.com'], + bcc: ['bcc@example.com'], + subject: 'this is a test', + message: 'this is the message', + }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action params: [to/cc/bcc]: stub for actual message"` + ); + + const allEmails = ['to@example.com', 'cc@example.com', 'bcc@example.com']; + expect(configurationUtilities.validateEmailAddresses).toHaveBeenNthCalledWith(1, allEmails, { + treatMustacheTemplatesAsValid: true, + }); + }); }); describe('execute()', () => { @@ -454,21 +502,9 @@ describe('execute()', () => { "status": "ok", } `); + delete sendEmailMock.mock.calls[0][1].configurationUtilities; expect(sendEmailMock.mock.calls[0][1]).toMatchInlineSnapshot(` Object { - "configurationUtilities": Object { - "ensureActionTypeEnabled": [MockFunction], - "ensureHostnameAllowed": [MockFunction], - "ensureUriAllowed": [MockFunction], - "getCustomHostSettings": [MockFunction], - "getMicrosoftGraphApiUrl": [MockFunction], - "getProxySettings": [MockFunction], - "getResponseSettings": [MockFunction], - "getSSLSettings": [MockFunction], - "isActionTypeEnabled": [MockFunction], - "isHostnameAllowed": [MockFunction], - "isUriAllowed": [MockFunction], - }, "connectorId": "some-id", "content": Object { "message": "a message to you @@ -517,21 +553,9 @@ describe('execute()', () => { sendEmailMock.mockReset(); await actionType.executor(customExecutorOptions); + delete sendEmailMock.mock.calls[0][1].configurationUtilities; expect(sendEmailMock.mock.calls[0][1]).toMatchInlineSnapshot(` Object { - "configurationUtilities": Object { - "ensureActionTypeEnabled": [MockFunction], - "ensureHostnameAllowed": [MockFunction], - "ensureUriAllowed": [MockFunction], - "getCustomHostSettings": [MockFunction], - "getMicrosoftGraphApiUrl": [MockFunction], - "getProxySettings": [MockFunction], - "getResponseSettings": [MockFunction], - "getSSLSettings": [MockFunction], - "isActionTypeEnabled": [MockFunction], - "isHostnameAllowed": [MockFunction], - "isUriAllowed": [MockFunction], - }, "connectorId": "some-id", "content": Object { "message": "a message to you @@ -580,21 +604,9 @@ describe('execute()', () => { sendEmailMock.mockReset(); await actionType.executor(customExecutorOptions); + delete sendEmailMock.mock.calls[0][1].configurationUtilities; expect(sendEmailMock.mock.calls[0][1]).toMatchInlineSnapshot(` Object { - "configurationUtilities": Object { - "ensureActionTypeEnabled": [MockFunction], - "ensureHostnameAllowed": [MockFunction], - "ensureUriAllowed": [MockFunction], - "getCustomHostSettings": [MockFunction], - "getMicrosoftGraphApiUrl": [MockFunction], - "getProxySettings": [MockFunction], - "getResponseSettings": [MockFunction], - "getSSLSettings": [MockFunction], - "isActionTypeEnabled": [MockFunction], - "isHostnameAllowed": [MockFunction], - "isUriAllowed": [MockFunction], - }, "connectorId": "some-id", "content": Object { "message": "a message to you @@ -745,4 +757,48 @@ describe('execute()', () => { This message was sent by Kibana. [View this in Kibana](https://localhost:1234/foo/bar/my/app)." `); }); + + test('ensure execution runs validator with allowMustache false', async () => { + const configurationUtilities = actionsConfigMock.create(); + configurationUtilities.validateEmailAddresses.mockImplementation(validateEmailAddressesImpl); + + const testActionType = getActionType({ + logger: mockedLogger, + configurationUtilities, + }); + + const customExecutorOptions: EmailActionTypeExecutorOptions = { + ...executorOptions, + params: { + ...params, + }, + }; + + const result = await testActionType.executor(customExecutorOptions); + expect(result).toMatchInlineSnapshot(` + Object { + "actionId": "some-id", + "message": "[to/cc/bcc]: stub for actual message", + "status": "error", + } + `); + expect(configurationUtilities.validateEmailAddresses.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Array [ + "jim@example.com", + "james@example.com", + "jimmy@example.com", + ], + ], + ] + `); + }); }); + +function validateEmailAddressesImpl( + addresses: string[], + options?: ValidateEmailAddressesOptions +): string | undefined { + return 'stub for actual message'; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.ts b/x-pack/plugins/actions/server/builtin_action_types/email.ts index 29c4f9fb595bd..b2c0549bd7ea4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.ts @@ -10,8 +10,9 @@ import { i18n } from '@kbn/i18n'; import { schema, TypeOf } from '@kbn/config-schema'; import nodemailerGetService from 'nodemailer/lib/well-known'; import SMTPConnection from 'nodemailer/lib/smtp-connection'; - import { Logger } from '@kbn/core/server'; +import { withoutMustacheTemplate } from '../../common'; + import { sendEmail, JSON_TRANSPORT_SERVICE, SendEmailOptions, Transport } from './lib/send_email'; import { portSchema } from './lib/schemas'; import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types'; @@ -65,6 +66,12 @@ function validateConfig( ): string | void { const config = configObject; + const emails = [config.from]; + const invalidEmailsMessage = configurationUtilities.validateEmailAddresses(emails); + if (!!invalidEmailsMessage) { + return `[from]: ${invalidEmailsMessage}`; + } + // If service is set as JSON_TRANSPORT_SERVICE or EXCHANGE, host/port are ignored, when the email is sent. // Note, not currently making these message translated, as will be // emitted alongside messages from @kbn/config-schema, which does not @@ -128,30 +135,30 @@ const SecretsSchema = schema.object(SecretsSchemaProps); export type ActionParamsType = TypeOf; -const ParamsSchema = schema.object( - { - to: schema.arrayOf(schema.string(), { defaultValue: [] }), - cc: schema.arrayOf(schema.string(), { defaultValue: [] }), - bcc: schema.arrayOf(schema.string(), { defaultValue: [] }), - subject: schema.string(), - message: schema.string(), - // kibanaFooterLink isn't inteded for users to set, this is here to be able to programatically - // provide a more contextual URL in the footer (ex: URL to the alert details page) - kibanaFooterLink: schema.object({ - path: schema.string({ defaultValue: '/' }), - text: schema.string({ - defaultValue: i18n.translate('xpack.actions.builtin.email.kibanaFooterLinkText', { - defaultMessage: 'Go to Kibana', - }), +const ParamsSchemaProps = { + to: schema.arrayOf(schema.string(), { defaultValue: [] }), + cc: schema.arrayOf(schema.string(), { defaultValue: [] }), + bcc: schema.arrayOf(schema.string(), { defaultValue: [] }), + subject: schema.string(), + message: schema.string(), + // kibanaFooterLink isn't inteded for users to set, this is here to be able to programatically + // provide a more contextual URL in the footer (ex: URL to the alert details page) + kibanaFooterLink: schema.object({ + path: schema.string({ defaultValue: '/' }), + text: schema.string({ + defaultValue: i18n.translate('xpack.actions.builtin.email.kibanaFooterLinkText', { + defaultMessage: 'Go to Kibana', }), }), - }, - { - validate: validateParams, - } -); + }), +}; + +const ParamsSchema = schema.object(ParamsSchemaProps); -function validateParams(paramsObject: unknown): string | void { +function validateParams( + configurationUtilities: ActionsConfigurationUtilities, + paramsObject: unknown +): string | void { // avoids circular reference ... const params = paramsObject as ActionParamsType; @@ -161,6 +168,14 @@ function validateParams(paramsObject: unknown): string | void { if (addrs === 0) { return 'no [to], [cc], or [bcc] entries'; } + + const emails = withoutMustacheTemplate(to.concat(cc).concat(bcc)); + const invalidEmailsMessage = configurationUtilities.validateEmailAddresses(emails, { + treatMustacheTemplatesAsValid: true, + }); + if (invalidEmailsMessage) { + return `[to/cc/bcc]: ${invalidEmailsMessage}`; + } } interface GetActionTypeParams { @@ -203,7 +218,9 @@ export function getActionType(params: GetActionTypeParams): EmailActionType { validate: curry(validateConfig)(configurationUtilities), }), secrets: SecretsSchema, - params: ParamsSchema, + params: schema.object(ParamsSchemaProps, { + validate: curry(validateParams)(configurationUtilities), + }), connector: validateConnector, }, renderParameterTemplates, @@ -243,6 +260,17 @@ async function executor( const params = execOptions.params; const connectorTokenClient = execOptions.services.connectorTokenClient; + const emails = params.to.concat(params.cc).concat(params.bcc); + let invalidEmailsMessage = configurationUtilities.validateEmailAddresses(emails); + if (invalidEmailsMessage) { + return { status: 'error', actionId, message: `[to/cc/bcc]: ${invalidEmailsMessage}` }; + } + + invalidEmailsMessage = configurationUtilities.validateEmailAddresses([config.from]); + if (invalidEmailsMessage) { + return { status: 'error', actionId, message: `[from]: ${invalidEmailsMessage}` }; + } + const transport: Transport = {}; if (secrets.user != null) { diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts index 65aad21db447e..8c36113032461 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts @@ -131,6 +131,7 @@ describe('send_email module', () => { page: 1, }); await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + requestOAuthClientCredentialsTokenMock.mock.calls[0].pop(); expect(requestOAuthClientCredentialsTokenMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ "https://login.microsoftonline.com/undefined/oauth2/v2.0/token", @@ -150,32 +151,11 @@ describe('send_email module', () => { "clientSecret": "sdfhkdsjhfksdjfh", "scope": "https://graph.microsoft.com/.default", }, - Object { - "ensureActionTypeEnabled": [MockFunction], - "ensureHostnameAllowed": [MockFunction], - "ensureUriAllowed": [MockFunction], - "getCustomHostSettings": [MockFunction], - "getMicrosoftGraphApiUrl": [MockFunction] { - "calls": Array [ - Array [], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], - }, - "getProxySettings": [MockFunction], - "getResponseSettings": [MockFunction], - "getSSLSettings": [MockFunction], - "isActionTypeEnabled": [MockFunction], - "isHostnameAllowed": [MockFunction], - "isUriAllowed": [MockFunction], - }, ] `); + delete sendEmailGraphApiMock.mock.calls[0][0].options.configurationUtilities; + sendEmailGraphApiMock.mock.calls[0].pop(); expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { @@ -187,29 +167,6 @@ describe('send_email module', () => { "messageHTML": "

a message

", "options": Object { - "configurationUtilities": Object { - "ensureActionTypeEnabled": [MockFunction], - "ensureHostnameAllowed": [MockFunction], - "ensureUriAllowed": [MockFunction], - "getCustomHostSettings": [MockFunction], - "getMicrosoftGraphApiUrl": [MockFunction] { - "calls": Array [ - Array [], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], - }, - "getProxySettings": [MockFunction], - "getResponseSettings": [MockFunction], - "getSSLSettings": [MockFunction], - "isActionTypeEnabled": [MockFunction], - "isHostnameAllowed": [MockFunction], - "isUriAllowed": [MockFunction], - }, "connectorId": "1", "content": Object { "message": "a message", @@ -247,29 +204,6 @@ describe('send_email module', () => { "trace": [MockFunction], "warn": [MockFunction], }, - Object { - "ensureActionTypeEnabled": [MockFunction], - "ensureHostnameAllowed": [MockFunction], - "ensureUriAllowed": [MockFunction], - "getCustomHostSettings": [MockFunction], - "getMicrosoftGraphApiUrl": [MockFunction] { - "calls": Array [ - Array [], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], - }, - "getProxySettings": [MockFunction], - "getResponseSettings": [MockFunction], - "getSSLSettings": [MockFunction], - "isActionTypeEnabled": [MockFunction], - "isHostnameAllowed": [MockFunction], - "isUriAllowed": [MockFunction], - }, ] `); @@ -331,6 +265,8 @@ describe('send_email module', () => { await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); expect(requestOAuthClientCredentialsTokenMock.mock.calls.length).toBe(0); + delete sendEmailGraphApiMock.mock.calls[0][0].options.configurationUtilities; + sendEmailGraphApiMock.mock.calls[0].pop(); expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { @@ -342,29 +278,6 @@ describe('send_email module', () => { "messageHTML": "

a message

", "options": Object { - "configurationUtilities": Object { - "ensureActionTypeEnabled": [MockFunction], - "ensureHostnameAllowed": [MockFunction], - "ensureUriAllowed": [MockFunction], - "getCustomHostSettings": [MockFunction], - "getMicrosoftGraphApiUrl": [MockFunction] { - "calls": Array [ - Array [], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], - }, - "getProxySettings": [MockFunction], - "getResponseSettings": [MockFunction], - "getSSLSettings": [MockFunction], - "isActionTypeEnabled": [MockFunction], - "isHostnameAllowed": [MockFunction], - "isUriAllowed": [MockFunction], - }, "connectorId": "1", "content": Object { "message": "a message", @@ -402,29 +315,6 @@ describe('send_email module', () => { "trace": [MockFunction], "warn": [MockFunction], }, - Object { - "ensureActionTypeEnabled": [MockFunction], - "ensureHostnameAllowed": [MockFunction], - "ensureUriAllowed": [MockFunction], - "getCustomHostSettings": [MockFunction], - "getMicrosoftGraphApiUrl": [MockFunction] { - "calls": Array [ - Array [], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], - }, - "getProxySettings": [MockFunction], - "getResponseSettings": [MockFunction], - "getSSLSettings": [MockFunction], - "isActionTypeEnabled": [MockFunction], - "isHostnameAllowed": [MockFunction], - "isUriAllowed": [MockFunction], - }, ] `); @@ -510,6 +400,8 @@ describe('send_email module', () => { await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); expect(requestOAuthClientCredentialsTokenMock.mock.calls.length).toBe(1); + delete sendEmailGraphApiMock.mock.calls[0][0].options.configurationUtilities; + sendEmailGraphApiMock.mock.calls[0].pop(); expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { @@ -521,29 +413,6 @@ describe('send_email module', () => { "messageHTML": "

a message

", "options": Object { - "configurationUtilities": Object { - "ensureActionTypeEnabled": [MockFunction], - "ensureHostnameAllowed": [MockFunction], - "ensureUriAllowed": [MockFunction], - "getCustomHostSettings": [MockFunction], - "getMicrosoftGraphApiUrl": [MockFunction] { - "calls": Array [ - Array [], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], - }, - "getProxySettings": [MockFunction], - "getResponseSettings": [MockFunction], - "getSSLSettings": [MockFunction], - "isActionTypeEnabled": [MockFunction], - "isHostnameAllowed": [MockFunction], - "isUriAllowed": [MockFunction], - }, "connectorId": "1", "content": Object { "message": "a message", @@ -581,29 +450,6 @@ describe('send_email module', () => { "trace": [MockFunction], "warn": [MockFunction], }, - Object { - "ensureActionTypeEnabled": [MockFunction], - "ensureHostnameAllowed": [MockFunction], - "ensureUriAllowed": [MockFunction], - "getCustomHostSettings": [MockFunction], - "getMicrosoftGraphApiUrl": [MockFunction] { - "calls": Array [ - Array [], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], - }, - "getProxySettings": [MockFunction], - "getResponseSettings": [MockFunction], - "getSSLSettings": [MockFunction], - "isActionTypeEnabled": [MockFunction], - "isHostnameAllowed": [MockFunction], - "isUriAllowed": [MockFunction], - }, ] `); @@ -647,6 +493,8 @@ describe('send_email module', () => { `Not able to update connector token for connectorId: 1 due to error: Fail`, ]); + delete sendEmailGraphApiMock.mock.calls[0][0].options.configurationUtilities; + sendEmailGraphApiMock.mock.calls[0].pop(); expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { @@ -658,29 +506,6 @@ describe('send_email module', () => { "messageHTML": "

a message

", "options": Object { - "configurationUtilities": Object { - "ensureActionTypeEnabled": [MockFunction], - "ensureHostnameAllowed": [MockFunction], - "ensureUriAllowed": [MockFunction], - "getCustomHostSettings": [MockFunction], - "getMicrosoftGraphApiUrl": [MockFunction] { - "calls": Array [ - Array [], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], - }, - "getProxySettings": [MockFunction], - "getResponseSettings": [MockFunction], - "getSSLSettings": [MockFunction], - "isActionTypeEnabled": [MockFunction], - "isHostnameAllowed": [MockFunction], - "isUriAllowed": [MockFunction], - }, "connectorId": "1", "content": Object { "message": "a message", @@ -742,29 +567,6 @@ describe('send_email module', () => { ], }, }, - Object { - "ensureActionTypeEnabled": [MockFunction], - "ensureHostnameAllowed": [MockFunction], - "ensureUriAllowed": [MockFunction], - "getCustomHostSettings": [MockFunction], - "getMicrosoftGraphApiUrl": [MockFunction] { - "calls": Array [ - Array [], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], - }, - "getProxySettings": [MockFunction], - "getResponseSettings": [MockFunction], - "getSSLSettings": [MockFunction], - "isActionTypeEnabled": [MockFunction], - "isHostnameAllowed": [MockFunction], - "isUriAllowed": [MockFunction], - }, ] `); }); @@ -801,6 +603,8 @@ describe('send_email module', () => { expect(requestOAuthClientCredentialsTokenMock.mock.calls.length).toBe(1); expect(connectorTokenClientM.deleteConnectorTokens.mock.calls.length).toBe(1); + delete sendEmailGraphApiMock.mock.calls[0][0].options.configurationUtilities; + sendEmailGraphApiMock.mock.calls[0].pop(); expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { @@ -812,29 +616,6 @@ describe('send_email module', () => { "messageHTML": "

a message

", "options": Object { - "configurationUtilities": Object { - "ensureActionTypeEnabled": [MockFunction], - "ensureHostnameAllowed": [MockFunction], - "ensureUriAllowed": [MockFunction], - "getCustomHostSettings": [MockFunction], - "getMicrosoftGraphApiUrl": [MockFunction] { - "calls": Array [ - Array [], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], - }, - "getProxySettings": [MockFunction], - "getResponseSettings": [MockFunction], - "getSSLSettings": [MockFunction], - "isActionTypeEnabled": [MockFunction], - "isHostnameAllowed": [MockFunction], - "isUriAllowed": [MockFunction], - }, "connectorId": "1", "content": Object { "message": "a message", @@ -872,29 +653,6 @@ describe('send_email module', () => { "trace": [MockFunction], "warn": [MockFunction], }, - Object { - "ensureActionTypeEnabled": [MockFunction], - "ensureHostnameAllowed": [MockFunction], - "ensureUriAllowed": [MockFunction], - "getCustomHostSettings": [MockFunction], - "getMicrosoftGraphApiUrl": [MockFunction] { - "calls": Array [ - Array [], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], - }, - "getProxySettings": [MockFunction], - "getResponseSettings": [MockFunction], - "getSSLSettings": [MockFunction], - "isActionTypeEnabled": [MockFunction], - "isHostnameAllowed": [MockFunction], - "isUriAllowed": [MockFunction], - }, ] `); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index 270abfed3fb35..4475832e1a7f7 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -26,6 +26,9 @@ import { ExternalIncidentServiceConfigurationBaseSchema, } from './schema'; import { ActionsConfigurationUtilities } from '../../actions_config'; +import { SNProductsConfigValue } from '../../../common'; + +export type { SNProductsConfigValue, SNProductsConfig } from '../../../common'; export type ServiceNowPublicConfigurationBaseType = TypeOf< typeof ExternalIncidentServiceConfigurationBaseSchema @@ -247,17 +250,6 @@ export interface GetApplicationInfoResponse { version: string; } -export interface SNProductsConfigValue { - table: string; - appScope: string; - useImportAPI: boolean; - importSetTable: string; - commentFieldKey: string; - appId?: string; -} - -export type SNProductsConfig = Record; - export enum ObservableTypes { ip4 = 'ipv4-addr', url = 'URL', diff --git a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts index eb4ce4fda4cc6..23bc0fba603d4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts @@ -160,22 +160,10 @@ describe('execute()', () => { secrets: { webhookUrl: 'http://example.com' }, params: { message: 'this invocation should succeed' }, }); + delete requestMock.mock.calls[0][0].configurationUtilities; expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` Object { "axios": undefined, - "configurationUtilities": Object { - "ensureActionTypeEnabled": [MockFunction], - "ensureHostnameAllowed": [MockFunction], - "ensureUriAllowed": [MockFunction], - "getCustomHostSettings": [MockFunction], - "getMicrosoftGraphApiUrl": [MockFunction], - "getProxySettings": [MockFunction], - "getResponseSettings": [MockFunction], - "getSSLSettings": [MockFunction], - "isActionTypeEnabled": [MockFunction], - "isHostnameAllowed": [MockFunction], - "isUriAllowed": [MockFunction], - }, "data": Object { "text": "this invocation should succeed", }, @@ -225,22 +213,10 @@ describe('execute()', () => { secrets: { webhookUrl: 'http://example.com' }, params: { message: 'this invocation should succeed' }, }); + delete requestMock.mock.calls[0][0].configurationUtilities; expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` Object { "axios": undefined, - "configurationUtilities": Object { - "ensureActionTypeEnabled": [MockFunction], - "ensureHostnameAllowed": [MockFunction], - "ensureUriAllowed": [MockFunction], - "getCustomHostSettings": [MockFunction], - "getMicrosoftGraphApiUrl": [MockFunction], - "getProxySettings": [MockFunction], - "getResponseSettings": [MockFunction], - "getSSLSettings": [MockFunction], - "isActionTypeEnabled": [MockFunction], - "isHostnameAllowed": [MockFunction], - "isUriAllowed": [MockFunction], - }, "data": Object { "text": "this invocation should succeed", }, diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts index 2f68c6465fa18..6b8a1a4e8447b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts @@ -279,6 +279,7 @@ describe('execute()', () => { params: { body: 'some data' }, }); + delete requestMock.mock.calls[0][0].configurationUtilities; expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` Object { "auth": Object { @@ -286,19 +287,6 @@ describe('execute()', () => { "username": "abc", }, "axios": undefined, - "configurationUtilities": Object { - "ensureActionTypeEnabled": [MockFunction], - "ensureHostnameAllowed": [MockFunction], - "ensureUriAllowed": [MockFunction], - "getCustomHostSettings": [MockFunction], - "getMicrosoftGraphApiUrl": [MockFunction], - "getProxySettings": [MockFunction], - "getResponseSettings": [MockFunction], - "getSSLSettings": [MockFunction], - "isActionTypeEnabled": [MockFunction], - "isHostnameAllowed": [MockFunction], - "isUriAllowed": [MockFunction], - }, "data": "some data", "headers": Object { "aheader": "a value", @@ -377,22 +365,10 @@ describe('execute()', () => { params: { body: 'some data' }, }); + delete requestMock.mock.calls[0][0].configurationUtilities; expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` Object { "axios": undefined, - "configurationUtilities": Object { - "ensureActionTypeEnabled": [MockFunction], - "ensureHostnameAllowed": [MockFunction], - "ensureUriAllowed": [MockFunction], - "getCustomHostSettings": [MockFunction], - "getMicrosoftGraphApiUrl": [MockFunction], - "getProxySettings": [MockFunction], - "getResponseSettings": [MockFunction], - "getSSLSettings": [MockFunction], - "isActionTypeEnabled": [MockFunction], - "isHostnameAllowed": [MockFunction], - "isUriAllowed": [MockFunction], - }, "data": "some data", "headers": Object { "aheader": "a value", diff --git a/x-pack/plugins/actions/server/config.test.ts b/x-pack/plugins/actions/server/config.test.ts index 4f39956d910a9..e6e3a24db5214 100644 --- a/x-pack/plugins/actions/server/config.test.ts +++ b/x-pack/plugins/actions/server/config.test.ts @@ -212,6 +212,25 @@ describe('config validation', () => { } `); }); + + test('validates email.domain_allowlist', () => { + const config: Record = {}; + let result = configSchema.validate(config); + expect(result.email === undefined); + + config.email = {}; + expect(() => configSchema.validate(config)).toThrowErrorMatchingInlineSnapshot( + `"[email.domain_allowlist]: expected value of type [array] but got [undefined]"` + ); + + config.email = { domain_allowlist: [] }; + result = configSchema.validate(config); + expect(result.email?.domain_allowlist).toEqual([]); + + config.email = { domain_allowlist: ['a.com', 'b.c.com', 'd.e.f.com'] }; + result = configSchema.validate(config); + expect(result.email?.domain_allowlist).toEqual(['a.com', 'b.c.com', 'd.e.f.com']); + }); }); // object creator that ensures we can create a property named __proto__ on an diff --git a/x-pack/plugins/actions/server/config.ts b/x-pack/plugins/actions/server/config.ts index 1f279f20c27c3..4c8ca7ff9fff7 100644 --- a/x-pack/plugins/actions/server/config.ts +++ b/x-pack/plugins/actions/server/config.ts @@ -112,6 +112,11 @@ export const configSchema = schema.object({ pageSize: schema.number({ defaultValue: 100 }), }), microsoftGraphApiUrl: schema.maybe(schema.string()), + email: schema.maybe( + schema.object({ + domain_allowlist: schema.arrayOf(schema.string()), + }) + ), }); export type ActionsConfig = TypeOf; diff --git a/x-pack/plugins/actions/server/index.ts b/x-pack/plugins/actions/server/index.ts index 7b5863eabf0a9..6b0070af0b022 100644 --- a/x-pack/plugins/actions/server/index.ts +++ b/x-pack/plugins/actions/server/index.ts @@ -57,6 +57,9 @@ export const plugin = (initContext: PluginInitializerContext) => new ActionsPlug export const config: PluginConfigDescriptor = { schema: configSchema, + exposeToBrowser: { + email: { domain_allowlist: true }, + }, deprecations: ({ renameFromRoot, unused }) => [ renameFromRoot('xpack.actions.whitelistedHosts', 'xpack.actions.allowedHosts', { level: 'warning', diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 3cdf28a8d80f3..1fad2a6189693 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -314,6 +314,7 @@ export class ActionsPlugin implements Plugin(), this.licenseState, + actionsConfigUtils, this.usageCounter ); diff --git a/x-pack/plugins/actions/server/routes/index.ts b/x-pack/plugins/actions/server/routes/index.ts index 48d87e9d62c58..ab90141ae1c80 100644 --- a/x-pack/plugins/actions/server/routes/index.ts +++ b/x-pack/plugins/actions/server/routes/index.ts @@ -18,10 +18,12 @@ import { connectorTypesRoute } from './connector_types'; import { updateActionRoute } from './update'; import { getWellKnownEmailServiceRoute } from './get_well_known_email_service'; import { defineLegacyRoutes } from './legacy'; +import { ActionsConfigurationUtilities } from '../actions_config'; export function defineRoutes( router: IRouter, licenseState: ILicenseState, + actionsConfigUtils: ActionsConfigurationUtilities, usageCounter?: UsageCounter ) { defineLegacyRoutes(router, licenseState, usageCounter); diff --git a/x-pack/plugins/actions/tsconfig.json b/x-pack/plugins/actions/tsconfig.json index 95788811e43f8..aad127a6ca94c 100644 --- a/x-pack/plugins/actions/tsconfig.json +++ b/x-pack/plugins/actions/tsconfig.json @@ -10,7 +10,8 @@ "server/**/*", // have to declare *.json explicitly due to https://github.com/microsoft/TypeScript/issues/25636 "server/**/*.json", - "common/*" + "public/**/*", + "common/**/*" ], "references": [ { "path": "../../../src/core/tsconfig.json" }, diff --git a/x-pack/plugins/triggers_actions_ui/kibana.json b/x-pack/plugins/triggers_actions_ui/kibana.json index 4be95ed2db91d..a872ad6fab5c0 100644 --- a/x-pack/plugins/triggers_actions_ui/kibana.json +++ b/x-pack/plugins/triggers_actions_ui/kibana.json @@ -7,9 +7,9 @@ "version": "kibana", "server": true, "ui": true, - "optionalPlugins": ["alerting", "cloud", "features", "home", "spaces"], - "requiredPlugins": ["management", "charts", "data", "kibanaReact", "kibanaUtils", "savedObjects", "unifiedSearch", "dataViews"], + "optionalPlugins": ["cloud", "features", "home", "spaces"], + "requiredPlugins": ["management", "charts", "data", "kibanaReact", "kibanaUtils", "savedObjects", "unifiedSearch", "dataViews", "alerting", "actions"], "configPath": ["xpack", "trigger_actions_ui"], "extraPublicDirs": ["public/common", "public/common/constants"], - "requiredBundles": ["alerting", "esUiShared", "kibanaReact", "kibanaUtils"] + "requiredBundles": ["alerting", "esUiShared", "kibanaReact", "kibanaUtils", "actions"] } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx index ab2cb5332d452..31ff85a20ed3b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx @@ -10,13 +10,44 @@ import { registerBuiltInActionTypes } from '..'; import { ActionTypeModel } from '../../../../types'; import { EmailActionConnector } from '../types'; import { getEmailServices } from './email'; +import { + ValidatedEmail, + InvalidEmailReason, + ValidateEmailAddressesOptions, + MustacheInEmailRegExp, +} from '@kbn/actions-plugin/common'; const ACTION_TYPE_ID = '.email'; let actionTypeModel: ActionTypeModel; +const RegistrationServices = { + validateEmailAddresses: validateEmails, +}; + +// stub for the real validator +function validateEmails( + addresses: string[], + options?: ValidateEmailAddressesOptions +): ValidatedEmail[] { + return addresses.map((address) => { + if (address.includes('invalid')) + return { address, valid: false, reason: InvalidEmailReason.invalid }; + else if (address.includes('notallowed')) + return { address, valid: false, reason: InvalidEmailReason.notAllowed }; + else if (options?.treatMustacheTemplatesAsValid) return { address, valid: true }; + else if (address.match(MustacheInEmailRegExp)) + return { address, valid: false, reason: InvalidEmailReason.invalid }; + else return { address, valid: true }; + }); +} + +beforeEach(() => { + jest.resetAllMocks(); +}); + beforeAll(() => { const actionTypeRegistry = new TypeRegistry(); - registerBuiltInActionTypes({ actionTypeRegistry }); + registerBuiltInActionTypes({ actionTypeRegistry, services: RegistrationServices }); const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); if (getResult !== null) { actionTypeModel = getResult; @@ -138,7 +169,7 @@ describe('connector validation', () => { actionTypeId: '.email', name: 'email', config: { - from: 'test@test.com', + from: 'test@notallowed.com', hasAuth: true, service: 'other', }, @@ -147,7 +178,7 @@ describe('connector validation', () => { expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ config: { errors: { - from: [], + from: ['Email address test@notallowed.com is not allowed.'], port: ['Port is required.'], host: ['Host is required.'], service: [], @@ -163,7 +194,13 @@ describe('connector validation', () => { }, }, }); + + // also check that mustache is not valid + actionConnector.config.from = '{{mustached}}'; + const validation = await actionTypeModel.validateConnector(actionConnector); + expect(validation?.config?.errors?.from).toEqual(['Email address {{mustached}} is not valid.']); }); + test('connector validation fails when user specified but not password', async () => { const actionConnector = { secrets: { @@ -336,6 +373,7 @@ describe('action params validation', () => { const actionParams = { to: [], cc: ['test1@test.com'], + bcc: ['mustache {{\n}} template'], message: 'message {test}', subject: 'test', }; @@ -353,15 +391,17 @@ describe('action params validation', () => { test('action params validation fails when action params is not valid', async () => { const actionParams = { - to: ['test@test.com'], + to: ['invalid.com'], + cc: ['bob@notallowed.com'], + bcc: ['another-invalid.com'], subject: 'test', }; expect(await actionTypeModel.validateParams(actionParams)).toEqual({ errors: { - to: [], - cc: [], - bcc: [], + to: ['Email address invalid.com is not valid.'], + cc: ['Email address bob@notallowed.com is not allowed.'], + bcc: ['Email address another-invalid.com is not valid.'], message: ['Message is required.'], subject: [], }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx index c5da19c305268..0add2396a74d0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx @@ -5,16 +5,18 @@ * 2.0. */ +import { uniq } from 'lodash'; import { lazy } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSelectOption } from '@elastic/eui'; -import { AdditionalEmailServices } from '@kbn/actions-plugin/common'; +import { AdditionalEmailServices, InvalidEmailReason } from '@kbn/actions-plugin/common'; import { ActionTypeModel, ConnectorValidationResult, GenericValidationResult, } from '../../../../types'; import { EmailActionParams, EmailConfig, EmailSecrets, EmailActionConnector } from '../types'; +import { RegistrationServices } from '..'; const emailServices: EuiSelectOption[] = [ { @@ -79,8 +81,9 @@ export function getEmailServices(isCloudEnabled: boolean) { : emailServices.filter((service) => service.value !== 'elastic_cloud'); } -export function getActionType(): ActionTypeModel { - const mailformat = /^[^@\s]+@[^@\s]+$/; +export function getActionType( + services: RegistrationServices +): ActionTypeModel { return { id: '.email', iconClass: 'email', @@ -122,9 +125,15 @@ export function getActionType(): ActionTypeModel(), }; const validationResult = { errors }; - if ( - (!(actionParams.to instanceof Array) || actionParams.to.length === 0) && - (!(actionParams.cc instanceof Array) || actionParams.cc.length === 0) && - (!(actionParams.bcc instanceof Array) || actionParams.bcc.length === 0) - ) { - const errorText = translations.TO_CC_REQUIRED; - errors.to.push(errorText); - errors.cc.push(errorText); - errors.bcc.push(errorText); - } + if (!actionParams.message?.length) { errors.message.push(translations.MESSAGE_REQUIRED); } if (!actionParams.subject?.length) { errors.subject.push(translations.SUBJECT_REQUIRED); } + + const toEmails = getToFields(actionParams); + const ccEmails = getCcFields(actionParams); + const bccEmails = getBccFields(actionParams); + + if (toEmails.length === 0 && ccEmails.length === 0 && bccEmails.length === 0) { + const errorText = translations.TO_CC_REQUIRED; + errors.to.push(errorText); + errors.cc.push(errorText); + errors.bcc.push(errorText); + } + + const allEmails = uniq(toEmails.concat(ccEmails).concat(bccEmails)); + const validatedEmails = services.validateEmailAddresses(allEmails, { + treatMustacheTemplatesAsValid: true, + }); + + const toEmailSet = new Set(toEmails); + const ccEmailSet = new Set(ccEmails); + const bccEmailSet = new Set(bccEmails); + + for (const validated of validatedEmails) { + if (!validated.valid) { + const email = validated.address; + const message = + validated.reason === InvalidEmailReason.notAllowed + ? translations.getNotAllowedEmailAddress(email) + : translations.getInvalidEmailAddress(email); + + if (toEmailSet.has(email)) errors.to.push(message); + if (ccEmailSet.has(email)) errors.cc.push(message); + if (bccEmailSet.has(email)) errors.bcc.push(message); + } + } + return validationResult; }, actionConnectorFields: lazy(() => import('./email_connector')), actionParamsFields: lazy(() => import('./email_params')), }; } + +function getToFields(actionParams: EmailActionParams): string[] { + if (!(actionParams.to instanceof Array)) return []; + return actionParams.to; +} + +function getCcFields(actionParams: EmailActionParams): string[] { + if (!(actionParams.cc instanceof Array)) return []; + return actionParams.cc; +} + +function getBccFields(actionParams: EmailActionParams): string[] { + if (!(actionParams.bcc instanceof Array)) return []; + return actionParams.bcc; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/translations.ts index 38e16f6046184..e1bee12d98993 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/translations.ts @@ -104,3 +104,20 @@ export const SUBJECT_REQUIRED = i18n.translate( defaultMessage: 'Subject is required.', } ); + +export function getInvalidEmailAddress(email: string) { + return i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.invalidEmail', + { + defaultMessage: 'Email address {email} is not valid.', + values: { email }, + } + ); +} + +export function getNotAllowedEmailAddress(email: string) { + return i18n.translate('xpack.triggersActionsUI.components.builtinActionTypes.error.notAllowed', { + defaultMessage: 'Email address {email} is not allowed.', + values: { email }, + }); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx index 4097adb7b067f..42dc8a16a8b19 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx @@ -9,13 +9,14 @@ import { TypeRegistry } from '../../../type_registry'; import { registerBuiltInActionTypes } from '..'; import { ActionTypeModel } from '../../../../types'; import { EsIndexActionConnector } from '../types'; +import { registrationServicesMock } from '../../../../mocks'; const ACTION_TYPE_ID = '.index'; let actionTypeModel: ActionTypeModel; beforeAll(() => { const actionTypeRegistry = new TypeRegistry(); - registerBuiltInActionTypes({ actionTypeRegistry }); + registerBuiltInActionTypes({ actionTypeRegistry, services: registrationServicesMock }); const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); if (getResult !== null) { actionTypeModel = getResult; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts index 6817631e2150a..e2089221b4d60 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { ValidatedEmail, ValidateEmailAddressesOptions } from '@kbn/actions-plugin/common'; import { getServerLogActionType } from './server_log'; import { getSlackActionType } from './slack'; import { getEmailActionType } from './email'; @@ -24,14 +25,23 @@ import { getJiraActionType } from './jira'; import { getResilientActionType } from './resilient'; import { getTeamsActionType } from './teams'; +export interface RegistrationServices { + validateEmailAddresses: ( + addresses: string[], + options?: ValidateEmailAddressesOptions + ) => ValidatedEmail[]; +} + export function registerBuiltInActionTypes({ actionTypeRegistry, + services, }: { actionTypeRegistry: TypeRegistry; + services: RegistrationServices; }) { actionTypeRegistry.register(getServerLogActionType()); actionTypeRegistry.register(getSlackActionType()); - actionTypeRegistry.register(getEmailActionType()); + actionTypeRegistry.register(getEmailActionType(services)); actionTypeRegistry.register(getIndexActionType()); actionTypeRegistry.register(getPagerDutyActionType()); actionTypeRegistry.register(getSwimlaneActionType()); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx index 4becccec3483d..3bb023b135c40 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira.test.tsx @@ -9,13 +9,14 @@ import { TypeRegistry } from '../../../type_registry'; import { registerBuiltInActionTypes } from '..'; import { ActionTypeModel } from '../../../../types'; import { JiraActionConnector } from './types'; +import { registrationServicesMock } from '../../../../mocks'; const ACTION_TYPE_ID = '.jira'; let actionTypeModel: ActionTypeModel; beforeAll(() => { const actionTypeRegistry = new TypeRegistry(); - registerBuiltInActionTypes({ actionTypeRegistry }); + registerBuiltInActionTypes({ actionTypeRegistry, services: registrationServicesMock }); const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); if (getResult !== null) { actionTypeModel = getResult; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx index 10bf1a45806e8..a8274729506af 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx @@ -9,13 +9,14 @@ import { TypeRegistry } from '../../../type_registry'; import { registerBuiltInActionTypes } from '..'; import { ActionTypeModel } from '../../../../types'; import { PagerDutyActionConnector } from '../types'; +import { registrationServicesMock } from '../../../../mocks'; const ACTION_TYPE_ID = '.pagerduty'; let actionTypeModel: ActionTypeModel; beforeAll(() => { const actionTypeRegistry = new TypeRegistry(); - registerBuiltInActionTypes({ actionTypeRegistry }); + registerBuiltInActionTypes({ actionTypeRegistry, services: registrationServicesMock }); const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); if (getResult !== null) { actionTypeModel = getResult; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx index 67a51964dcb03..209606913dce6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.test.tsx @@ -9,13 +9,14 @@ import { TypeRegistry } from '../../../type_registry'; import { registerBuiltInActionTypes } from '..'; import { ActionTypeModel } from '../../../../types'; import { ResilientActionConnector } from './types'; +import { registrationServicesMock } from '../../../../mocks'; const ACTION_TYPE_ID = '.resilient'; let actionTypeModel: ActionTypeModel; beforeAll(() => { const actionTypeRegistry = new TypeRegistry(); - registerBuiltInActionTypes({ actionTypeRegistry }); + registerBuiltInActionTypes({ actionTypeRegistry, services: registrationServicesMock }); const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); if (getResult !== null) { actionTypeModel = getResult; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.test.tsx index 69c1c18bd06ea..012a233973012 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.test.tsx @@ -8,13 +8,14 @@ import { TypeRegistry } from '../../../type_registry'; import { registerBuiltInActionTypes } from '..'; import { ActionTypeModel, UserConfiguredActionConnector } from '../../../../types'; +import { registrationServicesMock } from '../../../../mocks'; const ACTION_TYPE_ID = '.server-log'; let actionTypeModel: ActionTypeModel; beforeAll(() => { const actionTypeRegistry = new TypeRegistry(); - registerBuiltInActionTypes({ actionTypeRegistry }); + registerBuiltInActionTypes({ actionTypeRegistry, services: registrationServicesMock }); const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); if (getResult !== null) { actionTypeModel = getResult; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts index dad3e3e0d0170..a9e8b8f544d42 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts @@ -7,9 +7,7 @@ import { HttpSetup } from '@kbn/core/public'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { snExternalServiceConfig } from '@kbn/actions-plugin/server/builtin_action_types/servicenow/config'; -import { ActionTypeExecutorResult } from '@kbn/actions-plugin/common'; +import { ActionTypeExecutorResult, snExternalServiceConfig } from '@kbn/actions-plugin/common'; import { BASE_ACTION_API_PATH } from '../../../constants'; import { API_INFO_ERROR } from './translations'; import { AppInfo, RESTApiError } from './types'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx index a52c48d4e8e27..9a634170fa793 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.test.tsx @@ -9,6 +9,7 @@ import { TypeRegistry } from '../../../type_registry'; import { registerBuiltInActionTypes } from '..'; import { ActionTypeModel } from '../../../../types'; import { ServiceNowActionConnector } from './types'; +import { registrationServicesMock } from '../../../../mocks'; const SERVICENOW_ITSM_ACTION_TYPE_ID = '.servicenow'; const SERVICENOW_SIR_ACTION_TYPE_ID = '.servicenow-sir'; @@ -17,7 +18,7 @@ let actionTypeRegistry: TypeRegistry; beforeAll(() => { actionTypeRegistry = new TypeRegistry(); - registerBuiltInActionTypes({ actionTypeRegistry }); + registerBuiltInActionTypes({ actionTypeRegistry, services: registrationServicesMock }); }); describe('actionTypeRegistry.get() works', () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index a9ff9497bdf19..22afcd5255e44 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -8,8 +8,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { EuiSpacer } from '@elastic/eui'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { snExternalServiceConfig } from '@kbn/actions-plugin/server/builtin_action_types/servicenow/config'; +import { snExternalServiceConfig } from '@kbn/actions-plugin/common'; import { ActionConnectorFieldsProps } from '../../../../types'; import * as i18n from './translations'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector.tsx index 732aeed6313ac..de5cf4df5731a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector.tsx @@ -21,8 +21,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { snExternalServiceConfig } from '@kbn/actions-plugin/server/builtin_action_types/servicenow/config'; +import { snExternalServiceConfig } from '@kbn/actions-plugin/common'; import { ActionConnectorFieldsProps } from '../../../../types'; import { ServiceNowActionConnector } from './types'; import { CredentialsApiUrl } from './credentials_api_url'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx index 95ce25a03349e..76a23ab94d972 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx @@ -9,13 +9,14 @@ import { TypeRegistry } from '../../../type_registry'; import { registerBuiltInActionTypes } from '..'; import { ActionTypeModel } from '../../../../types'; import { SlackActionConnector } from '../types'; +import { registrationServicesMock } from '../../../../mocks'; const ACTION_TYPE_ID = '.slack'; let actionTypeModel: ActionTypeModel; beforeAll(async () => { const actionTypeRegistry = new TypeRegistry(); - registerBuiltInActionTypes({ actionTypeRegistry }); + registerBuiltInActionTypes({ actionTypeRegistry, services: registrationServicesMock }); const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); if (getResult !== null) { actionTypeModel = getResult; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane.test.tsx index 5d69af2d08779..45d68c8ab39e8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/swimlane/swimlane.test.tsx @@ -9,13 +9,14 @@ import { TypeRegistry } from '../../../type_registry'; import { registerBuiltInActionTypes } from '..'; import { ActionTypeModel } from '../../../../types'; import { SwimlaneActionConnector } from './types'; +import { registrationServicesMock } from '../../../../mocks'; const ACTION_TYPE_ID = '.swimlane'; let actionTypeModel: ActionTypeModel; beforeAll(() => { const actionTypeRegistry = new TypeRegistry(); - registerBuiltInActionTypes({ actionTypeRegistry }); + registerBuiltInActionTypes({ actionTypeRegistry, services: registrationServicesMock }); const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); if (getResult !== null) { actionTypeModel = getResult; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.test.tsx index da0a67fe79a20..8590433f39cc0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/teams/teams.test.tsx @@ -9,13 +9,14 @@ import { TypeRegistry } from '../../../type_registry'; import { registerBuiltInActionTypes } from '..'; import { ActionTypeModel } from '../../../../types'; import { TeamsActionConnector } from '../types'; +import { registrationServicesMock } from '../../../../mocks'; const ACTION_TYPE_ID = '.teams'; let actionTypeModel: ActionTypeModel; beforeAll(async () => { const actionTypeRegistry = new TypeRegistry(); - registerBuiltInActionTypes({ actionTypeRegistry }); + registerBuiltInActionTypes({ actionTypeRegistry, services: registrationServicesMock }); const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); if (getResult !== null) { actionTypeModel = getResult; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx index 7551f647a5b4a..771786157ed4c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx @@ -9,13 +9,14 @@ import { TypeRegistry } from '../../../type_registry'; import { registerBuiltInActionTypes } from '..'; import { ActionTypeModel } from '../../../../types'; import { WebhookActionConnector } from '../types'; +import { registrationServicesMock } from '../../../../mocks'; const ACTION_TYPE_ID = '.webhook'; let actionTypeModel: ActionTypeModel; beforeAll(() => { const actionTypeRegistry = new TypeRegistry(); - registerBuiltInActionTypes({ actionTypeRegistry }); + registerBuiltInActionTypes({ actionTypeRegistry, services: registrationServicesMock }); const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); if (getResult !== null) { actionTypeModel = getResult; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/xmatters.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/xmatters.test.tsx index 957302d18a6fc..7e9dbc4cf885a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/xmatters.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/xmatters.test.tsx @@ -9,13 +9,14 @@ import { TypeRegistry } from '../../../type_registry'; import { registerBuiltInActionTypes } from '..'; import { ActionTypeModel } from '../../../../types'; import { XmattersActionConnector } from '../types'; +import { registrationServicesMock } from '../../../../mocks'; const ACTION_TYPE_ID = '.xmatters'; let actionTypeModel: ActionTypeModel; beforeAll(() => { const actionTypeRegistry = new TypeRegistry(); - registerBuiltInActionTypes({ actionTypeRegistry }); + registerBuiltInActionTypes({ actionTypeRegistry, services: registrationServicesMock }); const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); if (getResult !== null) { actionTypeModel = getResult; diff --git a/x-pack/plugins/triggers_actions_ui/public/mocks.ts b/x-pack/plugins/triggers_actions_ui/public/mocks.ts index 79edc1f08ac97..007b906e8747b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/mocks.ts +++ b/x-pack/plugins/triggers_actions_ui/public/mocks.ts @@ -5,13 +5,14 @@ * 2.0. */ +import type { ValidatedEmail } from '@kbn/actions-plugin/common'; import type { TriggersAndActionsUIPublicPluginStart } from './plugin'; import { getAddConnectorFlyoutLazy } from './common/get_add_connector_flyout'; import { getEditConnectorFlyoutLazy } from './common/get_edit_connector_flyout'; import { getAddAlertFlyoutLazy } from './common/get_add_alert_flyout'; import { getEditAlertFlyoutLazy } from './common/get_edit_alert_flyout'; - +import { RegistrationServices } from './application/components/builtin_action_types'; import { TypeRegistry } from './application/type_registry'; import { ActionTypeModel, @@ -69,3 +70,9 @@ function createStartMock(): TriggersAndActionsUIPublicPluginStart { export const triggersActionsUiMock = { createStart: createStartMock, }; + +function validateEmailAddresses(addresses: string[]): ValidatedEmail[] { + return addresses.map((address) => ({ address, valid: true })); +} + +export const registrationServicesMock: RegistrationServices = { validateEmailAddresses }; diff --git a/x-pack/plugins/triggers_actions_ui/public/plugin.ts b/x-pack/plugins/triggers_actions_ui/public/plugin.ts index ba2e869c82e0f..7997552a81023 100644 --- a/x-pack/plugins/triggers_actions_ui/public/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/public/plugin.ts @@ -16,6 +16,7 @@ import { ManagementAppMountParams, ManagementSetup } from '@kbn/management-plugi import type { HomePublicPluginSetup } from '@kbn/home-plugin/public'; import { ChartsPluginStart } from '@kbn/charts-plugin/public'; import { PluginStartContract as AlertingStart } from '@kbn/alerting-plugin/public'; +import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; import { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { Storage } from '@kbn/kibana-utils-plugin/public'; @@ -81,6 +82,7 @@ interface PluginsSetup { management: ManagementSetup; home?: HomePublicPluginSetup; cloud?: { isCloudEnabled: boolean }; + actions: ActionsPublicPluginSetup; } interface PluginsStart { @@ -193,6 +195,9 @@ export class Plugin registerBuiltInActionTypes({ actionTypeRegistry: this.actionTypeRegistry, + services: { + validateEmailAddresses: plugins.actions.validateEmailAddresses, + }, }); if (this.experimentalFeatures.internalAlertsTable) { diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 02f1f42af5270..14039ad3360a0 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -24,6 +24,7 @@ interface CreateTestConfigOptions { preconfiguredAlertHistoryEsIndex?: boolean; customizeLocalHostSsl?: boolean; rejectUnauthorized?: boolean; // legacy + emailDomainsAllowed?: string[]; } // test.not-enabled is specifically not enabled @@ -62,6 +63,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) preconfiguredAlertHistoryEsIndex = false, customizeLocalHostSsl = false, rejectUnauthorized = true, // legacy + emailDomainsAllowed = undefined, } = options; return async ({ readConfigFile }: FtrConfigProviderContext) => { @@ -132,6 +134,10 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) ? [`--xpack.actions.customHostSettings=${JSON.stringify(customHostSettingsValue)}`] : []; + const emailSettings = emailDomainsAllowed + ? [`--xpack.actions.email.domain_allowlist=${JSON.stringify(emailDomainsAllowed)}`] + : []; + return { testFiles: [require.resolve(`../${name}/tests/`)], servers, @@ -173,6 +179,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) `--xpack.actions.ssl.verificationMode=${verificationMode}`, ...actionsProxyUrl, ...customHostSettings, + ...emailSettings, '--xpack.eventLog.logEntries=true', '--xpack.task_manager.ephemeral_tasks.enabled=false', `--xpack.task_manager.unsafe.exclude_task_types=${JSON.stringify([ diff --git a/x-pack/test/alerting_api_integration/spaces_only/config.ts b/x-pack/test/alerting_api_integration/spaces_only/config.ts index 204f5b27da9d5..dcf1c70cf8dca 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/config.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/config.ts @@ -7,6 +7,8 @@ import { createTestConfig } from '../common/config'; +export const EmailDomainsAllowed = ['example.org', 'test.com']; + // eslint-disable-next-line import/no-default-export export default createTestConfig('spaces_only', { disabledPlugins: ['security'], @@ -15,4 +17,5 @@ export default createTestConfig('spaces_only', { verificationMode: 'none', customizeLocalHostSsl: true, preconfiguredAlertHistoryEsIndex: true, + emailDomainsAllowed: EmailDomainsAllowed, }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/email.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/email.ts new file mode 100644 index 0000000000000..22d46b3918932 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/email.ts @@ -0,0 +1,175 @@ +/* + * 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. + */ +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { ObjectRemover } from '../../../../common/lib'; +import { EmailDomainsAllowed } from '../../../config'; + +const EmailDomainAllowed = EmailDomainsAllowed[EmailDomainsAllowed.length - 1]; + +// eslint-disable-next-line import/no-default-export +export default function emailTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const objectRemover = new ObjectRemover(supertest); + + describe('email connector', () => { + afterEach(() => objectRemover.removeAll()); + + it('succeeds with allowed email domains', async () => { + const from = `bob@${EmailDomainAllowed}`; + const conn = await createConnector(from); + expect(conn.status).to.be(200); + + const { id } = conn.body; + expect(id).to.be.a('string'); + + const to = EmailDomainsAllowed.map((domain) => `jeb@${domain}`).sort(); + const cc = EmailDomainsAllowed.map((domain) => `jim@${domain}`).sort(); + const bcc = EmailDomainsAllowed.map((domain) => `joe@${domain}`).sort(); + + const ccNames = cc.map((email) => `Jimmy Jack <${email}>`); + + const run = await runConnector(id, to, ccNames, bcc); + expect(run.status).to.be(200); + + const { status, data } = run.body || {}; + expect(status).to.be('ok'); + + const { message } = data || {}; + const { from: fromMsg } = message || {}; + + expect(fromMsg?.address).to.be(from); + expect(addressesFromMessage(message, 'to')).to.eql(to); + expect(addressesFromMessage(message, 'cc')).to.eql(cc); + expect(addressesFromMessage(message, 'bcc')).to.eql(bcc); + + const ccNamesMsg = namesFromMessage(message, 'cc'); + for (const ccName of ccNamesMsg) { + expect(ccName).to.be('Jimmy Jack'); + } + }); + + describe('fails for invalid email domains', () => { + it('in create when invalid "from" used', async () => { + const from = `bob@not.allowed`; + const { status, body } = await createConnector(from); + expect(status).to.be(400); + + const { message = 'no message returned' } = body || {}; + expect(message).to.match(/not allowed emails: bob@not.allowed/); + }); + + it('in execute when invalid "to", "cc" or "bcc" used', async () => { + const from = `bob@${EmailDomainAllowed}`; + const conn = await createConnector(from); + expect(conn.status).to.be(200); + + const { id } = conn.body || {}; + expect(id).to.be.a('string'); + + const to = EmailDomainsAllowed.map((domain) => `jeb@${domain}`).sort(); + const cc = EmailDomainsAllowed.map((domain) => `jim@${domain}`).sort(); + const bcc = EmailDomainsAllowed.map((domain) => `joe@${domain}`).sort(); + + to.push('jeb1@not.allowed'); + cc.push('jeb2@not.allowed'); + bcc.push('jeb3@not.allowed'); + + const { status, body } = await runConnector(id, to, cc, bcc); + expect(status).to.be(200); + + expect(body?.status).to.be('error'); + expect(body?.message).to.match( + /not allowed emails: jeb1@not.allowed, jeb2@not.allowed, jeb3@not.allowed/ + ); + }); + }); + }); + + /* returns the following `body`, for the special email __json service: + { + "status": "ok", + "data": { + "envelope": { + "from": "bob@example.org", + "to": [ "jeb@example.com", ...] + }, + "messageId": "", + "message": { + "from": { "address": "bob@example.org", "name": "" }, + "to": [ { "address": "jeb@example.com", "name": "" }, ...], + "cc": [ ... ], + "bcc": [ ... ], + ... + } + }, + ... + } + */ + async function createConnector(from: string): Promise<{ status: number; body: any }> { + const { status, body } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: `An email connector from ${__filename}`, + connector_type_id: '.email', + config: { + service: '__json', + from, + hasAuth: true, + }, + secrets: { + user: 'bob', + password: 'changeme', + }, + }); + + if (status === 200) { + objectRemover.add('default', body.id, 'connector', 'actions'); + } + + return { status, body }; + } + + async function runConnector( + id: string, + to: string[], + cc: string[], + bcc: string[] + ): Promise<{ status: number; body: any }> { + const subject = 'email-subject'; + const message = 'email-message'; + const { status, body } = await supertest + .post(`/api/actions/connector/${id}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params: { to, cc, bcc, subject, message } }); + + return { status, body }; + } +} + +function addressesFromMessage(message: any, which: 'to' | 'cc' | 'bcc'): string[] { + return addressFieldFromMessage(message, which, 'address'); +} + +function namesFromMessage(message: any, which: 'to' | 'cc' | 'bcc'): string[] { + return addressFieldFromMessage(message, which, 'name'); +} + +function addressFieldFromMessage( + message: any, + which1: 'to' | 'cc' | 'bcc', + which2: 'name' | 'address' +): string[] { + const result: string[] = []; + + const list = message?.[which1]; + if (!Array.isArray(list)) return result; + + return list.map((entry) => `${entry?.[which2]}`).sort(); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts index fc0b23290a865..20498981ac2eb 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts @@ -22,6 +22,7 @@ export default function actionsTests({ loadTestFile, getService }: FtrProviderCo loadTestFile(require.resolve('./update')); loadTestFile(require.resolve('./execute')); loadTestFile(require.resolve('./enqueue')); + loadTestFile(require.resolve('./builtin_action_types/email')); loadTestFile(require.resolve('./builtin_action_types/es_index')); loadTestFile(require.resolve('./builtin_action_types/webhook')); loadTestFile(require.resolve('./builtin_action_types/preconfigured_alert_history_connector')); diff --git a/yarn.lock b/yarn.lock index bd4e88bf2fcd6..4d1428bc991a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13094,6 +13094,11 @@ elliptic@^6.0.0: minimalistic-assert "^1.0.1" minimalistic-crypto-utils "^1.0.1" +email-addresses@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/email-addresses/-/email-addresses-5.0.0.tgz#7ae9e7f58eef7d5e3e2c2c2d3ea49b78dc854fa6" + integrity sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw== + emittery@^0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.7.1.tgz#c02375a927a40948c0345cc903072597f5270451"