Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b63b5fb
Validate mimeType from request body
tsullivan Sep 10, 2025
bbda7be
Add mime-type validation on download
tsullivan Sep 10, 2025
581f027
Tests and hardening
tsullivan Sep 11, 2025
c62d7f1
update integration tests
tsullivan Sep 11, 2025
3f9cae2
Prevent mime type manipulation from client-sent filename
tsullivan Sep 12, 2025
187abbd
Update tests
tsullivan Sep 12, 2025
5fd0887
cleanup
tsullivan Sep 13, 2025
eff63bc
Merge branch 'main' into fix/team-2022
tsullivan Sep 15, 2025
8b94103
simplify getDownloadHeadersForFile
tsullivan Sep 15, 2025
c86fe0c
Fix cases api integration tests
tsullivan Sep 15, 2025
52e532c
Merge branch 'main' into fix/team-2022
tsullivan Sep 15, 2025
3fa9324
Minor cleanup
tsullivan Sep 15, 2025
fcb65f8
Merge branch 'main' into fix/team-2022
tsullivan Sep 17, 2025
cc3311b
Merge branch 'main' into fix/team-2022
tsullivan Sep 18, 2025
e581a91
Fix the method of setting res.file with explicit content-type
tsullivan Sep 18, 2025
5e5cf5e
[CI] Auto-commit changed files from 'node scripts/yarn_deduplicate'
kibanamachine Sep 18, 2025
9b571fc
Merge branch 'main' into fix/team-2022
tsullivan Sep 19, 2025
944f678
Fix plain test for plain text with no extension
tsullivan Sep 19, 2025
9d432d0
Merge branch 'fix/team-2022' of github.com:tsullivan/kibana into fix/…
tsullivan Sep 19, 2025
b8f3337
Merge branch 'main' into fix/team-2022
tsullivan Sep 22, 2025
92603fe
Merge branch 'main' into fix/team-2022
tsullivan Sep 23, 2025
eb4183b
Merge branch 'main' into fix/team-2022
tsullivan Sep 23, 2025
ed7d95e
[CI] Auto-commit changed files from 'node scripts/lint_ts_projects --…
kibanamachine Sep 23, 2025
d1e4245
[CI] Auto-commit changed files from 'node scripts/styled_components_m…
kibanamachine Sep 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 4 additions & 20 deletions src/platform/plugins/shared/files/server/routes/common.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,31 +19,15 @@ describe('getDownloadHeadersForFile', () => {
}

const file = { data: { name: 'test', mimeType: undefined } } as unknown as File;
test('no mime type and name from file object', () => {
expect(getDownloadHeadersForFile({ file, fileName: undefined })).toEqual(
test('no mime type', () => {
expect(getDownloadHeadersForFile(file)).toEqual(
expectHeaders({ contentType: 'application/octet-stream' })
);
});

test('no mime type and name (without ext)', () => {
expect(getDownloadHeadersForFile({ file, fileName: 'myfile' })).toEqual(
expectHeaders({ contentType: 'application/octet-stream' })
);
});
test('no mime type and name (with ext)', () => {
expect(getDownloadHeadersForFile({ file, fileName: 'myfile.png' })).toEqual(
expectHeaders({ contentType: 'image/png' })
);
});
test('mime type and no name', () => {
const fileWithMime = { data: { ...file.data, mimeType: 'application/pdf' } } as File;
expect(getDownloadHeadersForFile({ file: fileWithMime, fileName: undefined })).toEqual(
expectHeaders({ contentType: 'application/pdf' })
);
});
test('mime type and name', () => {
test('mime type', () => {
const fileWithMime = { data: { ...file.data, mimeType: 'application/pdf' } } as File;
expect(getDownloadHeadersForFile({ file: fileWithMime, fileName: 'a cool file.pdf' })).toEqual(
expect(getDownloadHeadersForFile(fileWithMime)).toEqual(
expectHeaders({ contentType: 'application/pdf' })
);
});
Expand Down
11 changes: 2 additions & 9 deletions src/platform/plugins/shared/files/server/routes/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,12 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import mime from 'mime';
import type { ResponseHeaders } from '@kbn/core/server';
import type { File } from '../../common/types';

interface Args {
file: File;
fileName?: string;
}

export function getDownloadHeadersForFile({ file, fileName }: Args): ResponseHeaders {
export function getDownloadHeadersForFile(file: File): ResponseHeaders {
return {
'content-type':
(fileName && mime.getType(fileName)) ?? file.data.mimeType ?? 'application/octet-stream',
'content-type': file.data.mimeType ?? 'application/octet-stream',
'cache-control': 'max-age=31536000, immutable',
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { CreateRouteDefinition } from '../api_routes';
import { FILES_API_ROUTES } from '../api_routes';
import type { FileKindRouter } from './types';
import * as commonSchemas from '../common_schemas';
import { validateMimeType } from './helpers';
import type { CreateHandler } from './types';

export const method = 'post' as const;
Expand All @@ -33,25 +34,33 @@ export type Endpoint<M = unknown> = CreateRouteDefinition<
FilesClient['create']
>;

export const handler: CreateHandler<Endpoint> = async ({ core, fileKind, files }, req, res) => {
const [{ security }, { fileService }] = await Promise.all([core, files]);
const {
body: { name, alt, meta, mimeType },
} = req;
const user = security.authc.getCurrentUser();
const file = await fileService.asCurrentUser().create({
fileKind,
name,
alt,
meta,
user: user ? { name: user.username, id: user.profile_uid } : undefined,
mime: mimeType,
});
const body: Endpoint['output'] = {
file: file.toJSON(),
const createHandler =
(fileKindDefinition: FileKind): CreateHandler<Endpoint> =>
async ({ core, fileKind, files }, req, res) => {
const [{ security }, { fileService }] = await Promise.all([core, files]);
const {
body: { name, alt, meta, mimeType },
} = req;
const user = security.authc.getCurrentUser();

const invalidResponse = validateMimeType(mimeType, fileKindDefinition);
if (invalidResponse) {
return invalidResponse;
}

const file = await fileService.asCurrentUser().create({
fileKind,
name,
alt,
meta,
user: user ? { name: user.username, id: user.profile_uid } : undefined,
mime: mimeType,
});
const body: Endpoint['output'] = {
file: file.toJSON(),
};
return res.ok({ body });
};
return res.ok({ body });
};

export function register(fileKindRouter: FileKindRouter, fileKind: FileKind) {
if (fileKind.http.create) {
Expand All @@ -67,7 +76,7 @@ export function register(fileKindRouter: FileKindRouter, fileKind: FileKind) {
},
},
},
handler
createHandler(fileKind)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type { FileKind } from '../../../common/types';
import { fileNameWithExt } from '../common_schemas';
import { fileErrors } from '../../file';
import { getDownloadHeadersForFile, getDownloadedFileName } from '../common';
import { getById } from './helpers';
import { getById, validateFileNameExtension } from './helpers';
import type { CreateHandler, FileKindRouter } from './types';
import type { CreateRouteDefinition } from '../api_routes';
import { FILES_API_ROUTES } from '../api_routes';
Expand All @@ -37,14 +37,21 @@ export const handler: CreateHandler<Endpoint> = async ({ files, fileKind }, req,
const {
params: { id, fileName },
} = req;

const { error, result: file } = await getById(fileService.asCurrentUser(), id, fileKind);
if (error) return error;

try {
const invalidExtensionResponse = validateFileNameExtension(fileName, file);
if (invalidExtensionResponse) {
return invalidExtensionResponse;
}

const body: Response = await file.downloadContent();
return res.file({
body,
filename: fileName ?? getDownloadedFileName(file),
headers: getDownloadHeadersForFile({ file, fileName }),
headers: getDownloadHeadersForFile(file),
});
} catch (e) {
if (e instanceof fileErrors.NoDownloadAvailableError) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import type { IKibanaResponse } from '@kbn/core/server';
import type { File, FileJSON, FileKind } from '../../../common';
import { validateFileNameExtension, validateMimeType } from './helpers';

describe('helpers', () => {
describe('validateMimeType', () => {
const createFileKind = (allowedMimeTypes?: string[]): FileKind => ({
id: 'test-file-kind',
allowedMimeTypes,
http: {
create: { requiredPrivileges: [] },
download: { requiredPrivileges: [] },
},
});

it('should return undefined when fileKind has empty allowedMimeTypes array', () => {
const fileKind = createFileKind([]);
const result = validateMimeType('image/png', fileKind);
expect(result).toBeUndefined();
});

it('should return undefined when mimeType is in allowedMimeTypes', () => {
const fileKind = createFileKind(['image/png', 'image/jpeg']);
const result = validateMimeType('image/png', fileKind);
expect(result).toBeUndefined();
});

it('should return bad request response when mimeType is not in allowedMimeTypes', () => {
const fileKind = createFileKind(['image/png', 'image/jpeg']);
const result = validateMimeType('application/pdf', fileKind);

expect(result).toBeDefined();
expect((result as IKibanaResponse).status).toBe(400);
expect((result as IKibanaResponse).payload).toEqual({
message: 'File type is not supported',
});
});

it('should be case sensitive for mime type validation', () => {
const fileKind = createFileKind(['image/png']);
const result = validateMimeType('Image/PNG', fileKind);

expect(result).toBeDefined();
expect((result as IKibanaResponse).status).toBe(400);
});
});

describe('validateFileNameExtension', () => {
const createFile = (mimeType?: string) =>
({
id: 'test-file',
data: {
id: 'test-file',
name: 'test-file',
mimeType,
extension: 'txt',
fileKind: 'test',
} as FileJSON,
} as File);

it('should return undefined when fileName is undefined', () => {
const file = createFile('image/png');
const result = validateFileNameExtension(undefined, file);
expect(result).toBeUndefined();
});

it('should return undefined when file is undefined', () => {
const result = validateFileNameExtension('test.png', undefined);
expect(result).toBeUndefined();
});

it('should return undefined when file has no mimeType', () => {
const file = createFile();
const result = validateFileNameExtension('test.png', file);
expect(result).toBeUndefined();
});

it('should return undefined when fileName has no extension', () => {
const file = createFile('text/plain');
const result = validateFileNameExtension('README', file);
expect(result).toBeUndefined();
});

it('should return undefined when extension matches expected extension', () => {
const file = createFile('image/png');
const result = validateFileNameExtension('image.png', file);
expect(result).toBeUndefined();
});

it('should handle mime types with no known extensions', () => {
const file = createFile('application/x-custom-type');
const result = validateFileNameExtension('file.custom', file);

// Should return undefined since there are no expected extensions for this mime type
expect(result).toBeUndefined();
});

it('should handle file names with special characters', () => {
const file = createFile('text/plain');

expect(validateFileNameExtension('[email protected]', file)).toBeUndefined();
expect(validateFileNameExtension('файл.txt', file)).toBeUndefined(); // Unicode filename
expect(validateFileNameExtension('file with spaces.txt', file)).toBeUndefined();
});

it('should trim whitespace from mime type before validation', () => {
const file = createFile(' text/plain ');
const result = validateFileNameExtension('test.txt', file);
expect(result).toBeUndefined();
});

it('should be case insensitive for file extensions', () => {
const file = createFile('image/png');

expect(validateFileNameExtension('image.PNG', file)).toBeUndefined();
expect(validateFileNameExtension('image.Png', file)).toBeUndefined();
expect(validateFileNameExtension('image.pNG', file)).toBeUndefined();
});

it('should return bad request when extension does not match mime type', () => {
const file = createFile('image/png');
const result = validateFileNameExtension('document.pdf', file);

expect(result).toBeDefined();
expect((result as IKibanaResponse).status).toBe(400);
expect((result as IKibanaResponse).payload).toEqual({
message: 'File extension does not match file type',
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@

import type { IKibanaResponse } from '@kbn/core/server';
import { kibanaResponseFactory } from '@kbn/core/server';
import type { File } from '../../../common';
import mimeTypes from 'mime-types';
import type { File, FileKind } from '../../../common';
import type { FileServiceStart } from '../../file_service';
import { errors } from '../../file_service';

Expand All @@ -23,7 +24,7 @@ type ResultOrHttpError =
export async function getById(
fileService: FileServiceStart,
id: string,
fileKind: string
_fileKind: string
): Promise<ResultOrHttpError> {
let result: undefined | File;
try {
Expand All @@ -40,3 +41,72 @@ export async function getById(

return { result };
}

/**
* Validate file kind restrictions on a provided MIME type
* @param mimeType The MIME type to validate
* @param fileKind The file kind definition that may contain restrictions
* @returns `undefined` if the MIME type is valid or there are no restrictions.
*/
export function validateMimeType(
mimeType: string | undefined,
fileKind: FileKind | undefined
): undefined | IKibanaResponse {
if (!mimeType || !fileKind) {
return;
}

const allowedMimeTypes = fileKind.allowedMimeTypes;
if (!allowedMimeTypes || allowedMimeTypes.length === 0) {
return;
}

if (!allowedMimeTypes.includes(mimeType)) {
return kibanaResponseFactory.badRequest({
body: {
message: `File type is not supported`,
},
});
}
}

/**
* Validate file name extension matches the file's MIME type
* @param fileName The file name to validate
* @param file
* @returns `undefined` if the extension matches the MIME type or if no MIME type is provided.
*/
export function validateFileNameExtension(
fileName: string | undefined,
file: File | undefined
): undefined | IKibanaResponse {
if (!fileName || !file || !file.data.mimeType) {
return;
}

const fileMimeType = file.data.mimeType.trim();
if (!fileMimeType) {
return;
}

// Extract file extension (handle cases with multiple dots)
const lastDotIndex = fileName.lastIndexOf('.');
if (lastDotIndex === -1) {
// No extension found - this might be intentional for some file types
return;
}

const fileExtension = fileName.substring(lastDotIndex + 1).toLowerCase();
if (!fileExtension) {
return;
}

const expectedExtensions = mimeTypes.extensions[fileMimeType];
if (expectedExtensions && !expectedExtensions.includes(fileExtension)) {
return kibanaResponseFactory.badRequest({
body: {
message: `File extension does not match file type`,
},
});
}
}
Loading