Skip to content

Commit 53cf88a

Browse files
tofikwestclaude
andcommitted
fix(api): accept MIME types with parameters or whitespace (CS-217)
File upload endpoints were rejecting valid Content-Type values like `application/pdf;charset=utf-8` or `application/pdf\n` with a 400 "Invalid MIME type format" error, because each DTO enforced the regex /^[a-zA-Z0-9\-]+\/[a-zA-Z0-9\-\+\.]+$/ with no trim or parameter strip. Extracts a shared `IsMimeTypeField` decorator (+ `normalizeMimeType` helper) that: - strips RFC 7231 media-type parameters (`;charset=...`) and whitespace - lowercases the value before validation and persistence - matches the RFC 6838 restricted-name-chars grammar Applied to all four upload DTOs: - attachments/upload-attachment.dto.ts - tasks/dto/upload-attachment.dto.ts - task-management/dto/upload-task-item-attachment.dto.ts - org-chart/dto/upload-org-chart.dto.ts Also removes dead `BLOCKED_MIME_TYPES` constants from two DTOs — the actual blocklist lives in `attachments.service.ts` and operates on the already-lowercased fileType. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fdf8dc9 commit 53cf88a

6 files changed

Lines changed: 167 additions & 59 deletions

File tree

apps/api/src/attachments/upload-attachment.dto.ts

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,8 @@ import {
66
IsOptional,
77
IsString,
88
MaxLength,
9-
Matches,
109
} from 'class-validator';
11-
12-
// Block dangerous MIME types that could execute code
13-
const BLOCKED_MIME_TYPES = [
14-
'application/x-msdownload', // .exe
15-
'application/x-msdos-program',
16-
'application/x-executable',
17-
'application/x-sh', // Shell scripts
18-
'application/x-bat', // Batch files
19-
'text/x-sh',
20-
'text/x-python',
21-
'text/x-perl',
22-
'text/x-ruby',
23-
'application/x-httpd-php', // PHP files
24-
'application/x-javascript', // Executable JS (not JSON)
25-
'application/javascript',
26-
'text/javascript',
27-
];
10+
import { IsMimeTypeField } from '../utils/mime-type.validator';
2811

2912
export class UploadAttachmentDto {
3013
@ApiProperty({
@@ -42,11 +25,7 @@ export class UploadAttachmentDto {
4225
description: 'MIME type of the file',
4326
example: 'application/pdf',
4427
})
45-
@IsString()
46-
@IsNotEmpty()
47-
@Matches(/^[a-zA-Z0-9\-]+\/[a-zA-Z0-9\-\+\.]+$/, {
48-
message: 'Invalid MIME type format',
49-
})
28+
@IsMimeTypeField()
5029
fileType: string;
5130

5231
@ApiProperty({

apps/api/src/org-chart/dto/upload-org-chart.dto.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { IsNotEmpty, IsString, Matches } from 'class-validator';
1+
import { IsNotEmpty, IsString } from 'class-validator';
22
import { ApiProperty } from '@nestjs/swagger';
3+
import { IsMimeTypeField } from '../../utils/mime-type.validator';
34

45
export class UploadOrgChartDto {
56
@ApiProperty({ description: 'Original file name' })
@@ -8,11 +9,7 @@ export class UploadOrgChartDto {
89
fileName: string;
910

1011
@ApiProperty({ description: 'MIME type of the file (e.g. image/png)' })
11-
@IsString()
12-
@IsNotEmpty()
13-
@Matches(/^[a-zA-Z0-9\-]+\/[a-zA-Z0-9\-\+\.]+$/, {
14-
message: 'Invalid MIME type format',
15-
})
12+
@IsMimeTypeField()
1613
fileType: string;
1714

1815
@ApiProperty({ description: 'Base64-encoded file data' })

apps/api/src/task-management/dto/upload-task-item-attachment.dto.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@ import { Transform } from 'class-transformer';
33
import {
44
IsBase64,
55
IsNotEmpty,
6-
IsOptional,
76
IsString,
87
MaxLength,
9-
Matches,
108
IsEnum,
119
} from 'class-validator';
1210
import { TaskItemEntityType } from '@db';
11+
import { IsMimeTypeField } from '../../utils/mime-type.validator';
1312

1413
export class UploadTaskItemAttachmentDto {
1514
@ApiProperty({
@@ -27,11 +26,7 @@ export class UploadTaskItemAttachmentDto {
2726
description: 'MIME type of the file',
2827
example: 'application/pdf',
2928
})
30-
@IsString()
31-
@IsNotEmpty()
32-
@Matches(/^[a-zA-Z0-9\-]+\/[a-zA-Z0-9\-\+\.]+$/, {
33-
message: 'Invalid MIME type format',
34-
})
29+
@IsMimeTypeField()
3530
fileType: string;
3631

3732
@ApiProperty({

apps/api/src/tasks/dto/upload-attachment.dto.ts

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,8 @@ import {
66
IsOptional,
77
IsString,
88
MaxLength,
9-
Matches,
109
} from 'class-validator';
11-
12-
// Block dangerous MIME types that could execute code
13-
const BLOCKED_MIME_TYPES = [
14-
'application/x-msdownload', // .exe
15-
'application/x-msdos-program',
16-
'application/x-executable',
17-
'application/x-sh', // Shell scripts
18-
'application/x-bat', // Batch files
19-
'text/x-sh',
20-
'text/x-python',
21-
'text/x-perl',
22-
'text/x-ruby',
23-
'application/x-httpd-php', // PHP files
24-
'application/x-javascript', // Executable JS (not JSON)
25-
'application/javascript',
26-
'text/javascript',
27-
];
10+
import { IsMimeTypeField } from '../../utils/mime-type.validator';
2811

2912
export class UploadAttachmentDto {
3013
@ApiProperty({
@@ -42,11 +25,7 @@ export class UploadAttachmentDto {
4225
description: 'MIME type of the file',
4326
example: 'application/pdf',
4427
})
45-
@IsString()
46-
@IsNotEmpty()
47-
@Matches(/^[a-zA-Z0-9\-]+\/[a-zA-Z0-9\-\+\.]+$/, {
48-
message: 'Invalid MIME type format',
49-
})
28+
@IsMimeTypeField()
5029
fileType: string;
5130

5231
@ApiProperty({
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { plainToInstance } from 'class-transformer';
2+
import { validate } from 'class-validator';
3+
import { IsMimeTypeField, normalizeMimeType } from './mime-type.validator';
4+
5+
class TestDto {
6+
@IsMimeTypeField()
7+
fileType!: string;
8+
}
9+
10+
const expectValid = async (input: unknown, expectedNormalized: string) => {
11+
const instance = plainToInstance(TestDto, { fileType: input });
12+
const errors = await validate(instance);
13+
expect(errors).toEqual([]);
14+
expect(instance.fileType).toBe(expectedNormalized);
15+
};
16+
17+
const expectInvalid = async (input: unknown) => {
18+
const instance = plainToInstance(TestDto, { fileType: input });
19+
const errors = await validate(instance);
20+
expect(errors.length).toBeGreaterThan(0);
21+
};
22+
23+
describe('normalizeMimeType', () => {
24+
it('returns non-strings unchanged', () => {
25+
expect(normalizeMimeType(undefined)).toBe(undefined);
26+
expect(normalizeMimeType(null)).toBe(null);
27+
expect(normalizeMimeType(123)).toBe(123);
28+
});
29+
30+
it('strips parameters, whitespace, and lowercases', () => {
31+
expect(normalizeMimeType('application/pdf')).toBe('application/pdf');
32+
expect(normalizeMimeType('application/pdf;charset=utf-8')).toBe(
33+
'application/pdf',
34+
);
35+
expect(normalizeMimeType('application/pdf; charset=utf-8')).toBe(
36+
'application/pdf',
37+
);
38+
expect(normalizeMimeType(' application/PDF ')).toBe('application/pdf');
39+
expect(normalizeMimeType('application/pdf\n')).toBe('application/pdf');
40+
});
41+
});
42+
43+
describe('IsMimeTypeField', () => {
44+
describe('valid inputs (CS-217 regression)', () => {
45+
it('accepts application/pdf', async () => {
46+
await expectValid('application/pdf', 'application/pdf');
47+
});
48+
49+
it('accepts the xlsx vendor type', async () => {
50+
await expectValid(
51+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
52+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
53+
);
54+
});
55+
56+
it('accepts application/pdf with charset parameter', async () => {
57+
await expectValid(
58+
'application/pdf;charset=utf-8',
59+
'application/pdf',
60+
);
61+
});
62+
63+
it('accepts xlsx with charset parameter', async () => {
64+
await expectValid(
65+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8',
66+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
67+
);
68+
});
69+
70+
it('accepts trailing whitespace', async () => {
71+
await expectValid('application/pdf ', 'application/pdf');
72+
});
73+
74+
it('accepts trailing newline', async () => {
75+
await expectValid('application/pdf\n', 'application/pdf');
76+
});
77+
78+
it('accepts uppercase', async () => {
79+
await expectValid('APPLICATION/PDF', 'application/pdf');
80+
});
81+
82+
it('accepts structured syntax suffix (e.g. +json)', async () => {
83+
await expectValid(
84+
'application/vnd.api+json',
85+
'application/vnd.api+json',
86+
);
87+
});
88+
89+
it('accepts image/svg+xml', async () => {
90+
await expectValid('image/svg+xml', 'image/svg+xml');
91+
});
92+
});
93+
94+
describe('invalid inputs', () => {
95+
it('rejects empty string', async () => {
96+
await expectInvalid('');
97+
});
98+
99+
it('rejects missing slash', async () => {
100+
await expectInvalid('applicationpdf');
101+
});
102+
103+
it('rejects leading slash', async () => {
104+
await expectInvalid('/pdf');
105+
});
106+
107+
it('rejects trailing slash', async () => {
108+
await expectInvalid('application/');
109+
});
110+
111+
it('rejects spaces inside type/subtype', async () => {
112+
await expectInvalid('application /pdf');
113+
await expectInvalid('application/ pdf');
114+
});
115+
116+
it('rejects multiple slashes', async () => {
117+
await expectInvalid('application/foo/bar');
118+
});
119+
120+
it('rejects non-string values', async () => {
121+
await expectInvalid(123);
122+
await expectInvalid(null);
123+
await expectInvalid(undefined);
124+
});
125+
});
126+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { applyDecorators } from '@nestjs/common';
2+
import { Transform } from 'class-transformer';
3+
import { IsNotEmpty, IsString, Matches } from 'class-validator';
4+
5+
// RFC 6838 restricted-name-chars set (alpha, digit, !, #, $, &, -, ^, _, ., +)
6+
// First char must be alpha/digit.
7+
const MIME_TYPE_REGEX =
8+
/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i;
9+
10+
/**
11+
* Normalize a Content-Type / MIME value to its bare `type/subtype` form:
12+
* strips optional `;parameters` (RFC 7231) and surrounding whitespace,
13+
* and lowercases the result.
14+
*/
15+
export const normalizeMimeType = (value: unknown): unknown => {
16+
if (typeof value !== 'string') return value;
17+
const bare = value.split(';')[0]?.trim().toLowerCase() ?? '';
18+
return bare;
19+
};
20+
21+
/**
22+
* Decorator for DTO fields that accept a file MIME type.
23+
* Normalizes the value (strip parameters, trim, lowercase) before validating
24+
* it against the RFC 6838 restricted-name-chars grammar.
25+
*/
26+
export const IsMimeTypeField = (): PropertyDecorator =>
27+
applyDecorators(
28+
IsString(),
29+
IsNotEmpty(),
30+
Transform(({ value }) => normalizeMimeType(value)),
31+
Matches(MIME_TYPE_REGEX, { message: 'Invalid MIME type format' }),
32+
);

0 commit comments

Comments
 (0)