From 81c9603ea0f8cdbfaed211227cc9a5398c15d0fe Mon Sep 17 00:00:00 2001 From: Jonatan Nilsson Date: Wed, 15 Nov 2023 08:51:48 +0000 Subject: [PATCH] Add cors support for sites that request it --- .gitignore | 3 - api/media/routes.mjs | 3 +- api/media/security.mjs | 14 +++++ package.json | 2 +- public/development_cors/.gitkeep | 0 test/helper.server.mjs | 6 ++ test/media/api.test.mjs | 70 ++++++++++++++++++++++-- test/media/security.test.mjs | 94 +++++++++++++++++++++++++++++++- 8 files changed, 180 insertions(+), 12 deletions(-) create mode 100644 public/development_cors/.gitkeep diff --git a/.gitignore b/.gitignore index 81990f5..fc33284 100644 --- a/.gitignore +++ b/.gitignore @@ -60,9 +60,6 @@ typings/ # Local development config file config/*.json -# Public folder should be ignored -public/* - # lol package-lock.json diff --git a/api/media/routes.mjs b/api/media/routes.mjs index 931f4fb..62a67ca 100644 --- a/api/media/routes.mjs +++ b/api/media/routes.mjs @@ -2,7 +2,7 @@ import path from 'path' import sharp from 'sharp' import fs from 'fs/promises' import config from '../config.mjs' -import { HttpError } from 'flaska' +import { HttpError, CorsHandler } from 'flaska' import * as security from './security.mjs' import * as formidable from './formidable.mjs' @@ -30,6 +30,7 @@ export default class MediaRoutes { server.flaska.post('/media/resize', [server.queryHandler()], this.resize.bind(this)) server.flaska.post('/media/resize/:filename', [server.queryHandler(), server.jsonHandler()], this.resizeExisting.bind(this)) server.flaska.delete('/media/:filename', [server.queryHandler()], this.remove.bind(this)) + server.flaska.options('/::path', [server.queryHandler(), this.security.verifyCorsEnabled], CorsHandler({})) } init(server) { diff --git a/api/media/security.mjs b/api/media/security.mjs index 2ac6859..31a3184 100644 --- a/api/media/security.mjs +++ b/api/media/security.mjs @@ -25,6 +25,20 @@ export function verifyToken(ctx) { } } +export function verifyCorsEnabled(ctx) { + let site + try { + site = verifyToken(ctx) + } catch (err) { + throw new HttpError(404) + } + + let sites = config.get('sites') + if (!sites[site] || sites[site].cors !== true) { + throw new HttpError(404) + } +} + export function throwIfNotPublic(site) { let sites = config.get('sites') if (!sites[site] || sites[site].public !== true) { diff --git a/package.json b/package.json index 9c2e3ba..2c92612 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "storage-upload", - "version": "2.2.7", + "version": "2.2.8", "description": "Micro service for uploading and image resizing files to a storage server.", "main": "index.js", "scripts": { diff --git a/public/development_cors/.gitkeep b/public/development_cors/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/helper.server.mjs b/test/helper.server.mjs index 248ff2c..fa3956a 100644 --- a/test/helper.server.mjs +++ b/test/helper.server.mjs @@ -35,6 +35,12 @@ export function startServer() { "default@HS256": "asdf1234" } }, + "development_cors": { + "keys": { + "default@HS256": "asdf1234" + }, + "cors": true + }, "existing": { "public": true, "keys": { diff --git a/test/media/api.test.mjs b/test/media/api.test.mjs index b9a0a61..a9e011c 100644 --- a/test/media/api.test.mjs +++ b/test/media/api.test.mjs @@ -15,7 +15,7 @@ function resolve(file) { const currYear = new Date().getFullYear().toString() -t.describe('Media (API)', () => { +t.timeout(10000).describe('Media (API)', () => { let client let secret = 'asdf1234' let testFiles = [] @@ -34,7 +34,65 @@ t.describe('Media (API)', () => { }) }) - t.timeout(10000).describe('POST /media', function temp() { + t.describe('OPTIONS', function() { + t.test('should fail options for every single route', async () => { + const testPaths = [ + '/media', + '/media/:site', + '/media', + '/media/noprefix', + '/media/resize', + '/media/resize/:filename', + '/media/:filename', + ] + + for (let path of testPaths) { + let err = await assert.isRejected( + client.customRequest('OPTIONS', path, null) + ) + assert.strictEqual(err.status, 404) + } + }) + + t.test('should fail options for every single route even with token', async () => { + let token = encode(null, { iss: 'development' }, secret) + const testPaths = [ + '/media', + '/media/:site', + '/media', + '/media/noprefix', + '/media/resize', + '/media/resize/:filename', + '/media/:filename', + ] + + for (let path of testPaths) { + let err = await assert.isRejected( + client.customRequest('OPTIONS', path + `?token=${token}`, null) + ) + assert.strictEqual(err.status, 404) + } + }) + + t.test('should work if specified has cors enabled', async () => { + let token = encode(null, { iss: 'development_cors' }, secret) + const testPaths = [ + '/media', + '/media/:site', + '/media', + '/media/noprefix', + '/media/resize', + '/media/resize/:filename', + '/media/:filename', + ] + + for (let path of testPaths) { + await client.customRequest('OPTIONS', path + `?token=${token}`, null) + } + }) + }) + + t.describe('POST /media', function temp() { t.test('should require authentication', async () => { resetLog() assert.strictEqual(log.error.callCount, 0) @@ -144,7 +202,7 @@ t.describe('Media (API)', () => { }) }) - t.timeout(10000).describe('POST /media/noprefix', function temp() { + t.describe('POST /media/noprefix', function temp() { t.test('should require authentication', async () => { resetLog() assert.strictEqual(log.error.callCount, 0) @@ -260,7 +318,7 @@ t.describe('Media (API)', () => { }) }) - t.timeout(10000).describe('POST /media/resize', function temp() { + t.describe('POST /media/resize', function temp() { t.test('should require authentication', async () => { resetLog() assert.strictEqual(log.error.callCount, 0) @@ -500,7 +558,7 @@ t.describe('Media (API)', () => { }) }) - t.timeout(10000).describe('POST /media/resize/:filename', function temp() { + t.describe('POST /media/resize/:filename', function temp() { let sourceFilename let sourcePath @@ -664,7 +722,7 @@ t.describe('Media (API)', () => { }) }) - t.timeout(10000).describe('DELETE /media/:filename', function temp() { + t.describe('DELETE /media/:filename', function temp() { t.test('should require authentication', async () => { resetLog() assert.strictEqual(log.error.callCount, 0) diff --git a/test/media/security.test.mjs b/test/media/security.test.mjs index 89a97ea..fec2584 100644 --- a/test/media/security.test.mjs +++ b/test/media/security.test.mjs @@ -2,7 +2,7 @@ import { Eltro as t, assert} from 'eltro' import { HttpError } from 'flaska' import { createContext } from '../helper.server.mjs' -import { verifyToken, verifyBody, throwIfNotPublic } from '../../api/media/security.mjs' +import { verifyToken, verifyBody, throwIfNotPublic, verifyCorsEnabled } from '../../api/media/security.mjs' import encode from '../../api/jwt/encode.mjs' import config from '../../api/config.mjs' @@ -61,6 +61,98 @@ t.describe('#throwIfNotPublic()', function() { }) }) +t.describe('#verifyCorsEnabled()', function() { + let ctx + let backup = {} + + t.beforeEach(function() { + ctx = createContext({ }) + }) + + t.before(function() { + backup = config.sources[1].store + config.sources[1].store = { + sites: { + justatest: { + keys: { + 'default@HS512': 'mysharedkey', + } + }, + justatest2: { + keys: { + 'default@HS512': 'mysharedkey', + }, + cors: false, + }, + justatest3: { + keys: { + 'default@HS512': 'mysharedkey', + }, + cors: true, + }, + }, + } + }) + + t.after(function() { + config.sources[1].store = backup + }) + + t.test('should throw 404 for sites that do not exist or are null', function() { + let tests = [ + 'justatest', + 'justatest2', + 'nonexisting1', + null, + ] + + tests.forEach(function(test) { + ctx.query.set('token', encode({ typ: 'JWT', alg: 'HS512' }, { iss: test }, 'mysharedkey')) + + assert.throws(function() { verifyCorsEnabled(ctx) }, function(err) { + assert.ok(err instanceof HttpError) + assert.ok(err instanceof Error) + assert.strictEqual(err.status, 404) + assert.notOk(err.message) + return true + }, `should throw with site ${test}`) + }) + }) + + t.test('should throw 404 for sites that use wrong token', function() { + let tests = [ + 'justatest', + 'justatest2', + 'nonexisting1', + null, + ] + + tests.forEach(function(test) { + ctx.query.set('token', encode({ typ: 'JWT', alg: 'HS512' }, { iss: test }, 'herp')) + + assert.throws(function() { verifyCorsEnabled(ctx) }, function(err) { + assert.ok(err instanceof HttpError) + assert.ok(err instanceof Error) + assert.strictEqual(err.status, 404) + assert.notOk(err.message) + return true + }, `should throw with site ${test}`) + }) + }) + + t.test('should pass for sites that have cors enabled', function() { + let tests = [ + 'justatest3', + ] + + tests.forEach(function(test) { + ctx.query.set('token', encode({ typ: 'JWT', alg: 'HS512' }, { iss: test }, 'mysharedkey')) + + assert.doesNotThrow(function() { verifyCorsEnabled(ctx) }, `should not throw with site ${test}`) + }) + }) +}) + t.describe('#verifyToken()', function() { let backup = {} t.before(function() {