[Case Observables] Implement server side validation (#209706)

## Summary

This PR introduces validation rules for Case Observables, shared between
client and the server.

### Testing

- Create a case
- Add on observable to it, picking up the ipv4 as an observable type
(for instance)
- Verify that only the valid values are allowed.
- Try updating the observable after it is created, same validation rules
apply.
- Do the same thing using API routes.
This commit is contained in:
Luke Gmys 2025-03-05 17:03:45 +01:00 committed by GitHub
parent 197a281bf9
commit 529a8573fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 453 additions and 292 deletions

View file

@ -0,0 +1,124 @@
/*
* 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 { validateDomain, validateEmail, validateGenericValue, validateIp } from './validators';
describe('validateEmail', () => {
it('should return an error if the value is not a string', () => {
const result = validateEmail(undefined);
expect(result).toEqual({
code: 'ERR_NOT_STRING',
});
});
it('should return an error if the value is not a valid email', () => {
const result = validateEmail('invalid-email');
expect(result).toEqual({
code: 'ERR_NOT_EMAIL',
});
});
it('should return undefined if the value is a valid email', () => {
const result = validateEmail('test@example.com');
expect(result).toBeUndefined();
});
});
describe('genericValidator', () => {
it('should return an error if the value is not a string', () => {
const result = validateGenericValue(123);
expect(result).toEqual({
code: 'ERR_NOT_STRING',
});
});
it('should return an error if the value is not valid', () => {
const result = validateGenericValue('invalid value!');
expect(result).toEqual({
code: 'ERR_NOT_VALID',
});
});
it('should return an error if the value is a json', () => {
const result = validateGenericValue('{}');
expect(result).toEqual({
code: 'ERR_NOT_VALID',
});
});
it('should return undefined if the value is valid', () => {
const result = validateGenericValue('valid_value');
expect(result).toBeUndefined();
});
});
describe('validateDomain', () => {
it('should return undefined for a valid domain', () => {
const result = validateDomain('example.com');
expect(result).toBeUndefined();
});
it('should return an error for an invalid domain', () => {
const result = validateDomain('-invalid.com');
expect(result).toEqual({
code: 'ERR_NOT_VALID',
});
});
it('should return an error for hyphen-spaced strings', () => {
const result = validateDomain('test-test');
expect(result).toEqual({
code: 'ERR_NOT_VALID',
});
});
it('should return an error for a non-string value', () => {
const result = validateDomain(12345);
expect(result).toEqual({
code: 'ERR_NOT_STRING',
});
});
});
describe('validateIp', () => {
it('should return undefined for a valid ipv4', () => {
const result = validateIp('ipv4')('127.0.0.1');
expect(result).toBeUndefined();
});
it('should return an error for invalid ipv4', () => {
const result = validateIp('ipv4')('invalid ip');
expect(result).toEqual({
code: 'ERR_NOT_VALID',
});
});
it('should return undefined for a valid ipv6', () => {
const result = validateIp('ipv6')('::1');
expect(result).toBeUndefined();
});
it('should return an error for an invalid ipv6', () => {
const result = validateIp('ipv6')('invalid ipv6');
expect(result).toEqual({
code: 'ERR_NOT_VALID',
});
});
});

View file

@ -0,0 +1,113 @@
/*
* 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 ipaddr from 'ipaddr.js';
import { parseAddressList } from 'email-addresses';
import isString from 'lodash/isString';
import {
OBSERVABLE_TYPE_DOMAIN,
OBSERVABLE_TYPE_EMAIL,
OBSERVABLE_TYPE_IPV4,
OBSERVABLE_TYPE_IPV6,
OBSERVABLE_TYPE_URL,
} from '../constants';
const DOMAIN_REGEX = /^(?!-)[A-Za-z0-9-]{1,63}(?<!-)\.[A-Za-z]{2,}$/;
const GENERIC_REGEX = /^[a-zA-Z0-9._:/\\]+$/;
export interface ValidationError {
code: string;
}
export type SharedValidationFunction = (value: unknown) => ValidationError | undefined;
export const createStringValidationFunction =
(stringValidator: (value: string) => ValidationError | undefined): SharedValidationFunction =>
(value: unknown): ValidationError | undefined => {
if (!isString(value)) {
return { code: 'ERR_NOT_STRING' };
}
if (!value.length) {
return { code: 'ERR_EMPTY' };
}
return stringValidator(value);
};
export const validateDomain = createStringValidationFunction((value) => {
return DOMAIN_REGEX.test(value) ? undefined : { code: 'ERR_NOT_VALID' };
});
export const validateGenericValue = createStringValidationFunction((value) => {
return GENERIC_REGEX.test(value) ? undefined : { code: 'ERR_NOT_VALID' };
});
export const validateIp = (kind: 'ipv6' | 'ipv4') =>
createStringValidationFunction((value: string) => {
try {
const parsed = ipaddr.parse(value);
if (parsed.kind() !== kind) {
return {
code: 'ERR_NOT_VALID',
};
}
} catch (error) {
return {
code: 'ERR_NOT_VALID',
};
}
});
export const validateUrl = createStringValidationFunction((value) => {
try {
new URL(value);
} catch (error) {
return {
code: 'ERR_NOT_VALID',
};
}
});
export const validateEmail = createStringValidationFunction((value: string) => {
if (parseAddressList(value) === null) {
return {
code: 'ERR_NOT_EMAIL',
};
}
});
export const getValidatorForObservableType = (
observableTypeKey: string | undefined
): SharedValidationFunction => {
switch (observableTypeKey) {
case OBSERVABLE_TYPE_URL.key: {
return validateUrl;
}
case OBSERVABLE_TYPE_DOMAIN.key: {
return validateDomain;
}
case OBSERVABLE_TYPE_EMAIL.key: {
return validateEmail;
}
case OBSERVABLE_TYPE_IPV4.key: {
return validateIp('ipv4');
}
case OBSERVABLE_TYPE_IPV6.key: {
return validateIp('ipv6');
}
default: {
return validateGenericValue;
}
}
};

View file

@ -5,142 +5,32 @@
* 2.0.
*/
import type { ValidationFunc } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib/types';
import { domainValidator, emailValidator, genericValidator, ipv4Validator } from './fields_config';
import { normalizeValueType, fieldsConfig } from './fields_config';
import { OBSERVABLE_TYPE_DOMAIN } from '../../../common/constants';
describe('emailValidator', () => {
it('should return an error if the value is not a string', () => {
const result = emailValidator({
value: undefined,
path: 'email',
} as Parameters<ValidationFunc>[0]);
describe('fields_config.ts', () => {
describe('normalizeValueType', () => {
it('should return the correct value type if it exists', () => {
expect(normalizeValueType(OBSERVABLE_TYPE_DOMAIN.key)).toEqual(OBSERVABLE_TYPE_DOMAIN.key);
});
expect(result).toEqual({
code: 'ERR_NOT_STRING',
message: 'Value should be a string',
path: 'email',
it('should return "generic" if value type does not exist', () => {
expect(normalizeValueType('nonExistentKey')).toEqual('generic');
});
});
it('should return an error if the value is not a valid email', () => {
const result = emailValidator({
value: 'invalid-email',
path: 'email',
} as Parameters<ValidationFunc>[0]);
expect(result).toEqual({
code: 'ERR_NOT_EMAIL',
message: 'Value should be a valid email',
path: 'email',
describe('fieldsConfig', () => {
it('should have correct default values for type key validation', () => {
const typeKeyValidations = fieldsConfig.typeKey.validations;
expect(typeKeyValidations.length).toBe(1);
expect(typeKeyValidations[0].validator).toBeDefined();
});
});
it('should return undefined if the value is a valid email', () => {
const result = emailValidator({
value: 'test@example.com',
path: 'email',
} as Parameters<ValidationFunc>[0]);
expect(result).toBeUndefined();
});
});
it('should have observable value field types defined', () => {
const valueConfigs = fieldsConfig.value;
describe('genericValidator', () => {
it('should return an error if the value is not a string', () => {
const result = genericValidator({
value: 123,
path: 'generic',
} as Parameters<ValidationFunc>[0]);
expect(result).toEqual({
code: 'ERR_NOT_STRING',
message: 'Value should be a string',
path: 'generic',
});
});
it('should return an error if the value is not valid', () => {
const result = genericValidator({
value: 'invalid value!',
path: 'generic',
} as Parameters<ValidationFunc>[0]);
expect(result).toEqual({
code: 'ERR_NOT_VALID',
message: 'Value is invalid',
path: 'generic',
});
});
it('should return undefined if the value is valid', () => {
const result = genericValidator({
value: 'valid_value',
path: 'generic',
} as Parameters<ValidationFunc>[0]);
expect(result).toBeUndefined();
});
});
describe('domainValidator', () => {
it('should return undefined for a valid domain', () => {
const result = domainValidator({
value: 'example.com',
path: 'domain',
} as Parameters<ValidationFunc>[0]);
expect(result).toBeUndefined();
});
it('should return an error for an invalid domain', () => {
const result = domainValidator({
value: '-invalid.com',
path: 'domain',
} as Parameters<ValidationFunc>[0]);
expect(result).toEqual({
code: 'ERR_NOT_VALID',
message: 'Value is invalid',
path: 'domain',
});
});
it('should return an error for hyphen-spaced strings', () => {
const result = domainValidator({
value: 'test-test',
path: 'domain',
} as Parameters<ValidationFunc>[0]);
expect(result).toEqual({
code: 'ERR_NOT_VALID',
message: 'Value is invalid',
path: 'domain',
});
});
it('should return an error for a non-string value', () => {
const result = domainValidator({
value: 12345,
path: 'domain',
} as Parameters<ValidationFunc>[0]);
expect(result).toEqual({
code: 'ERR_NOT_STRING',
message: 'Value should be a string',
path: 'domain',
});
});
});
describe('ipv4Validator', () => {
it('should return undefined for a valid ipv4', () => {
const result = ipv4Validator({
value: '127.0.0.1',
path: 'ipv4',
} as Parameters<ValidationFunc>[0]);
expect(result).toBeUndefined();
});
it('should return an error for invalid ipv4', () => {
const result = domainValidator({
value: 'invalid ip',
path: 'ipv4',
} as Parameters<ValidationFunc>[0]);
expect(result).toEqual({
code: 'ERR_NOT_VALID',
message: 'Value is invalid',
path: 'ipv4',
expect(Object.keys(valueConfigs).length).toBeGreaterThan(0);
});
});
});

View file

@ -6,10 +6,10 @@
*/
import { type ValidationFunc } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { parseAddressList } from 'email-addresses';
import ipaddr from 'ipaddr.js';
import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers';
import { getValidatorForObservableType } from '../../../common/observables/validators';
import {
OBSERVABLE_TYPE_DOMAIN,
OBSERVABLE_TYPE_EMAIL,
@ -19,182 +19,89 @@ import {
} from '../../../common/constants';
import * as i18n from './translations';
export const normalizeValueType = (value: string): keyof typeof fieldsConfig.value | 'generic' => {
if (value in fieldsConfig.value) {
return value as keyof typeof fieldsConfig.value;
const GENERIC_OBSERVABLE_VALUE_TYPE = 'generic' as const;
export const normalizeValueType = (
observableTypeKey: string
): keyof typeof fieldsConfig.value | typeof GENERIC_OBSERVABLE_VALUE_TYPE => {
if (observableTypeKey in fieldsConfig.value) {
return observableTypeKey as keyof typeof fieldsConfig.value;
}
return 'generic';
return GENERIC_OBSERVABLE_VALUE_TYPE;
};
const DOMAIN_REGEX = /^(?!-)[A-Za-z0-9-]{1,63}(?<!-)\.[A-Za-z]{2,}$/;
const GENERIC_REGEX = /^[a-zA-Z0-9._:/\\]+$/;
const notStringError = (path: string) => ({
code: 'ERR_NOT_STRING',
message: 'Value should be a string',
path,
});
const { emptyField } = fieldValidators;
const validatorFactory =
(
regex: RegExp,
message: string = i18n.INVALID_VALUE,
code: string = 'ERR_NOT_VALID'
): ValidationFunc =>
(...args: Parameters<ValidationFunc>) => {
const [{ value, path }] = args;
interface FieldValidationConfig {
validator: ValidationFunc;
}
if (typeof value !== 'string') {
return notStringError(path);
}
const validationsFactory = (
observableTypeKey: string | undefined,
message: string = i18n.INVALID_VALUE
): FieldValidationConfig[] => [
{
validator: emptyField(i18n.REQUIRED_VALUE),
},
{
validator: (...args: Parameters<ValidationFunc>) => {
const [{ value, path }] = args;
if (!regex.test(value)) {
return {
code,
message,
path,
};
}
};
const validationResult = getValidatorForObservableType(observableTypeKey)(value);
export const genericValidator = validatorFactory(GENERIC_REGEX);
export const domainValidator = validatorFactory(DOMAIN_REGEX);
const ipValidatorFactory =
(kind: 'ipv6' | 'ipv4') =>
(...args: Parameters<ValidationFunc>) => {
const [{ value, path }] = args;
if (typeof value !== 'string') {
return notStringError(path);
}
try {
const parsed = ipaddr.parse(value);
if (parsed.kind() !== kind) {
if (validationResult) {
return {
code: 'ERR_NOT_VALID',
message: i18n.INVALID_VALUE,
...validationResult,
message,
path,
};
}
} catch (error) {
return {
code: 'ERR_NOT_VALID',
message: i18n.INVALID_VALUE,
path,
};
}
};
export const ipv6Validator = ipValidatorFactory('ipv6');
export const ipv4Validator = ipValidatorFactory('ipv4');
export const urlValidator = (...args: Parameters<ValidationFunc>) => {
const [{ value, path }] = args;
if (typeof value !== 'string') {
return notStringError(path);
}
try {
new URL(value);
} catch (error) {
return {
code: 'ERR_NOT_VALID',
message: i18n.INVALID_VALUE,
path,
};
}
};
export const emailValidator = (...args: Parameters<ValidationFunc>) => {
const [{ value, path }] = args;
if (typeof value !== 'string') {
return notStringError(path);
}
const emailAddresses = parseAddressList(value);
if (emailAddresses == null) {
return { message: i18n.INVALID_EMAIL, code: 'ERR_NOT_EMAIL', path };
}
};
export const fieldsConfig = {
value: {
generic: {
validations: [
{
validator: emptyField(i18n.REQUIRED_VALUE),
},
{
validator: genericValidator,
},
],
label: i18n.FIELD_LABEL_VALUE,
},
[OBSERVABLE_TYPE_EMAIL.key]: {
validations: [
{
validator: emptyField(i18n.REQUIRED_VALUE),
},
{
validator: emailValidator,
},
],
label: 'Email',
},
[OBSERVABLE_TYPE_DOMAIN.key]: {
validations: [
{
validator: emptyField(i18n.REQUIRED_VALUE),
},
{
validator: domainValidator,
},
],
label: 'Domain',
},
[OBSERVABLE_TYPE_IPV4.key]: {
validations: [
{
validator: emptyField(i18n.REQUIRED_VALUE),
},
{
validator: ipv4Validator,
},
],
label: 'IPv4',
},
[OBSERVABLE_TYPE_IPV6.key]: {
validations: [
{
validator: emptyField(i18n.REQUIRED_VALUE),
},
{
validator: ipv6Validator,
},
],
label: 'IPv6',
},
[OBSERVABLE_TYPE_URL.key]: {
validations: [
{
validator: emptyField(i18n.REQUIRED_VALUE),
},
{
validator: urlValidator,
},
],
label: 'URL',
},
},
];
const observableValueFieldTypes = [
{
key: undefined,
label: i18n.FIELD_LABEL_VALUE,
},
{
key: OBSERVABLE_TYPE_EMAIL.key,
label: 'Email',
},
{
key: OBSERVABLE_TYPE_DOMAIN.key,
label: 'Domain',
},
{
key: OBSERVABLE_TYPE_IPV4.key,
label: 'IPv4',
},
{
key: OBSERVABLE_TYPE_IPV6.key,
label: 'IPv6',
},
{
key: OBSERVABLE_TYPE_URL.key,
label: 'URL',
},
];
const fieldsValueConfigsPerObservableType = observableValueFieldTypes.reduce(
(fieldsConfig, valueFieldConfig) => {
fieldsConfig[valueFieldConfig.key ?? GENERIC_OBSERVABLE_VALUE_TYPE] = {
label: valueFieldConfig.label,
validations: validationsFactory(valueFieldConfig.key),
};
return fieldsConfig;
},
{} as Record<string, { label: string; validations: FieldValidationConfig[] }>
);
export const fieldsConfig = {
value: fieldsValueConfigsPerObservableType,
typeKey: {
validations: [
{

View file

@ -73,6 +73,23 @@ describe('addObservable', () => {
);
});
it('should throw an error if the value is not valid', async () => {
mockLicensingService.isAtLeastPlatinum.mockResolvedValue(true);
await expect(
addObservable(
'case-id',
{ observable: { typeKey: OBSERVABLE_TYPE_IPV4.key, value: 'not an ip', description: '' } },
mockClientArgs,
mockCasesClient
)
).rejects.toThrow(
Boom.forbidden(
'Failed to add observable: Error: Observable value "not an ip" is not valid for selected observable type observable-type-ipv4.'
)
);
});
it('should throw an error if observable type is invalid', async () => {
mockLicensingService.isAtLeastPlatinum.mockResolvedValue(true);
@ -150,6 +167,28 @@ describe('updateObservable', () => {
expect(result).toBeDefined();
});
it('should not update an observable when the provided value is not valid', async () => {
mockLicensingService.isAtLeastPlatinum.mockResolvedValue(true);
await expect(
updateObservable(
'case-id',
mockObservable.id,
{
observable: {
value: 'not an ip',
description: 'Updated description',
},
},
mockClientArgs,
mockCasesClient
)
).rejects.toThrow(
Boom.forbidden(
'Failed to update observable: Error: Observable value "not an ip" is not valid for selected observable type observable-type-ipv4.'
)
);
});
it('should throw an error if license is not platinum', async () => {
mockLicensingService.isAtLeastPlatinum.mockResolvedValue(false);

View file

@ -28,6 +28,7 @@ import { LICENSING_CASE_OBSERVABLES_FEATURE } from '../../common/constants';
import {
validateDuplicatedObservablesInRequest,
validateObservableTypeKeyExists,
validateObservableValue,
} from '../validators';
const ensureUpdateAuthorized = async (
@ -76,6 +77,8 @@ export const addObservable = async (
observableTypeKey: params.observable.typeKey,
});
validateObservableValue(paramArgs.observable.typeKey, paramArgs.observable.value);
const currentObservables = retrievedCase.attributes.observables ?? [];
if (currentObservables.length === MAX_OBSERVABLES_PER_CASE) {
@ -156,6 +159,11 @@ export const updateObservable = async (
throw Boom.notFound(`Failed to update observable: observable id ${observableId} not found`);
}
validateObservableValue(
currentObservables[observableIndex].typeKey,
paramArgs.observable.value
);
const updatedObservables = [...currentObservables];
updatedObservables[observableIndex] = {
...updatedObservables[observableIndex],

View file

@ -5,13 +5,14 @@
* 2.0.
*/
import { OBSERVABLE_TYPE_IPV4 } from '../../common/constants';
import { OBSERVABLE_TYPE_EMAIL, OBSERVABLE_TYPE_IPV4 } from '../../common/constants';
import { createCasesClientMock } from './mocks';
import {
validateDuplicatedKeysInRequest,
validateDuplicatedObservableTypesInRequest,
validateDuplicatedObservablesInRequest,
validateObservableTypeKeyExists,
validateObservableValue,
} from './validators';
describe('validators', () => {
@ -220,4 +221,20 @@ describe('validators', () => {
);
});
});
describe('validateObservableValue', () => {
it('throws an error if any observable value is not valid', async () => {
expect(() =>
validateObservableValue(OBSERVABLE_TYPE_EMAIL.key, 'test')
).toThrowErrorMatchingInlineSnapshot(
`"Observable value \\"test\\" is not valid for selected observable type observable-type-email."`
);
});
it('does not throw when obserable value is valid', async () => {
expect(() =>
validateObservableValue(OBSERVABLE_TYPE_EMAIL.key, 'test@test.com')
).not.toThrow();
});
});
});

View file

@ -6,6 +6,7 @@
*/
import Boom from '@hapi/boom';
import { getValidatorForObservableType } from '../../common/observables/validators';
import { OBSERVABLE_TYPES_BUILTIN } from '../../common/constants';
import { type CasesClient } from './client';
import { getAvailableObservableTypesMap } from './observable_types';
@ -127,3 +128,17 @@ export const validateObservableTypeKeyExists = async (
throw Boom.badRequest(`Invalid observable type, key does not exist: ${observableTypeKey}`);
}
};
export const validateObservableValue = (
observableTypeKey: string | undefined,
observableValue: unknown
) => {
const validator = getValidatorForObservableType(observableTypeKey);
const validationError = validator(observableValue);
if (validationError) {
throw Boom.badRequest(
`Observable value "${observableValue}" is not valid for selected observable type ${observableTypeKey}.`
);
}
};

View file

@ -53,6 +53,26 @@ export default ({ getService }: FtrProviderContext): void => {
expect(updatedCase.observables.length).to.be.greaterThan(0);
});
it('can returns bad request when observable value does not pass validation', async () => {
const postedCase = await createCase(supertest, getPostCaseRequest());
expect(postedCase.observables).to.eql([]);
const newObservableData = {
value: 'not ip actually',
typeKey: OBSERVABLE_TYPE_IPV4.key,
description: '',
};
await addObservable({
supertest,
caseId: postedCase.id,
params: {
observable: newObservableData,
},
expectedHttpCode: 400,
});
});
it('returns bad request when using unknown observable type', async () => {
const postedCase = await createCase(supertest, getPostCaseRequest());
expect(postedCase.observables).to.eql([]);
@ -103,6 +123,34 @@ export default ({ getService }: FtrProviderContext): void => {
expect(updatedObservable.observables[0].value).to.be('192.168.68.1');
});
it('returns bad request when observable value does not pass validation', async () => {
const postedCase = await createCase(supertest, getPostCaseRequest());
const newObservableData = {
value: '127.0.0.1',
typeKey: OBSERVABLE_TYPE_IPV4.key,
description: '',
};
const {
observables: [observable],
} = await addObservable({
supertest,
caseId: postedCase.id,
params: {
observable: newObservableData,
},
});
await updateObservable({
supertest,
params: { observable: { description: '', value: 'not ip' } },
caseId: postedCase.id,
observableId: observable.id as string,
expectedHttpCode: 400,
});
});
});
describe('delete observable', () => {