diff --git a/.changeset/warm-bikes-carry.md b/.changeset/warm-bikes-carry.md new file mode 100644 index 00000000..dee8c9a5 --- /dev/null +++ b/.changeset/warm-bikes-carry.md @@ -0,0 +1,6 @@ +--- +'@tus/server': minor +--- + +- Add `allowedCredentials` option for the Access-Control-Allow-Credentials header +- Add `allowedOrigins` option for setting domains in Access-Control-Allow-Origin diff --git a/packages/server/README.md b/packages/server/README.md index 491d72d1..9335d75e 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -69,6 +69,16 @@ Max file size (in bytes) allowed when uploading (`number` | (`(req, id: string | null) => Promise | number`)). When providing a function during the OPTIONS request the id will be `null`. +#### `options.allowedCredentials` + +Sets `Access-Control-Allow-Credentials` (`boolean`, default: `false`). + +#### `options.allowedOrigins` + +Trusted origins (`string[]`). + +Sends the client's origin back in `Access-Control-Allow-Origin` if it matches. + #### `options.postReceiveInterval` Interval in milliseconds for sending progress of an upload over diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index d644341e..dd06d376 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -219,9 +219,11 @@ export class Server extends EventEmitter { } // Enable CORS + res.setHeader('Access-Control-Allow-Origin', this.getCorsOrigin(req)) res.setHeader('Access-Control-Expose-Headers', EXPOSED_HEADERS) - if (req.headers.origin) { - res.setHeader('Access-Control-Allow-Origin', req.headers.origin) + + if (this.options.allowedCredentials === true) { + res.setHeader('Access-Control-Allow-Credentials', 'true') } // Invoke the handler for the method requested @@ -233,6 +235,23 @@ export class Server extends EventEmitter { return this.write(context, req, res, 404, 'Not found\n') } + private getCorsOrigin(req: http.IncomingMessage): string { + const origin = req.headers.origin + const isOriginAllowed = + this.options.allowedOrigins?.some((allowedOrigin) => allowedOrigin === origin) ?? + true + + if (origin && isOriginAllowed) { + return origin + } + + if (this.options.allowedOrigins && this.options.allowedOrigins.length > 0) { + return this.options.allowedOrigins[0] + } + + return '*' + } + write( context: CancellationContext, req: http.IncomingMessage, diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 0ec93009..b1a816ff 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -34,6 +34,16 @@ export type ServerOptions = { */ allowedHeaders?: string[] + /** + * Set `Access-Control-Allow-Credentials` to true or false (the default) + */ + allowedCredentials?: boolean + + /** + * Add trusted origins to `Access-Control-Allow-Origin`. + */ + allowedOrigins?: string[] + /** * Interval in milliseconds for sending progress of an upload over `EVENTS.POST_RECEIVE_V2` */ diff --git a/packages/server/test/Server.test.ts b/packages/server/test/Server.test.ts index 6094b1eb..c29da467 100644 --- a/packages/server/test/Server.test.ts +++ b/packages/server/test/Server.test.ts @@ -165,6 +165,19 @@ describe('Server', () => { }) }) + it('OPTIONS should return returns custom headers in Access-Control-Allow-Credentials', (done) => { + server.options.allowedCredentials = true + + request(listener) + .options('/') + .expect(204, '', (err, res) => { + res.headers.should.have.property('access-control-allow-credentials') + res.headers['access-control-allow-credentials'].should.containEql('true') + server.options.allowedCredentials = undefined + done(err) + }) + }) + it('HEAD should 404 non files', (done) => { request(listener) .head('/') @@ -252,8 +265,37 @@ describe('Server', () => { done() }) - it('should allow overriding the HTTP method', async () => { + it('should allow overriding the HTTP origin', async () => { + const origin = 'vimeo.com' + const req = httpMocks.createRequest({ + headers: {origin}, + method: 'OPTIONS', + url: '/', + }) + // @ts-expect-error todo + const res = new http.ServerResponse({method: 'OPTIONS'}) + await server.handle(req, res) + assert.equal(res.hasHeader('Access-Control-Allow-Origin'), true) + }) + + it('should allow overriding the HTTP origin only if match allowedOrigins', async () => { + const origin = 'vimeo.com' + server.options.allowedOrigins = ['vimeo.com'] + const req = httpMocks.createRequest({ + headers: {origin}, + method: 'OPTIONS', + url: '/', + }) + // @ts-expect-error todo + const res = new http.ServerResponse({method: 'OPTIONS'}) + await server.handle(req, res) + assert.equal(res.hasHeader('Access-Control-Allow-Origin'), true) + assert.equal(res.getHeader('Access-Control-Allow-Origin'), 'vimeo.com') + }) + + it('should allow overriding the HTTP origin only if match allowedOrigins with multiple allowed domains', async () => { const origin = 'vimeo.com' + server.options.allowedOrigins = ['google.com', 'vimeo.com'] const req = httpMocks.createRequest({ headers: {origin}, method: 'OPTIONS', @@ -263,6 +305,35 @@ describe('Server', () => { const res = new http.ServerResponse({method: 'OPTIONS'}) await server.handle(req, res) assert.equal(res.hasHeader('Access-Control-Allow-Origin'), true) + assert.equal(res.getHeader('Access-Control-Allow-Origin'), 'vimeo.com') + }) + + it(`should now allow overriding the HTTP origin if doesn't match allowedOrigins`, async () => { + const origin = 'vimeo.com' + server.options.allowedOrigins = ['google.com'] + const req = httpMocks.createRequest({ + headers: {origin}, + method: 'OPTIONS', + url: '/', + }) + // @ts-expect-error todo + const res = new http.ServerResponse({method: 'OPTIONS'}) + await server.handle(req, res) + assert.equal(res.hasHeader('Access-Control-Allow-Origin'), true) + assert.equal(res.getHeader('Access-Control-Allow-Origin'), 'google.com') + }) + + it('should return Access-Control-Allow-Origin if no origin header', async () => { + server.options.allowedOrigins = ['google.com'] + const req = httpMocks.createRequest({ + method: 'OPTIONS', + url: '/', + }) + // @ts-expect-error todo + const res = new http.ServerResponse({method: 'OPTIONS'}) + await server.handle(req, res) + assert.equal(res.hasHeader('Access-Control-Allow-Origin'), true) + assert.equal(res.getHeader('Access-Control-Allow-Origin'), 'google.com') }) it('should not invoke handlers if onIncomingRequest throws', (done) => {