1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-09 00:18:00 +01:00

fix: disallow invalid tag values (#7268)

This PR fixes how Unleash handles tag values. Specifically, it does
these things:
1. Trims leading and trailing whitespace from tag values before
inserting them into the database
2. Updates OpenAPI validation to not allow whitespace-only and to ignore
leading and trailing whitespace

Additionally, it moves the tag length constants into the constants file
from the Joi tag schema file. This is because importing the values
previously rendered them as undefined (probably due to a circular
dependency somewhere in the system). This means that the previous values
were also ignored by OpenAPI.

UI updates reflecting this wil follow.

## Background
When you tag a flag, there's nothing stopping you from using an entirely
empty tag or a tag with leading/trailing whitespace.

Empty tags make little sense and leading trailing whitespace differences
are incredibly subtle:

![image](https://github.com/Unleash/unleash/assets/17786332/ec8fe193-8837-4c6a-b7bf-8766eff34eed)


Additionally, leading and trailing whitespace is not shown in the
dropdown list, so you'd have to guess at which is the right one.

![image](https://github.com/Unleash/unleash/assets/17786332/a14698ab-2bfd-4ec3-8814-b8e876d0aadb)
This commit is contained in:
Thomas Heartman 2024-06-05 08:31:30 +02:00 committed by GitHub
parent fef77c1fde
commit 2bad98a121
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 169 additions and 8 deletions

View File

@ -0,0 +1,101 @@
import { TAG_MAX_LENGTH, TAG_MIN_LENGTH } from '../../util';
import { validateSchema } from '../validate';
describe('tag value validation', () => {
test.each([
['minimum', TAG_MIN_LENGTH],
['maximum', TAG_MAX_LENGTH],
])(`names with the %s length are valid`, (_, length) => {
const data = {
value: 'a'.repeat(length),
type: 'simple',
};
const validationResult = validateSchema(
'#/components/schemas/createTagSchema',
data,
);
expect(validationResult).toBeUndefined();
});
test(`names can not be only whitespace`, () => {
const space = ' '.repeat(TAG_MIN_LENGTH);
const data = {
value: space,
type: 'simple',
};
const validationResult = validateSchema(
'#/components/schemas/createTagSchema',
data,
);
expect(validationResult).toMatchObject({
errors: [{ keyword: 'pattern', instancePath: '/value' }],
});
});
test(`names must be at least ${TAG_MIN_LENGTH} characters long, not counting leading and trailing whitespace`, () => {
const space = ' '.repeat(TAG_MIN_LENGTH);
const data = {
value: space + 'a'.repeat(TAG_MIN_LENGTH - 1) + space,
type: 'simple',
};
const validationResult = validateSchema(
'#/components/schemas/createTagSchema',
data,
);
expect(validationResult).toMatchObject({
errors: [{ keyword: 'pattern', instancePath: '/value' }],
});
});
test(`spaces within a tag value counts towards its maximum length`, () => {
const space = ' '.repeat(TAG_MAX_LENGTH);
const data = {
value: `a${space}z`,
type: 'simple',
};
const validationResult = validateSchema(
'#/components/schemas/createTagSchema',
data,
);
expect(validationResult).toMatchObject({
errors: [{ keyword: 'pattern', instancePath: '/value' }],
});
});
test(`leading and trailing whitespace does not count towards a name's maximum length`, () => {
const space = ' '.repeat(TAG_MAX_LENGTH);
const data = {
value: space + 'a'.repeat(TAG_MAX_LENGTH) + space,
type: 'simple',
};
const validationResult = validateSchema(
'#/components/schemas/createTagSchema',
data,
);
expect(validationResult).toBeUndefined();
});
test(`tag names can contain spaces`, () => {
const data = {
value: 'tag name with spaces',
type: 'simple',
};
const validationResult = validateSchema(
'#/components/schemas/createTagSchema',
data,
);
expect(validationResult).toBeUndefined();
});
});

View File

@ -0,0 +1,24 @@
import type { FromSchema } from 'json-schema-to-ts';
import { TAG_MAX_LENGTH, TAG_MIN_LENGTH } from '../../util';
import { tagSchema } from './tag-schema';
export const createTagSchema = {
...tagSchema,
$id: '#/components/schemas/createTagSchema',
description:
'Data used to create a new [tag](https://docs.getunleash.io/reference/tags)',
properties: {
...tagSchema.properties,
value: {
type: 'string',
pattern: `^\\s*\\S.{${TAG_MIN_LENGTH - 2},${
TAG_MAX_LENGTH - 2
}}\\S\\s*$`,
description: `The value of the tag. The value must be between ${TAG_MIN_LENGTH} and ${TAG_MAX_LENGTH} characters long. Leading and trailing whitespace is ignored and will be trimmed before saving the tag value.`,
example: 'a-tag-value',
},
},
components: {},
} as const;
export type CreateTagSchema = FromSchema<typeof createTagSchema>;

View File

@ -54,6 +54,7 @@ export * from './create-invited-user-schema';
export * from './create-pat-schema'; export * from './create-pat-schema';
export * from './create-strategy-schema'; export * from './create-strategy-schema';
export * from './create-strategy-variant-schema'; export * from './create-strategy-variant-schema';
export * from './create-tag-schema';
export * from './create-user-response-schema'; export * from './create-user-response-schema';
export * from './create-user-schema'; export * from './create-user-schema';
export * from './date-schema'; export * from './date-schema';

View File

@ -1,5 +1,5 @@
import type { FromSchema } from 'json-schema-to-ts'; import type { FromSchema } from 'json-schema-to-ts';
import { TAG_MAX_LENGTH, TAG_MIN_LENGTH } from '../../services/tag-schema'; import { TAG_MAX_LENGTH, TAG_MIN_LENGTH } from '../../util';
export const tagSchema = { export const tagSchema = {
$id: '#/components/schemas/tagSchema', $id: '#/components/schemas/tagSchema',
@ -11,16 +11,15 @@ export const tagSchema = {
properties: { properties: {
value: { value: {
type: 'string', type: 'string',
description: `The value of the tag.`,
minLength: TAG_MIN_LENGTH, minLength: TAG_MIN_LENGTH,
maxLength: TAG_MAX_LENGTH, maxLength: TAG_MAX_LENGTH,
description: 'The value of the tag',
example: 'a-tag-value', example: 'a-tag-value',
}, },
type: { type: {
type: 'string', type: 'string',
minLength: TAG_MIN_LENGTH, minLength: TAG_MIN_LENGTH,
maxLength: TAG_MAX_LENGTH, maxLength: TAG_MAX_LENGTH,
default: 'simple',
description: description:
'The [type](https://docs.getunleash.io/reference/tags#tag-types) of the tag', 'The [type](https://docs.getunleash.io/reference/tags#tag-types) of the tag',
example: 'simple', example: 'simple',

View File

@ -27,6 +27,7 @@ import {
} from '../../openapi/util/standard-responses'; } from '../../openapi/util/standard-responses';
import type FeatureTagService from '../../services/feature-tag-service'; import type FeatureTagService from '../../services/feature-tag-service';
import type { IFlagResolver } from '../../types'; import type { IFlagResolver } from '../../types';
import type { CreateTagSchema } from '../../openapi';
const version = 1; const version = 1;
@ -94,7 +95,7 @@ class TagController extends Controller {
), ),
...getStandardResponses(400, 401, 403, 409, 415), ...getStandardResponses(400, 401, 403, 409, 415),
}, },
requestBody: createRequestSchema('tagSchema'), requestBody: createRequestSchema('createTagSchema'),
}), }),
], ],
}); });
@ -196,7 +197,7 @@ class TagController extends Controller {
} }
async createTag( async createTag(
req: IAuthRequest<unknown, unknown, TagSchema>, req: IAuthRequest<unknown, unknown, CreateTagSchema>,
res: Response<TagWithVersionSchema>, res: Response<TagWithVersionSchema>,
): Promise<void> { ): Promise<void> {
const userName = extractUsername(req); const userName = extractUsername(req);

View File

@ -1,9 +1,8 @@
import Joi from 'joi'; import Joi from 'joi';
import { customJoi } from '../routes/util'; import { customJoi } from '../routes/util';
import { TAG_MAX_LENGTH, TAG_MIN_LENGTH } from '../util';
export const TAG_MIN_LENGTH = 2;
export const TAG_MAX_LENGTH = 50;
export const tagSchema = Joi.object() export const tagSchema = Joi.object()
.keys({ .keys({
value: Joi.string().min(TAG_MIN_LENGTH).max(TAG_MAX_LENGTH), value: Joi.string().min(TAG_MIN_LENGTH).max(TAG_MAX_LENGTH),

View File

@ -0,0 +1,29 @@
import type { IUnleashConfig } from '../types/option';
import { createTestConfig } from '../../test/config/test-config';
import type EventService from '../features/events/event-service';
import FakeTagStore from '../../test/fixtures/fake-tag-store';
import TagService from './tag-service';
const config: IUnleashConfig = createTestConfig();
test('should trim tag values before saving them', async () => {
const tagStore = new FakeTagStore();
const service = new TagService({ tagStore }, config, {
storeEvent: async () => {},
} as unknown as EventService);
await service.createTag(
{
value: ' test ',
type: 'simple',
},
{ id: 1, username: 'audit user', ip: '' },
);
expect(tagStore.tags).toMatchObject([
{
value: 'test',
type: 'simple',
},
]);
});

View File

@ -52,7 +52,11 @@ export default class TagService {
} }
async createTag(tag: ITag, auditUser: IAuditUser): Promise<ITag> { async createTag(tag: ITag, auditUser: IAuditUser): Promise<ITag> {
const data = await this.validate(tag); const trimmedTag = {
...tag,
value: tag.value.trim(),
};
const data = await this.validate(trimmedTag);
await this.tagStore.createTag(data); await this.tagStore.createTag(data);
await this.eventService.storeEvent( await this.eventService.storeEvent(
new TagCreatedEvent({ new TagCreatedEvent({

View File

@ -17,6 +17,9 @@ export const PREDEFINED_ROLE_TYPES = [ROOT_ROLE_TYPE, PROJECT_ROLE_TYPE];
export const ROOT_ROLE_TYPES = [ROOT_ROLE_TYPE, CUSTOM_ROOT_ROLE_TYPE]; export const ROOT_ROLE_TYPES = [ROOT_ROLE_TYPE, CUSTOM_ROOT_ROLE_TYPE];
export const PROJECT_ROLE_TYPES = [PROJECT_ROLE_TYPE, CUSTOM_PROJECT_ROLE_TYPE]; export const PROJECT_ROLE_TYPES = [PROJECT_ROLE_TYPE, CUSTOM_PROJECT_ROLE_TYPE];
export const TAG_MIN_LENGTH = 2;
export const TAG_MAX_LENGTH = 50;
/* CONTEXT FIELD OPERATORS */ /* CONTEXT FIELD OPERATORS */
export const NOT_IN = 'NOT_IN'; export const NOT_IN = 'NOT_IN';