[Cases] Add guardrails for add and update comment API (#161200)

Connected to https://github.com/elastic/kibana/issues/146945

## Summary

| Description  | Limit | Done? | Documented?
| ------------- | ---- | :---: | ---- |
| Total number of comment characters | 30.000 |  | Yes
|

- Tests.
- Updated Documentation.

### Checklist

Delete any items that are not applicable to this PR.

- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

### Release Notes

Post and Patch comment API limits total number of characters per comment
to 30000

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Janki Salvi 2023-07-06 11:47:27 +02:00 committed by GitHub
parent 75140a8977
commit 8543d5f94b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 451 additions and 4 deletions

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { PathReporter } from 'io-ts/lib/PathReporter';
import {
CommentAttributesBasicRt,
CommentType,
@ -29,6 +31,7 @@ import {
BulkGetAttachmentsRequestRt,
BulkGetAttachmentsResponseRt,
} from '.';
import { MAX_COMMENT_LENGTH } from '../../../constants';
describe('Comments', () => {
describe('CommentAttributesBasicRt', () => {
@ -323,6 +326,7 @@ describe('Comments', () => {
type: CommentType.user,
owner: 'cases',
};
it('has expected attributes in request', () => {
const query = CommentRequestRt.decode(defaultRequest);
@ -340,6 +344,68 @@ describe('Comments', () => {
right: defaultRequest,
});
});
describe('errors', () => {
describe('commentType: user', () => {
it('throws error when comment is too long', () => {
const longComment = 'x'.repeat(MAX_COMMENT_LENGTH + 1);
expect(
PathReporter.report(
CommentRequestRt.decode({ ...defaultRequest, comment: longComment })
)
).toContain('The length of the comment is too long. The maximum length is 30000.');
});
it('throws error when comment is empty', () => {
expect(
PathReporter.report(CommentRequestRt.decode({ ...defaultRequest, comment: '' }))
).toContain('The comment field cannot be an empty string.');
});
it('throws error when comment string of empty characters', () => {
expect(
PathReporter.report(CommentRequestRt.decode({ ...defaultRequest, comment: ' ' }))
).toContain('The comment field cannot be an empty string.');
});
});
describe('commentType: action', () => {
const request = {
type: CommentType.actions,
actions: {
targets: [
{
hostname: 'host1',
endpointId: '001',
},
],
type: 'isolate',
},
owner: 'cases',
};
it('throws error when comment is too long', () => {
const longComment = 'x'.repeat(MAX_COMMENT_LENGTH + 1);
expect(
PathReporter.report(CommentRequestRt.decode({ ...request, comment: longComment }))
).toContain('The length of the comment is too long. The maximum length is 30000.');
});
it('throws error when comment is empty', () => {
expect(
PathReporter.report(CommentRequestRt.decode({ ...request, comment: '' }))
).toContain('The comment field cannot be an empty string.');
});
it('throws error when comment string of empty characters', () => {
expect(
PathReporter.report(CommentRequestRt.decode({ ...request, comment: ' ' }))
).toContain('The comment field cannot be an empty string.');
});
});
});
});
describe('CommentRt', () => {

View file

@ -6,8 +6,12 @@
*/
import * as rt from 'io-ts';
import { MAX_BULK_GET_ATTACHMENTS, MAX_COMMENTS_PER_PAGE } from '../../../constants';
import { limitedArraySchema, paginationSchema } from '../../../schema';
import {
MAX_BULK_GET_ATTACHMENTS,
MAX_COMMENTS_PER_PAGE,
MAX_COMMENT_LENGTH,
} from '../../../constants';
import { limitedArraySchema, paginationSchema, limitedStringSchema } from '../../../schema';
import { jsonValueRt } from '../../runtime_types';
import { UserRt } from '../../user';
@ -193,7 +197,31 @@ const BasicCommentRequestRt = rt.union([
PersistableStateAttachmentRt,
]);
export const CommentRequestRt = rt.union([BasicCommentRequestRt, ExternalReferenceSORt]);
export const CommentRequestRt = rt.union([
rt.strict({
comment: limitedStringSchema({ fieldName: 'comment', min: 1, max: MAX_COMMENT_LENGTH }),
type: rt.literal(CommentType.user),
owner: rt.string,
}),
AlertCommentRequestRt,
rt.strict({
type: rt.literal(CommentType.actions),
comment: limitedStringSchema({ fieldName: 'comment', min: 1, max: MAX_COMMENT_LENGTH }),
actions: rt.strict({
targets: rt.array(
rt.strict({
hostname: rt.string,
endpointId: rt.string,
})
),
type: rt.string,
}),
owner: rt.string,
}),
ExternalReferenceNoSORt,
ExternalReferenceSORt,
PersistableStateAttachmentRt,
]);
export const CommentRequestWithoutRefsRt = rt.union([
BasicCommentRequestRt,

View file

@ -119,6 +119,7 @@ export const MAX_REPORTERS_FILTER_LENGTH = 100 as const;
export const MAX_TITLE_LENGTH = 160 as const;
export const MAX_CATEGORY_LENGTH = 50 as const;
export const MAX_DESCRIPTION_LENGTH = 30000 as const;
export const MAX_COMMENT_LENGTH = 30000 as const;
export const MAX_LENGTH_PER_TAG = 256 as const;
export const MAX_TAGS_PER_CASE = 200 as const;
export const MAX_DELETE_IDS_LENGTH = 100 as const;

View file

@ -5553,6 +5553,7 @@
"comment": {
"description": "The new comment. It is required only when `type` is `user`.",
"type": "string",
"maxLength": 30000,
"example": "A new comment."
},
"owner": {
@ -5642,6 +5643,7 @@
"comment": {
"description": "The new comment. It is required only when `type` is `user`.",
"type": "string",
"maxLength": 30000,
"example": "A new comment."
},
"id": {

View file

@ -3597,6 +3597,7 @@ components:
comment:
description: The new comment. It is required only when `type` is `user`.
type: string
maxLength: 30000
example: A new comment.
owner:
$ref: '#/components/schemas/owners'
@ -3663,6 +3664,7 @@ components:
comment:
description: The new comment. It is required only when `type` is `user`.
type: string
maxLength: 30000
example: A new comment.
id:
type: string

View file

@ -5,6 +5,7 @@ properties:
comment:
description: The new comment. It is required only when `type` is `user`.
type: string
maxLength: 30000
example: A new comment.
owner:
$ref: 'owners.yaml'

View file

@ -5,6 +5,7 @@ properties:
comment:
description: The new comment. It is required only when `type` is `user`.
type: string
maxLength: 30000
example: A new comment.
id:
type: string

View file

@ -7,6 +7,7 @@
import { comment } from '../../mocks';
import { createCasesClientMockArgs } from '../mocks';
import { MAX_COMMENT_LENGTH } from '../../../common/constants';
import { addComment } from './add';
describe('addComment', () => {
@ -22,4 +23,30 @@ describe('addComment', () => {
addComment({ comment: { ...comment, foo: 'bar' }, caseId: 'test-case' }, clientArgs)
).rejects.toThrow('invalid keys "foo"');
});
it('should throw an error if the comment length is too long', async () => {
const longComment = 'x'.repeat(MAX_COMMENT_LENGTH + 1);
await expect(
addComment({ comment: { ...comment, comment: longComment }, caseId: 'test-case' }, clientArgs)
).rejects.toThrow(
`Failed while adding a comment to case id: test-case error: Error: The length of the comment is too long. The maximum length is ${MAX_COMMENT_LENGTH}.`
);
});
it('should throw an error if the comment is an empty string', async () => {
await expect(
addComment({ comment: { ...comment, comment: '' }, caseId: 'test-case' }, clientArgs)
).rejects.toThrow(
'Failed while adding a comment to case id: test-case error: Error: The comment field cannot be an empty string.'
);
});
it('should throw an error if the description is a string with empty characters', async () => {
await expect(
addComment({ comment: { ...comment, comment: ' ' }, caseId: 'test-case' }, clientArgs)
).rejects.toThrow(
'Failed while adding a comment to case id: test-case error: Error: The comment field cannot be an empty string.'
);
});
});

View file

@ -5,8 +5,9 @@
* 2.0.
*/
import { comment } from '../../mocks';
import { comment, actionComment } from '../../mocks';
import { createCasesClientMockArgs } from '../mocks';
import { MAX_COMMENT_LENGTH } from '../../../common/constants';
import { bulkCreate } from './bulk_create';
describe('bulkCreate', () => {
@ -22,4 +23,79 @@ describe('bulkCreate', () => {
bulkCreate({ attachments: [{ ...comment, foo: 'bar' }], caseId: 'test-case' }, clientArgs)
).rejects.toThrow('invalid keys "foo"');
});
describe('comments', () => {
it('should throw an error if the comment length is too long', async () => {
const longComment = Array(MAX_COMMENT_LENGTH + 1)
.fill('x')
.toString();
await expect(
bulkCreate(
{ attachments: [{ ...comment, comment: longComment }], caseId: 'test-case' },
clientArgs
)
).rejects.toThrow(
`Failed while bulk creating attachment to case id: test-case error: Error: The length of the comment is too long. The maximum length is ${MAX_COMMENT_LENGTH}.`
);
});
it('should throw an error if the comment is an empty string', async () => {
await expect(
bulkCreate({ attachments: [{ ...comment, comment: '' }], caseId: 'test-case' }, clientArgs)
).rejects.toThrow(
'Failed while bulk creating attachment to case id: test-case error: Error: The comment field cannot be an empty string.'
);
});
it('should throw an error if the description is a string with empty characters', async () => {
await expect(
bulkCreate(
{ attachments: [{ ...comment, comment: ' ' }], caseId: 'test-case' },
clientArgs
)
).rejects.toThrow(
'Failed while bulk creating attachment to case id: test-case error: Error: The comment field cannot be an empty string.'
);
});
});
describe('actions', () => {
it('should throw an error if the comment length is too long', async () => {
const longComment = Array(MAX_COMMENT_LENGTH + 1)
.fill('x')
.toString();
await expect(
bulkCreate(
{ attachments: [{ ...actionComment, comment: longComment }], caseId: 'test-case' },
clientArgs
)
).rejects.toThrow(
`Failed while bulk creating attachment to case id: test-case error: Error: The length of the comment is too long. The maximum length is ${MAX_COMMENT_LENGTH}.`
);
});
it('should throw an error if the comment is an empty string', async () => {
await expect(
bulkCreate(
{ attachments: [{ ...actionComment, comment: '' }], caseId: 'test-case' },
clientArgs
)
).rejects.toThrow(
'Failed while bulk creating attachment to case id: test-case error: Error: The comment field cannot be an empty string.'
);
});
it('should throw an error if the description is a string with empty characters', async () => {
await expect(
bulkCreate(
{ attachments: [{ ...actionComment, comment: ' ' }], caseId: 'test-case' },
clientArgs
)
).rejects.toThrow(
'Failed while bulk creating attachment to case id: test-case error: Error: The comment field cannot be an empty string.'
);
});
});
});

View file

@ -0,0 +1,100 @@
/*
* 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 { comment, actionComment } from '../../mocks';
import { createCasesClientMockArgs } from '../mocks';
import { MAX_COMMENT_LENGTH } from '../../../common/constants';
import { update } from './update';
describe('update', () => {
const clientArgs = createCasesClientMockArgs();
beforeEach(() => {
jest.clearAllMocks();
});
describe('comments', () => {
const updateComment = { ...comment, id: 'comment-id', version: 'WzAsMV0=' };
it('should throw an error if the comment length is too long', async () => {
const longComment = Array(MAX_COMMENT_LENGTH + 1)
.fill('x')
.toString();
await expect(
update(
{ updateRequest: { ...updateComment, comment: longComment }, caseID: 'test-case' },
clientArgs
)
).rejects.toThrow(
`Failed to patch comment case id: test-case: Error: The length of the comment is too long. The maximum length is ${MAX_COMMENT_LENGTH}.`
);
});
it('should throw an error if the comment is an empty string', async () => {
await expect(
update(
{ updateRequest: { ...updateComment, comment: '' }, caseID: 'test-case' },
clientArgs
)
).rejects.toThrow(
'Failed to patch comment case id: test-case: Error: The comment field cannot be an empty string.'
);
});
it('should throw an error if the description is a string with empty characters', async () => {
await expect(
update(
{ updateRequest: { ...updateComment, comment: ' ' }, caseID: 'test-case' },
clientArgs
)
).rejects.toThrow(
'Failed to patch comment case id: test-case: Error: The comment field cannot be an empty string.'
);
});
});
describe('actions', () => {
const updateActionComment = { ...actionComment, id: 'comment-id', version: 'WzAsMV0=' };
it('should throw an error if the comment length is too long', async () => {
const longComment = Array(MAX_COMMENT_LENGTH + 1)
.fill('x')
.toString();
await expect(
update(
{ updateRequest: { ...updateActionComment, comment: longComment }, caseID: 'test-case' },
clientArgs
)
).rejects.toThrow(
`Failed to patch comment case id: test-case: Error: The length of the comment is too long. The maximum length is ${MAX_COMMENT_LENGTH}.`
);
});
it('should throw an error if the comment is an empty string', async () => {
await expect(
update(
{ updateRequest: { ...updateActionComment, comment: '' }, caseID: 'test-case' },
clientArgs
)
).rejects.toThrow(
'Failed to patch comment case id: test-case: Error: The comment field cannot be an empty string.'
);
});
it('should throw an error if the description is a string with empty characters', async () => {
await expect(
update(
{ updateRequest: { ...updateActionComment, comment: ' ' }, caseID: 'test-case' },
clientArgs
)
).rejects.toThrow(
'Failed to patch comment case id: test-case: Error: The comment field cannot be an empty string.'
);
});
});
});

View file

@ -9,6 +9,7 @@ import type { SavedObject } from '@kbn/core/server';
import type {
CasePostRequest,
CommentAttributes,
CommentRequestActionsType,
CommentRequestAlertType,
CommentRequestUserType,
ConnectorMappings,
@ -664,6 +665,21 @@ export const comment: CommentRequestUserType = {
owner: SECURITY_SOLUTION_OWNER,
};
export const actionComment: CommentRequestActionsType = {
type: CommentType.actions,
comment: 'I just isolated the host!',
actions: {
targets: [
{
hostname: 'host1',
endpointId: '001',
},
],
type: 'isolate',
},
owner: 'cases',
};
export const alertComment: CommentRequestAlertType = {
alertId: 'alert-id-1',
index: 'alert-index-1',

View file

@ -173,6 +173,73 @@ export default ({ getService }: FtrProviderContext): void => {
});
});
it('unhappy path - 400s when comment is too long', async () => {
const postedCase = await createCase(supertest, postCaseReq);
const patchedCase = await createComment({
supertest,
caseId: postedCase.id,
params: postCommentUserReq,
});
const longComment = Array(30001).fill('a').toString();
await updateComment({
supertest,
caseId: postedCase.id,
req: {
id: patchedCase.comments![0].id,
version: patchedCase.comments![0].version,
type: CommentType.user,
comment: longComment,
owner: 'securitySolutionFixture',
},
expectedHttpCode: 400,
});
});
it('unhappy path - 400s when comment is empty', async () => {
const postedCase = await createCase(supertest, postCaseReq);
const patchedCase = await createComment({
supertest,
caseId: postedCase.id,
params: postCommentUserReq,
});
await updateComment({
supertest,
caseId: postedCase.id,
req: {
id: patchedCase.comments![0].id,
version: patchedCase.comments![0].version,
type: CommentType.user,
comment: '',
owner: 'securitySolutionFixture',
},
expectedHttpCode: 400,
});
});
it('unhappy path - 400s when comment is a string of empty characters', async () => {
const postedCase = await createCase(supertest, postCaseReq);
const patchedCase = await createComment({
supertest,
caseId: postedCase.id,
params: postCommentUserReq,
});
await updateComment({
supertest,
caseId: postedCase.id,
req: {
id: patchedCase.comments![0].id,
version: patchedCase.comments![0].version,
type: CommentType.user,
comment: ' ',
owner: 'securitySolutionFixture',
},
expectedHttpCode: 400,
});
});
it('unhappy path - 400s when trying to change comment type', async () => {
const postedCase = await createCase(supertest, postCaseReq);
const patchedCase = await createComment({

View file

@ -274,6 +274,49 @@ export default ({ getService }: FtrProviderContext): void => {
});
});
it('400s when adding too long comment', async () => {
const postedCase = await createCase(supertest, postCaseReq);
const longComment = Array(30001).fill('a').toString();
await createComment({
supertest,
caseId: postedCase.id,
// @ts-expect-error
params: {
comment: longComment,
},
expectedHttpCode: 400,
});
});
it('400s when adding empty comment', async () => {
const postedCase = await createCase(supertest, postCaseReq);
await createComment({
supertest,
caseId: postedCase.id,
// @ts-expect-error
params: {
comment: '',
},
expectedHttpCode: 400,
});
});
it('400s when adding a comment with only empty characters', async () => {
const postedCase = await createCase(supertest, postCaseReq);
await createComment({
supertest,
caseId: postedCase.id,
// @ts-expect-error
params: {
comment: ' ',
},
expectedHttpCode: 400,
});
});
it('400s when adding excess attributes for type user', async () => {
const postedCase = await createCase(supertest, postCaseReq);

View file

@ -441,6 +441,23 @@ export default ({ getService }: FtrProviderContext): void => {
});
});
it('400s when comment is too long', async () => {
const longComment = 'x'.repeat(30001);
await bulkCreateAttachments({
supertest,
caseId: 'case-id',
params: [
{
type: CommentType.user,
comment: longComment,
owner: 'securitySolutionFixture',
},
],
expectedHttpCode: 400,
});
});
it('400s when adding excess attributes for type user', async () => {
const postedCase = await createCase(supertest, postCaseReq);