diff --git a/packages/s3-store/index.ts b/packages/s3-store/index.ts index e81c5dd7..a9e1e705 100644 --- a/packages/s3-store/index.ts +++ b/packages/s3-store/index.ts @@ -558,7 +558,7 @@ export class S3Store extends DataStore { file.size = upload_length - this.saveMetadata(file, uploadId) + return this.saveMetadata(file, uploadId) } public async remove(id: string): Promise { diff --git a/packages/server/src/constants.ts b/packages/server/src/constants.ts index 1048b489..f80f4495 100644 --- a/packages/server/src/constants.ts +++ b/packages/server/src/constants.ts @@ -49,6 +49,14 @@ export const ERRORS = { status_code: 410, body: 'The file for this url no longer exists\n', }, + ERR_SIZE_EXCEEDED: { + status_code: 413, + body: "upload's size exceeded\n", + }, + ERR_MAX_SIZE_EXCEEDED: { + status_code: 413, + body: 'Maximum size exceeded\n', + }, INVALID_LENGTH: { status_code: 400, body: 'Upload-Length or Upload-Defer-Length header required\n', diff --git a/packages/server/src/handlers/BaseHandler.ts b/packages/server/src/handlers/BaseHandler.ts index e04ce0ac..9818a112 100644 --- a/packages/server/src/handlers/BaseHandler.ts +++ b/packages/server/src/handlers/BaseHandler.ts @@ -3,6 +3,8 @@ import EventEmitter from 'node:events' import type {ServerOptions} from '../types' import type {DataStore} from '../models' import type http from 'node:http' +import {Upload} from '../models' +import {ERRORS} from '../constants' const reExtractFileID = /([^/]+)\/?$/ const reForwardedHost = /host="?([^";]+)/ @@ -78,4 +80,53 @@ export class BaseHandler extends EventEmitter { return decodeURIComponent(match[1]) } + + getConfiguredMaxSize(req: http.IncomingMessage, id: string) { + if (typeof this.options.maxSize === 'function') { + return this.options.maxSize(req, id) + } + return this.options.maxSize ?? 0 + } + + async getBodyMaxSize( + req: http.IncomingMessage, + info: Upload, + configuredMaxSize?: number + ) { + configuredMaxSize = + configuredMaxSize ?? (await this.getConfiguredMaxSize(req, info.id)) + + const length = parseInt(req.headers['content-length'] || '0', 10) + const offset = info.offset + + // Test if this upload fits into the file's size + if (!info.sizeIsDeferred && offset + length > (info.size || 0)) { + throw ERRORS.ERR_SIZE_EXCEEDED + } + + let maxSize = (info.size || 0) - offset + // If the upload's length is deferred and the PATCH request does not contain the Content-Length + // header (which is allowed if 'Transfer-Encoding: chunked' is used), we still need to set limits for + // the body size. + if (info.sizeIsDeferred) { + if (configuredMaxSize > 0) { + // Ensure that the upload does not exceed the maximum upload size + maxSize = configuredMaxSize - offset + } else { + // If no upload limit is given, we allow arbitrary sizes + maxSize = Number.MAX_SAFE_INTEGER + } + } + + if (length > 0) { + maxSize = length + } + + // limit the request body to the maxSize if provided + if (configuredMaxSize > 0 && maxSize > configuredMaxSize) { + maxSize = configuredMaxSize + } + + return maxSize + } } diff --git a/packages/server/src/handlers/OptionsHandler.ts b/packages/server/src/handlers/OptionsHandler.ts index 8b2b5087..03f3ad2b 100644 --- a/packages/server/src/handlers/OptionsHandler.ts +++ b/packages/server/src/handlers/OptionsHandler.ts @@ -6,7 +6,13 @@ import type http from 'node:http' // A successful response indicated by the 204 No Content status MUST contain // the Tus-Version header. It MAY include the Tus-Extension and Tus-Max-Size headers. export class OptionsHandler extends BaseHandler { - async send(_: http.IncomingMessage, res: http.ServerResponse) { + async send(req: http.IncomingMessage, res: http.ServerResponse) { + const maxSize = await this.getConfiguredMaxSize(req, '') + + if (maxSize > 0) { + res.setHeader('Tus-Max-Size', maxSize) + } + res.setHeader('Access-Control-Allow-Methods', ALLOWED_METHODS) res.setHeader('Access-Control-Allow-Headers', ALLOWED_HEADERS) res.setHeader('Access-Control-Max-Age', MAX_AGE) diff --git a/packages/server/src/handlers/PatchHandler.ts b/packages/server/src/handlers/PatchHandler.ts index c01624bb..e5f0990e 100644 --- a/packages/server/src/handlers/PatchHandler.ts +++ b/packages/server/src/handlers/PatchHandler.ts @@ -4,6 +4,8 @@ import {BaseHandler} from './BaseHandler' import {ERRORS, EVENTS} from '../constants' import type http from 'node:http' +import stream from 'node:stream/promises' +import {StreamLimiter} from '../models/StreamLimiter' const log = debug('tus-node-server:handlers:patch') @@ -55,6 +57,8 @@ export class PatchHandler extends BaseHandler { throw ERRORS.INVALID_OFFSET } + const maxFileSize = await this.getConfiguredMaxSize(req, id) + // The request MUST validate upload-length related headers const upload_length = req.headers['upload-length'] as string | undefined if (upload_length !== undefined) { @@ -73,11 +77,21 @@ export class PatchHandler extends BaseHandler { throw ERRORS.INVALID_LENGTH } + if (maxFileSize > 0 && size > maxFileSize) { + throw ERRORS.ERR_MAX_SIZE_EXCEEDED + } + await this.store.declareUploadLength(id, size) upload.size = size } - const newOffset = await this.store.write(req, id, offset) + let newOffset = 0 + const bodyMaxSize = await this.getBodyMaxSize(req, upload, maxFileSize) + + await stream.pipeline(req, new StreamLimiter(bodyMaxSize), async (stream) => { + newOffset = await this.store.write(stream as StreamLimiter, id, offset) + }) + upload.offset = newOffset this.emit(EVENTS.POST_RECEIVE, req, res, upload) if (newOffset === upload.size && this.options.onUploadFinish) { diff --git a/packages/server/src/handlers/PostHandler.ts b/packages/server/src/handlers/PostHandler.ts index 49041c09..512bee52 100644 --- a/packages/server/src/handlers/PostHandler.ts +++ b/packages/server/src/handlers/PostHandler.ts @@ -8,6 +8,8 @@ import {EVENTS, ERRORS} from '../constants' import type http from 'node:http' import type {ServerOptions} from '../types' import type {DataStore} from '../models' +import stream from 'node:stream/promises' +import {StreamLimiter} from '../models/StreamLimiter' const log = debug('tus-node-server:handlers:post') @@ -75,6 +77,11 @@ export class PostHandler extends BaseHandler { metadata, }) + const maxFileSize = await this.getConfiguredMaxSize(req, id) + if (maxFileSize > 0 && (upload.size || 0) > maxFileSize) { + throw ERRORS.ERR_MAX_SIZE_EXCEEDED + } + if (this.options.onUploadCreate) { try { res = await this.options.onUploadCreate(req, res, upload) @@ -89,7 +96,7 @@ export class PostHandler extends BaseHandler { this.emit(EVENTS.POST_CREATE, req, res, upload, url) - let newOffset + let newOffset = 0 let isFinal = upload.size === 0 && !upload.sizeIsDeferred const headers: { 'Upload-Offset'?: string @@ -98,7 +105,11 @@ export class PostHandler extends BaseHandler { // The request MIGHT include a Content-Type header when using creation-with-upload extension if (validateHeader('content-type', req.headers['content-type'])) { - newOffset = await this.store.write(req, upload.id, 0) + const bodyMaxSize = await this.getBodyMaxSize(req, upload, maxFileSize) + await stream.pipeline(req, new StreamLimiter(bodyMaxSize), async (stream) => { + newOffset = await this.store.write(stream as StreamLimiter, upload.id, 0) + }) + headers['Upload-Offset'] = newOffset.toString() isFinal = newOffset === Number.parseInt(upload_length as string, 10) upload.offset = newOffset diff --git a/packages/server/src/models/DataStore.ts b/packages/server/src/models/DataStore.ts index c33c51ca..f6d05259 100644 --- a/packages/server/src/models/DataStore.ts +++ b/packages/server/src/models/DataStore.ts @@ -4,6 +4,7 @@ import EventEmitter from 'node:events' import {Upload} from './Upload' import type stream from 'node:stream' +import type streamP from 'node:stream/promises' import type http from 'node:http' export class DataStore extends EventEmitter { diff --git a/packages/server/src/models/StreamLimiter.ts b/packages/server/src/models/StreamLimiter.ts new file mode 100644 index 00000000..6dc6c124 --- /dev/null +++ b/packages/server/src/models/StreamLimiter.ts @@ -0,0 +1,34 @@ +import {Transform, TransformCallback} from 'stream' +import {ERRORS} from '../constants' + +export class MaxFileExceededError extends Error { + status_code: number + body: string + + constructor() { + super(ERRORS.ERR_MAX_SIZE_EXCEEDED.body) + this.status_code = ERRORS.ERR_MAX_SIZE_EXCEEDED.status_code + this.body = ERRORS.ERR_MAX_SIZE_EXCEEDED.body + Object.setPrototypeOf(this, MaxFileExceededError.prototype) + } +} + +export class StreamLimiter extends Transform { + private maxSize: number + private currentSize = 0 + + constructor(maxSize: number) { + super() + this.maxSize = maxSize + } + + _transform(chunk: Buffer, encoding: BufferEncoding, callback: TransformCallback): void { + this.currentSize += chunk.length + if (this.currentSize > this.maxSize) { + callback(new MaxFileExceededError()) + } else { + this.push(chunk) + callback() + } + } +} diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 5640b179..169702b6 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -150,7 +150,13 @@ export class Server extends EventEmitter { const onError = (error: {status_code?: number; body?: string; message: string}) => { const status_code = error.status_code || ERRORS.UNKNOWN_ERROR.status_code const body = error.body || `${ERRORS.UNKNOWN_ERROR.body}${error.message || ''}\n` - return this.write(res, status_code, body) + const writtenResp = this.write(res, status_code, body) + + // maxFile Exceeded + if (error.status_code === 413) { + req.destroy() + } + return writtenResp } try { diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index ebdf9297..4e435de3 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -5,6 +5,10 @@ import type {Upload} from './models' export type ServerOptions = { // The route to accept requests. path: string + // Number of + maxSize?: + | number + | ((req: http.IncomingMessage, uploadId: string) => Promise | number) // Return a relative URL as the `Location` header. relativeLocation?: boolean // Allow `Forwarded`, `X-Forwarded-Proto`, and `X-Forwarded-Host` headers diff --git a/packages/server/test/PatchHandler.test.ts b/packages/server/test/PatchHandler.test.ts index 79ac2a92..89fc3509 100644 --- a/packages/server/test/PatchHandler.test.ts +++ b/packages/server/test/PatchHandler.test.ts @@ -9,6 +9,9 @@ import httpMocks from 'node-mocks-http' import {PatchHandler} from '../src/handlers/PatchHandler' import {Upload, DataStore} from '../src/models' import {EVENTS} from '../src/constants' +import {MockIncomingMessage} from './utils' +import streamP from 'node:stream/promises' +import stream from 'node:stream' describe('PatchHandler', () => { const path = '/test/output' @@ -20,7 +23,10 @@ describe('PatchHandler', () => { beforeEach(() => { store = sinon.createStubInstance(DataStore) handler = new PatchHandler(store, {path}) - req = {method: 'PATCH', url: `${path}/1234`} as http.IncomingMessage + req = new MockIncomingMessage({ + method: 'PATCH', + url: `${path}/1234`, + }) as unknown as http.IncomingMessage res = httpMocks.createResponse({req}) }) @@ -165,4 +171,58 @@ describe('PatchHandler', () => { assert.ok(spy.args[0][1]) assert.equal(spy.args[0][2].offset, 10) }) + + it('should throw max size exceeded error when upload-length is higher then the maxSize', async () => { + handler = new PatchHandler(store, {path, maxSize: 5}) + req.headers = { + 'upload-offset': '0', + 'upload-length': '10', + 'content-type': 'application/offset+octet-stream', + } + req.url = `${path}/file` + + store.hasExtension.withArgs('creation-defer-length').returns(true) + store.getUpload.resolves(new Upload({id: '1234', offset: 0})) + store.write.resolves(5) + store.declareUploadLength.resolves() + + try { + await handler.send(req, res) + throw new Error('failed test') + } catch (e) { + assert.equal('body' in e, true) + assert.equal('status_code' in e, true) + assert.equal(e.body, 'Maximum size exceeded\n') + assert.equal(e.status_code, 413) + } + }) + + it('should throw max size exceeded error when the request body is bigger then the maxSize', async () => { + handler = new PatchHandler(store, {path, maxSize: 5}) + req.headers = { + 'upload-offset': '0', + 'content-type': 'application/offset+octet-stream', + } + req.url = `${path}/file` + ;(req as unknown as MockIncomingMessage).addBodyChunk(Buffer.alloc(30)) + + store.getUpload.resolves(new Upload({id: '1234', offset: 0})) + store.write.callsFake(async (readable: http.IncomingMessage | stream.Readable) => { + const writeStream = new stream.Duplex() + await streamP.pipeline(readable, writeStream) + return writeStream.readableLength + }) + store.declareUploadLength.resolves() + + try { + await handler.send(req, res) + throw new Error('failed test') + } catch (e) { + assert.equal(e.message !== 'failed test', true, 'failed test') + assert.equal('body' in e, true) + assert.equal('status_code' in e, true) + assert.equal(e.body, 'Maximum size exceeded\n') + assert.equal(e.status_code, 413) + } + }) }) diff --git a/packages/server/test/PostHandler.test.ts b/packages/server/test/PostHandler.test.ts index e17a7de0..cb17f255 100644 --- a/packages/server/test/PostHandler.test.ts +++ b/packages/server/test/PostHandler.test.ts @@ -10,6 +10,7 @@ import sinon from 'sinon' import {Upload, DataStore} from '../src/models' import {PostHandler} from '../src/handlers/PostHandler' import {EVENTS} from '../src/constants' +import {MockIncomingMessage} from './utils' const SERVER_OPTIONS = { path: '/test', @@ -24,7 +25,10 @@ describe('PostHandler', () => { fake_store.hasExtension.withArgs('creation-defer-length').returns(true) beforeEach(() => { - req = {url: '/files', method: 'POST'} as http.IncomingMessage + req = new MockIncomingMessage({ + url: '/files', + method: 'POST', + }) as unknown as http.IncomingMessage res = httpMocks.createResponse({req}) }) diff --git a/packages/server/test/utils.ts b/packages/server/test/utils.ts new file mode 100644 index 00000000..7a2052e3 --- /dev/null +++ b/packages/server/test/utils.ts @@ -0,0 +1,45 @@ +import {Readable} from 'node:stream' +import {ReadableOptions} from 'stream' + +interface MockIncomingMessageOptions extends ReadableOptions { + headers?: Record + httpVersion?: string + method?: string + url?: string + chunks?: Buffer[] // Array of data chunks to emit +} + +export class MockIncomingMessage extends Readable { + public headers: Record + public httpVersion: string + public method: string + public url: string + private chunks: Buffer[] + private currentIndex: number + + constructor(options: MockIncomingMessageOptions = {}) { + super(options) + this.headers = options.headers || {} + this.httpVersion = options.httpVersion || '1.1' + this.method = options.method || 'GET' + this.url = options.url || '/' + this.chunks = options.chunks || [] + this.currentIndex = 0 + } + + addBodyChunk(buffer: Buffer) { + this.chunks.push(buffer) + } + + _read(): void { + if (this.currentIndex < this.chunks.length) { + const chunk = this.chunks[this.currentIndex] + this.push(chunk) + this.currentIndex++ + } else if (this.currentIndex === this.chunks.length) { + // No more chunks, end the stream + this.push(null) + this.currentIndex++ + } + } +}