From a3c3a999c08a3bc4b5bcfe3e8781e6b9cf60d28a Mon Sep 17 00:00:00 2001 From: Jesus Urrutia Date: Tue, 1 Oct 2024 04:27:12 -0300 Subject: [PATCH] @tus/server: add Content-Type and Content-Disposition headers on GetHandler.send response (#655) --- .changeset/little-balloons-sort.md | 5 + packages/server/src/handlers/GetHandler.ts | 106 ++++++++++++++++++++- packages/server/test/GetHandler.test.ts | 97 +++++++++++++++++++ 3 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 .changeset/little-balloons-sort.md diff --git a/.changeset/little-balloons-sort.md b/.changeset/little-balloons-sort.md new file mode 100644 index 00000000..869c3322 --- /dev/null +++ b/.changeset/little-balloons-sort.md @@ -0,0 +1,5 @@ +--- +"@tus/server": minor +--- + +add Content-Type and Content-Disposition headers on GetHandler.send response diff --git a/packages/server/src/handlers/GetHandler.ts b/packages/server/src/handlers/GetHandler.ts index 1dbb92a3..e9ee3e7d 100644 --- a/packages/server/src/handlers/GetHandler.ts +++ b/packages/server/src/handlers/GetHandler.ts @@ -1,7 +1,7 @@ import stream from 'node:stream' import {BaseHandler} from './BaseHandler' -import {ERRORS} from '@tus/utils' +import {ERRORS, Upload} from '@tus/utils' import type http from 'node:http' import type {RouteHandler} from '../types' @@ -9,6 +9,49 @@ import type {RouteHandler} from '../types' export class GetHandler extends BaseHandler { paths: Map = new Map() + /** + * reMimeType is a RegExp for check mime-type form compliance with RFC1341 + * for support mime-type and extra parameters, for example: + * + * ``` + * text/plain; charset=utf-8 + * ``` + * + * See: https://datatracker.ietf.org/doc/html/rfc1341 (Page 6) + */ + reMimeType = + /^(?:application|audio|example|font|haptics|image|message|model|multipart|text|video|x-(?:[0-9A-Za-z!#$%&'*+.^_`|~-]+))\/([0-9A-Za-z!#$%&'*+.^_`|~-]+)((?:[ ]*;[ ]*[0-9A-Za-z!#$%&'*+.^_`|~-]+=(?:[0-9A-Za-z!#$%&'*+.^_`|~-]+|"(?:[^"\\]|\.)*"))*)$/ + + /** + * mimeInlineBrowserWhitelist is a set containing MIME types which should be + * allowed to be rendered by browser inline, instead of being forced to be + * downloaded. For example, HTML or SVG files are not allowed, since they may + * contain malicious JavaScript. In a similar fashion PDF is not on this list + * as their parsers commonly contain vulnerabilities which can be exploited. + */ + mimeInlineBrowserWhitelist = new Set([ + 'text/plain', + + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/bmp', + 'image/webp', + + 'audio/wave', + 'audio/wav', + 'audio/x-wav', + 'audio/x-pn-wav', + 'audio/webm', + 'audio/ogg', + + 'video/mp4', + 'video/webm', + 'video/ogg', + + 'application/ogg', + ]) + registerPath(path: string, handler: RouteHandler): void { this.paths.set(path, handler) } @@ -45,12 +88,71 @@ export class GetHandler extends BaseHandler { throw ERRORS.FILE_NOT_FOUND } + const {contentType, contentDisposition} = this.filterContentType(stats) + // @ts-expect-error exists if supported const file_stream = await this.store.read(id) - const headers = {'Content-Length': stats.offset} + const headers = { + 'Content-Length': stats.offset, + 'Content-Type': contentType, + 'Content-Disposition': contentDisposition, + } res.writeHead(200, headers) return stream.pipeline(file_stream, res, () => { // We have no need to handle streaming errors }) } + + /** + * filterContentType returns the values for the Content-Type and + * Content-Disposition headers for a given upload. These values should be used + * in responses for GET requests to ensure that only non-malicious file types + * are shown directly in the browser. It will extract the file name and type + * from the "filename" and "filetype". + * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition + */ + filterContentType(stats: Upload): { + contentType: string + contentDisposition: string + } { + let contentType: string + let contentDisposition: string + + const {filetype, filename} = stats.metadata ?? {} + + if (filetype && this.reMimeType.test(filetype)) { + // If the filetype from metadata is well formed, we forward use this + // for the Content-Type header. However, only whitelisted mime types + // will be allowed to be shown inline in the browser + contentType = filetype + + if (this.mimeInlineBrowserWhitelist.has(filetype)) { + contentDisposition = 'inline' + } else { + contentDisposition = 'attachment' + } + } else { + // If the filetype from the metadata is not well formed, we use a + // default type and force the browser to download the content + contentType = 'application/octet-stream' + contentDisposition = 'attachment' + } + + // Add a filename to Content-Disposition if one is available in the metadata + if (filename) { + contentDisposition += `; filename=${this.quote(filename)}` + } + + return { + contentType, + contentDisposition, + } + } + + /** + * Convert string to quoted string literals + */ + quote(value: string) { + return `"${value.replace(/"/g, '\\"')}"` + } } diff --git a/packages/server/test/GetHandler.test.ts b/packages/server/test/GetHandler.test.ts index 97ca4d27..88282e16 100644 --- a/packages/server/test/GetHandler.test.ts +++ b/packages/server/test/GetHandler.test.ts @@ -108,11 +108,108 @@ describe('GetHandler', () => { assert.equal(res.statusCode, 200) // TODO: this is the get handler but Content-Length is only send in 204 OPTIONS requests? // assert.equal(res.getHeader('Content-Length'), size) + + assert.equal(res.getHeader('Content-Type'), 'application/octet-stream') + assert.equal(res.getHeader('Content-Disposition'), 'attachment') + assert.equal(store.getUpload.calledOnceWith(fileId), true) assert.equal(store.read.calledOnceWith(fileId), true) }) }) + describe('filterContentType', () => { + it('should return default headers value without metadata', () => { + const fakeStore = sinon.stub(new DataStore()) + const handler = new GetHandler(fakeStore, serverOptions) + const size = 512 + const upload = new Upload({id: '1234', offset: size, size}) + + const res = handler.filterContentType(upload) + + assert.deepEqual(res, { + contentType: 'application/octet-stream', + contentDisposition: 'attachment', + }) + }) + + it('should return headers allow render in browser when filetype is in whitelist', () => { + const fakeStore = sinon.stub(new DataStore()) + const handler = new GetHandler(fakeStore, serverOptions) + const size = 512 + const upload = new Upload({ + id: '1234', + offset: size, + size, + metadata: {filetype: 'image/png', filename: 'pet.png'}, + }) + + const res = handler.filterContentType(upload) + + assert.deepEqual(res, { + contentType: 'image/png', + contentDisposition: 'inline; filename="pet.png"', + }) + }) + + it('should return headers force download when filetype is not in whitelist', () => { + const fakeStore = sinon.stub(new DataStore()) + const handler = new GetHandler(fakeStore, serverOptions) + const size = 512 + const upload = new Upload({ + id: '1234', + offset: size, + size, + metadata: {filetype: 'application/zip', filename: 'pets.zip'}, + }) + + const res = handler.filterContentType(upload) + + assert.deepEqual(res, { + contentType: 'application/zip', + contentDisposition: 'attachment; filename="pets.zip"', + }) + }) + + it('should return headers when filetype is not a valid form', () => { + const fakeStore = sinon.stub(new DataStore()) + const handler = new GetHandler(fakeStore, serverOptions) + const size = 512 + const upload = new Upload({ + id: '1234', + offset: size, + size, + metadata: {filetype: 'image_png', filename: 'pet.png'}, + }) + + const res = handler.filterContentType(upload) + + assert.deepEqual(res, { + contentType: 'application/octet-stream', + contentDisposition: 'attachment; filename="pet.png"', + }) + }) + }) + + describe('quote', () => { + it('should return simple quoted string', () => { + const fakeStore = sinon.stub(new DataStore()) + const handler = new GetHandler(fakeStore, serverOptions) + + const res = handler.quote('pet.png') + + assert.equal(res, '"pet.png"') + }) + + it('should return quoted string when include quotes', () => { + const fakeStore = sinon.stub(new DataStore()) + const handler = new GetHandler(fakeStore, serverOptions) + + const res = handler.quote('"pet.png"') + + assert.equal(res, '"\\"pet.png\\""') + }) + }) + describe('registerPath()', () => { it('should call registered path handler', async () => { const fakeStore = sinon.stub(new DataStore())