diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b754c0083b478..9ff5409c5b98a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -508,6 +508,7 @@ src/platform/packages/shared/kbn-i18n @elastic/kibana-core src/platform/packages/shared/kbn-i18n-react @elastic/kibana-core src/platform/packages/shared/kbn-interpreter @elastic/kibana-visualizations src/platform/packages/shared/kbn-io-ts-utils @elastic/obs-knowledge-team +src/platform/packages/shared/kbn-lazy-object @elastic/kibana-operations src/platform/packages/shared/kbn-lens-embeddable-utils @elastic/obs-ux-infra_services-team @elastic/kibana-visualizations src/platform/packages/shared/kbn-licensing-types @elastic/kibana-core src/platform/packages/shared/kbn-logging @elastic/kibana-core @@ -3021,4 +3022,4 @@ x-pack/plugins/observability_solution/synthetics/server/saved_objects/synthetics #### ## These rules are always last so they take ultimate priority over everything else -#### \ No newline at end of file +#### diff --git a/src/platform/plugins/shared/files/server/routes/common.test.ts b/src/platform/plugins/shared/files/server/routes/common.test.ts index 704b3dc49cb43..8b5d0b90dc562 100644 --- a/src/platform/plugins/shared/files/server/routes/common.test.ts +++ b/src/platform/plugins/shared/files/server/routes/common.test.ts @@ -8,43 +8,29 @@ */ import type { File } from '../file'; -import { getDownloadHeadersForFile } from './common'; +import { getFileHttpResponseOptions } from './common'; describe('getDownloadHeadersForFile', () => { - function expectHeaders({ contentType }: { contentType: string }) { + function expectResult({ contentType }: { contentType: string }) { return { - 'content-type': contentType, - 'cache-control': 'max-age=31536000, immutable', + fileContentType: contentType, + headers: { + 'cache-control': 'max-age=31536000, immutable', + }, }; } 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( - expectHeaders({ contentType: 'application/octet-stream' }) + test('no mime type', () => { + expect(getFileHttpResponseOptions(file)).toEqual( + expectResult({ 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( - expectHeaders({ contentType: 'application/pdf' }) + expect(getFileHttpResponseOptions(fileWithMime)).toEqual( + expectResult({ contentType: 'application/pdf' }) ); }); }); diff --git a/src/platform/plugins/shared/files/server/routes/common.ts b/src/platform/plugins/shared/files/server/routes/common.ts index a6188bf061c5d..2729ba3a59f20 100644 --- a/src/platform/plugins/shared/files/server/routes/common.ts +++ b/src/platform/plugins/shared/files/server/routes/common.ts @@ -7,20 +7,16 @@ * 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 { Readable } from 'stream'; +import type { FileHttpResponseOptions } from '@kbn/core-http-server'; import type { File } from '../../common/types'; -interface Args { - file: File; - fileName?: string; -} - -export function getDownloadHeadersForFile({ file, fileName }: Args): ResponseHeaders { +export function getFileHttpResponseOptions( + file: File +): Pick, 'headers' | 'fileContentType'> { return { - 'content-type': - (fileName && mime.getType(fileName)) ?? file.data.mimeType ?? 'application/octet-stream', - 'cache-control': 'max-age=31536000, immutable', + fileContentType: file.data.mimeType ?? 'application/octet-stream', + headers: { 'cache-control': 'max-age=31536000, immutable' }, }; } diff --git a/src/platform/plugins/shared/files/server/routes/file_kind/create.ts b/src/platform/plugins/shared/files/server/routes/file_kind/create.ts index db4904f8fe03e..6fd7abbb4c439 100644 --- a/src/platform/plugins/shared/files/server/routes/file_kind/create.ts +++ b/src/platform/plugins/shared/files/server/routes/file_kind/create.ts @@ -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; @@ -33,25 +34,33 @@ export type Endpoint = CreateRouteDefinition< FilesClient['create'] >; -export const handler: CreateHandler = 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 => + 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) { @@ -67,7 +76,7 @@ export function register(fileKindRouter: FileKindRouter, fileKind: FileKind) { }, }, }, - handler + createHandler(fileKind) ); } } diff --git a/src/platform/plugins/shared/files/server/routes/file_kind/download.ts b/src/platform/plugins/shared/files/server/routes/file_kind/download.ts index 7026c1f8fcf77..5c421b6f84bac 100644 --- a/src/platform/plugins/shared/files/server/routes/file_kind/download.ts +++ b/src/platform/plugins/shared/files/server/routes/file_kind/download.ts @@ -13,8 +13,8 @@ import type { FilesClient } from '../../../common/files_client'; 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 { getFileHttpResponseOptions, getDownloadedFileName } from '../common'; +import { getById, validateFileNameExtension } from './helpers'; import type { CreateHandler, FileKindRouter } from './types'; import type { CreateRouteDefinition } from '../api_routes'; import { FILES_API_ROUTES } from '../api_routes'; @@ -37,14 +37,23 @@ export const handler: CreateHandler = 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(); + const fileHttpResponseOptions = getFileHttpResponseOptions(file); + return res.file({ body, filename: fileName ?? getDownloadedFileName(file), - headers: getDownloadHeadersForFile({ file, fileName }), + ...fileHttpResponseOptions, }); } catch (e) { if (e instanceof fileErrors.NoDownloadAvailableError) { diff --git a/src/platform/plugins/shared/files/server/routes/file_kind/helpers.test.ts b/src/platform/plugins/shared/files/server/routes/file_kind/helpers.test.ts new file mode 100644 index 0000000000000..8b3e5097e320b --- /dev/null +++ b/src/platform/plugins/shared/files/server/routes/file_kind/helpers.test.ts @@ -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('file-name_with.special@chars.txt', 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', + }); + }); + }); +}); diff --git a/src/platform/plugins/shared/files/server/routes/file_kind/helpers.ts b/src/platform/plugins/shared/files/server/routes/file_kind/helpers.ts index 50709792aec7b..bf700d73c0e36 100644 --- a/src/platform/plugins/shared/files/server/routes/file_kind/helpers.ts +++ b/src/platform/plugins/shared/files/server/routes/file_kind/helpers.ts @@ -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'; @@ -23,7 +24,7 @@ type ResultOrHttpError = export async function getById( fileService: FileServiceStart, id: string, - fileKind: string + _fileKind: string ): Promise { let result: undefined | File; try { @@ -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`, + }, + }); + } +} diff --git a/src/platform/plugins/shared/files/server/routes/file_kind/integration_tests/file_kind_http.test.ts b/src/platform/plugins/shared/files/server/routes/file_kind/integration_tests/file_kind_http.test.ts index 7b5793fed5a85..78c1c68ec1351 100644 --- a/src/platform/plugins/shared/files/server/routes/file_kind/integration_tests/file_kind_http.test.ts +++ b/src/platform/plugins/shared/files/server/routes/file_kind/integration_tests/file_kind_http.test.ts @@ -384,4 +384,219 @@ describe('File kind HTTP API', () => { }); } }); + + describe('MIME type validation', () => { + test('should allow files with permitted MIME types', async () => { + // Test PNG (allowed) + const pngResult = await request + .post(root, `/api/files/files/${fileKind}`) + .set('x-elastic-internal-origin', 'files-test') + .send({ + name: 'test.png', + mimeType: 'image/png', + alt: 'test image', + meta: {}, + }) + .expect(200); + + expect(pngResult.body.file.mimeType).toBe('image/png'); + + // Test JPEG (allowed) + const jpegResult = await request + .post(root, `/api/files/files/${fileKind}`) + .set('x-elastic-internal-origin', 'files-test') + .send({ + name: 'test.jpg', + mimeType: 'image/jpeg', + alt: 'test image', + meta: {}, + }) + .expect(200); + + expect(jpegResult.body.file.mimeType).toBe('image/jpeg'); + + // Test PDF (allowed) + const pdfResult = await request + .post(root, `/api/files/files/${fileKind}`) + .set('x-elastic-internal-origin', 'files-test') + .send({ + name: 'document.pdf', + mimeType: 'application/pdf', + alt: 'test document', + meta: {}, + }) + .expect(200); + + expect(pdfResult.body.file.mimeType).toBe('application/pdf'); + }); + + test('should reject files with forbidden MIME types', async () => { + // Test video file rejection (not in allowed list) + const videoResult = await request + .post(root, `/api/files/files/${fileKind}`) + .set('x-elastic-internal-origin', 'files-test') + .send({ + name: 'test.mp4', + mimeType: 'video/mp4', + alt: 'test video', + meta: {}, + }) + .expect(400); + + expect(videoResult.body.message).toBe('File type is not supported'); + + // Test executable file rejection (not in allowed list) + const exeResult = await request + .post(root, `/api/files/files/${fileKind}`) + .set('x-elastic-internal-origin', 'files-test') + .send({ + name: 'malware.exe', + mimeType: 'application/x-msdownload', + alt: 'executable file', + meta: {}, + }) + .expect(400); + + expect(exeResult.body.message).toBe('File type is not supported'); + + // Test XML file rejection (not in allowed list) + const xmlResult = await request + .post(root, `/api/files/files/${fileKind}`) + .set('x-elastic-internal-origin', 'files-test') + .send({ + name: 'config.xml', + mimeType: 'text/xml', + alt: 'xml file', + meta: {}, + }) + .expect(400); + + expect(xmlResult.body.message).toBe('File type is not supported'); + }); + + test('should allow files with no MIME type when restrictions exist', async () => { + // Undefined MIME type should be allowed (validation only applies when MIME type is provided) + const result = await request + .post(root, `/api/files/files/${fileKind}`) + .set('x-elastic-internal-origin', 'files-test') + .send({ + name: 'test', + alt: 'test file', + meta: {}, + }) + .expect(200); + + expect(result.body.file.mimeType).toBeUndefined(); + }); + }); + + describe('file extension validation on download', () => { + test('should reject download with mismatched file extension', async () => { + const { id } = await createFile({ + name: 'image.png', + mimeType: 'image/png', + }); + + await request + .put(root, `/api/files/files/${fileKind}/${id}/blob`) + .set('Content-Type', 'application/octet-stream') + .set('x-elastic-internal-origin', 'files-test') + .send('image data') + .expect(200); + + // Try to download with wrong extension (PDF extension for PNG file) + const result = await request + .get(root, `/api/files/files/${fileKind}/${id}/blob/document.pdf`) + .set('x-elastic-internal-origin', 'files-test') + .expect(400); + + expect(result.body.message).toBe('File extension does not match file type'); + }); + + test('should allow download with matching file extension', async () => { + const { id } = await createFile({ + name: 'image.png', + mimeType: 'image/png', + }); + + await request + .put(root, `/api/files/files/${fileKind}/${id}/blob`) + .set('Content-Type', 'application/octet-stream') + .set('x-elastic-internal-origin', 'files-test') + .send('image data') + .expect(200); + + const result = await request + .get(root, `/api/files/files/${fileKind}/${id}/blob/image.png`) + .set('x-elastic-internal-origin', 'files-test') + .expect(200); + + expect(result.body.toString()).toBe('image data'); + }); + + test('should allow download with no file extension', async () => { + const { id } = await createFile({ + name: 'README', + mimeType: 'text/plain', + }); + + await request + .put(root, `/api/files/files/${fileKind}/${id}/blob`) + .set('Content-Type', 'application/octet-stream') + .set('x-elastic-internal-origin', 'files-test') + .send('readme content') + .expect(200); + + // Download without extension should work (no validation performed) + await request + .get(root, `/api/files/files/${fileKind}/${id}/blob/README`) + .set('x-elastic-internal-origin', 'files-test') + .expect(200); + }); + + test('should handle multiple extensions correctly', async () => { + const { id } = await createFile({ + name: 'archive.tar.gz', + mimeType: 'application/gzip', + }); + + await request + .put(root, `/api/files/files/${fileKind}/${id}/blob`) + .set('Content-Type', 'application/octet-stream') + .set('x-elastic-internal-origin', 'files-test') + .send('compressed data') + .expect(200); + + // Should use the last extension (.gz) for validation + await request + .get(root, `/api/files/files/${fileKind}/${id}/blob/archive.tar.gz`) + .set('x-elastic-internal-origin', 'files-test') + .expect(200); + }); + + test('should be case insensitive for file extensions', async () => { + const { id } = await createFile({ + name: 'image.png', + mimeType: 'image/png', + }); + + await request + .put(root, `/api/files/files/${fileKind}/${id}/blob`) + .set('Content-Type', 'application/octet-stream') + .set('x-elastic-internal-origin', 'files-test') + .send('image data') + .expect(200); + + // Both lowercase and uppercase extensions should work + await request + .get(root, `/api/files/files/${fileKind}/${id}/blob/image.PNG`) + .set('x-elastic-internal-origin', 'files-test') + .expect(200); + + await request + .get(root, `/api/files/files/${fileKind}/${id}/blob/image.Png`) + .set('x-elastic-internal-origin', 'files-test') + .expect(200); + }); + }); }); diff --git a/src/platform/plugins/shared/files/server/routes/integration_tests/routes.test.ts b/src/platform/plugins/shared/files/server/routes/integration_tests/routes.test.ts index 14698762a7370..f33a9cd01d4c8 100644 --- a/src/platform/plugins/shared/files/server/routes/integration_tests/routes.test.ts +++ b/src/platform/plugins/shared/files/server/routes/integration_tests/routes.test.ts @@ -9,9 +9,9 @@ import type { TypeOf } from '@kbn/config-schema'; import type { FileJSON } from '../../../common'; -import type { rt } from '../file_kind/create'; import type { TestEnvironmentUtils } from '../../test_utils'; import { setupIntegrationEnvironment } from '../../test_utils'; +import type { rt } from '../file_kind/create'; describe('File HTTP API', () => { let testHarness: TestEnvironmentUtils; @@ -333,7 +333,10 @@ describe('File HTTP API', () => { }); test('it downloads a publicly shared file', async () => { - const { id } = await createFile(); + const { id } = await createFile({ + name: 'myfilename.pdf', + mimeType: 'application/pdf', + }); const { body: { token }, @@ -357,7 +360,7 @@ describe('File HTTP API', () => { .expect(200); const { body: buffer, header } = await request - // By providing a file name like "myfilename.pdf" we imply that we want a pdf + // "myfilename.pdf" has a mime type that matches the metadata .get(root, `/api/files/public/blob/myfilename.pdf?token=${token}`) .set('x-elastic-internal-origin', 'files-test') .buffer() @@ -367,5 +370,236 @@ describe('File HTTP API', () => { expect(header['content-disposition']).toEqual('attachment; filename=myfilename.pdf'); expect(buffer.toString('utf8')).toEqual('test'); }); + + test('validates file extension in public download', async () => { + const { id } = await createFile({ + name: 'document.pdf', + mimeType: 'application/pdf', + }); + + const { + body: { token }, + } = await request + .post(root, `/api/files/shares/${fileKind}/${id}`) + .set('x-elastic-internal-origin', 'files-test') + .send({}) + .expect(200); + + await request + .put(root, `/api/files/files/${fileKind}/${id}/blob`) + .set('Content-Type', 'application/octet-stream') + .set('x-elastic-internal-origin', 'files-test') + .send('pdf content') + .expect(200); + + // Try public download with wrong extension (txt extension for PDF file) + const result = await request + .get(root, `/api/files/public/blob/document.txt?token=${token}`) + .set('x-elastic-internal-origin', 'files-test') + .expect(400); + + expect(result.body.message).toBe('File extension does not match file type'); + }); + + test('allows public download with matching file extension', async () => { + const { id } = await createFile({ + name: 'image.png', + mimeType: 'image/png', + }); + + const { + body: { token }, + } = await request + .post(root, `/api/files/shares/${fileKind}/${id}`) + .set('x-elastic-internal-origin', 'files-test') + .send({}) + .expect(200); + + await request + .put(root, `/api/files/files/${fileKind}/${id}/blob`) + .set('Content-Type', 'application/octet-stream') + .set('x-elastic-internal-origin', 'files-test') + .send('image data') + .expect(200); + + // Public download with correct extension should work + const { body: buffer, header } = await request + .get(root, `/api/files/public/blob/image.png?token=${token}`) + .set('x-elastic-internal-origin', 'files-test') + .buffer() + .expect(200); + + expect(header['content-type']).toEqual('image/png'); + expect(header['content-disposition']).toEqual('attachment; filename=image.png'); + expect(buffer.toString('utf8')).toEqual('image data'); + }); + + test('allows public download with no file extension', async () => { + const { id } = await createFile({ + name: 'README', + mimeType: 'text/plain', + }); + + const { + body: { token }, + } = await request + .post(root, `/api/files/shares/${fileKind}/${id}`) + .set('x-elastic-internal-origin', 'files-test') + .send({}) + .expect(200); + + await request + .put(root, `/api/files/files/${fileKind}/${id}/blob`) + .set('Content-Type', 'application/octet-stream') + .set('x-elastic-internal-origin', 'files-test') + .send('readme content') + .expect(200); + + // Public download without extension should work (no validation performed) + const response = await request + .get(root, `/api/files/public/blob/README?token=${token}`) + .set('x-elastic-internal-origin', 'files-test') + .buffer() + .expect(200); + + expect(response.header['content-type']).toEqual('text/plain; charset=utf-8'); + expect(response.text).toEqual('readme content'); + }); + + test('handles case insensitive extensions in public download', async () => { + const { id } = await createFile({ + name: 'image.jpg', + mimeType: 'image/jpeg', + }); + + const { + body: { token }, + } = await request + .post(root, `/api/files/shares/${fileKind}/${id}`) + .set('x-elastic-internal-origin', 'files-test') + .send({}) + .expect(200); + + await request + .put(root, `/api/files/files/${fileKind}/${id}/blob`) + .set('Content-Type', 'application/octet-stream') + .set('x-elastic-internal-origin', 'files-test') + .send('jpeg data') + .expect(200); + + // Both JPG and JPEG extensions should work for image/jpeg MIME type + await request + .get(root, `/api/files/public/blob/image.JPG?token=${token}`) + .set('x-elastic-internal-origin', 'files-test') + .expect(200); + + await request + .get(root, `/api/files/public/blob/image.jpeg?token=${token}`) + .set('x-elastic-internal-origin', 'files-test') + .expect(200); + }); + + test('handles Unicode filenames in public download validation', async () => { + const { id } = await createFile({ + name: 'файл.txt', + mimeType: 'text/plain', + }); + + const { + body: { token }, + } = await request + .post(root, `/api/files/shares/${fileKind}/${id}`) + .set('x-elastic-internal-origin', 'files-test') + .send({}) + .expect(200); + + await request + .put(root, `/api/files/files/${fileKind}/${id}/blob`) + .set('Content-Type', 'application/octet-stream') + .set('x-elastic-internal-origin', 'files-test') + .send('text content') + .expect(200); + + // Unicode filename with correct extension should work + const encodedFilename = encodeURIComponent('файл.txt'); + const response = await request + .get(root, `/api/files/public/blob/${encodedFilename}?token=${token}`) + .set('x-elastic-internal-origin', 'files-test') + .buffer() + .expect(200); + + expect(response.header['content-type']).toMatch(/^text\/plain(; charset=utf-8)?$/); + + // For text content with .buffer(), use response.text instead of response.body + expect(response.text).toEqual('text content'); + + // Wrong extension should still be rejected + const encodedWrongFilename = encodeURIComponent('файл.pdf'); + await request + .get(root, `/api/files/public/blob/${encodedWrongFilename}?token=${token}`) + .set('x-elastic-internal-origin', 'files-test') + .expect(400); + }); + + test('does not leak information in public download error messages', async () => { + const { id } = await createFile({ + name: 'secret.json', + mimeType: 'application/json', + }); + + const { + body: { token }, + } = await request + .post(root, `/api/files/shares/${fileKind}/${id}`) + .set('x-elastic-internal-origin', 'files-test') + .send({}) + .expect(200); + + await request + .put(root, `/api/files/files/${fileKind}/${id}/blob`) + .set('Content-Type', 'application/octet-stream') + .set('x-elastic-internal-origin', 'files-test') + .send('{"secret": "data"}') + .expect(200); + + // Try download with wrong extension + const result = await request + .get(root, `/api/files/public/blob/secret.xml?token=${token}`) + .set('x-elastic-internal-origin', 'files-test') + .expect(400); + + // Should not reveal MIME type or expected extensions + expect(result.body.message).toBe('File extension does not match file type'); + expect(result.body.message).not.toContain('json'); + expect(result.body.message).not.toContain('application/json'); + }); + + test('prevents MIME type manipulation through URL filename', async () => { + const { id } = await createFile({ + name: 'safe-document.pdf', + mimeType: 'application/pdf', + }); + + const { + body: { token }, + } = await request + .post(root, `/api/files/shares/${fileKind}/${id}`) + .set('x-elastic-internal-origin', 'files-test') + .send({}) + .expect(200); + + await request + .put(root, `/api/files/files/${fileKind}/${id}/blob`) + .set('Content-Type', 'application/octet-stream') + .set('x-elastic-internal-origin', 'files-test') + .send('PDF content') + .expect(200); + + // Extension validation blocks dangerous mismatched downloads + await request + .get(root, `/api/files/public/blob/malicious-script.html?token=${token}`) + .set('x-elastic-internal-origin', 'files-test') + .expect(400); // Correctly blocked! + }); }); }); diff --git a/src/platform/plugins/shared/files/server/routes/public_facing/download.ts b/src/platform/plugins/shared/files/server/routes/public_facing/download.ts index eb603d99bc64b..ee1fc97536d4b 100644 --- a/src/platform/plugins/shared/files/server/routes/public_facing/download.ts +++ b/src/platform/plugins/shared/files/server/routes/public_facing/download.ts @@ -19,9 +19,10 @@ import { import type { FilesRouter } from '../types'; import type { CreateRouteDefinition } from '../api_routes'; import { FILES_API_ROUTES } from '../api_routes'; -import { getDownloadHeadersForFile, getDownloadedFileName } from '../common'; +import { getFileHttpResponseOptions, getDownloadedFileName } from '../common'; import { fileNameWithExt } from '../common_schemas'; import type { CreateHandler } from '../types'; +import { validateFileNameExtension } from '../file_kind/helpers'; const method = 'get' as const; @@ -45,11 +46,19 @@ const handler: CreateHandler = async ({ files }, req, res) => { try { const file = await fileService.asInternalUser().getByToken(token); + + const invalidExtensionResponse = validateFileNameExtension(fileName, file); + if (invalidExtensionResponse) { + return invalidExtensionResponse; + } + const body: Readable = await file.downloadContent(); + const fileHttpResponseOptions = getFileHttpResponseOptions(file); + return res.file({ body, filename: fileName ?? getDownloadedFileName(file), - headers: getDownloadHeadersForFile({ file, fileName }), + ...fileHttpResponseOptions, }); } catch (e) { if ( diff --git a/src/platform/plugins/shared/files/server/test_utils/setup_integration_environment.ts b/src/platform/plugins/shared/files/server/test_utils/setup_integration_environment.ts index cb2da9bc55c9e..623e8176df840 100644 --- a/src/platform/plugins/shared/files/server/test_utils/setup_integration_environment.ts +++ b/src/platform/plugins/shared/files/server/test_utils/setup_integration_environment.ts @@ -7,12 +7,12 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { defaults } from 'lodash'; import { createRootWithCorePlugins, createTestServers, request, } from '@kbn/core-test-helpers-kbn-server'; +import { defaults } from 'lodash'; import pRetry from 'p-retry'; import type { FileJSON } from '../../common'; import { getFileKindsRegistry } from '../../common/file_kinds_registry'; @@ -95,11 +95,22 @@ export async function setupIntegrationEnvironment() { await root.setup(); /** - * Register a test file type + * Register a test file kind with MIME type restrictions for validation testing */ const testHttpConfig = { requiredPrivileges: ['myapp'] }; const myFileKind = { id: fileKind, + allowedMimeTypes: [ + 'image/png', + 'image/jpeg', + 'application/pdf', + 'text/html', + 'text/plain', + 'application/json', + 'application/gzip', + 'application/octet-stream', + 'image/x:123', // Special characters test case + ], blobStoreSettings: { esFixedSizeIndex: { index: testIndex }, }, diff --git a/src/platform/plugins/shared/files/tsconfig.json b/src/platform/plugins/shared/files/tsconfig.json index 4bda1ca567abe..d5d996e49f5ed 100644 --- a/src/platform/plugins/shared/files/tsconfig.json +++ b/src/platform/plugins/shared/files/tsconfig.json @@ -35,6 +35,7 @@ "@kbn/core-lifecycle-server-internal", "@kbn/cbor", "@kbn/lazy-object", + "@kbn/core-http-server", ], "exclude": [ "target/**/*", diff --git a/x-pack/platform/test/cases_api_integration/common/plugins/cases/server/files/index.ts b/x-pack/platform/test/cases_api_integration/common/plugins/cases/server/files/index.ts index 9bc5fbc9e6c69..8679eb92a889a 100644 --- a/x-pack/platform/test/cases_api_integration/common/plugins/cases/server/files/index.ts +++ b/x-pack/platform/test/cases_api_integration/common/plugins/cases/server/files/index.ts @@ -16,7 +16,7 @@ const buildFileKind = (): FileKind => { return { id: CASES_TEST_FIXTURE_FILE_KIND_ID, http: fileKindHttpTags(), - allowedMimeTypes: ['image/png'], + allowedMimeTypes: ['image/png', 'text/plain'], }; };