From 45709bd383062d32eff1e652ea833cc933b2c5ca Mon Sep 17 00:00:00 2001 From: Louis Capitanchik <contact@louiscap.co> Date: Mon, 22 Nov 2021 16:24:22 +0000 Subject: [PATCH] Add 'safe mode' to disable mutative requests, support SSL for DATABASE_URL based connections --- src/config/app.js | 1 + src/config/database.js | 11 +++++++++++ src/core/errors/SafeModeError.js | 7 +++++++ src/http/middleware/ErrorHandler.js | 7 ++++++- src/http/middleware/SafeModeBlock.js | 10 ++++++++++ src/http/routers/routes_v2.js | 20 +++++++++++--------- src/http/routes.js | 25 ++++++++++++++++--------- 7 files changed, 62 insertions(+), 19 deletions(-) create mode 100644 src/core/errors/SafeModeError.js create mode 100644 src/http/middleware/SafeModeBlock.js diff --git a/src/config/app.js b/src/config/app.js index 6cdbb05..803fe3f 100644 --- a/src/config/app.js +++ b/src/config/app.js @@ -11,6 +11,7 @@ module.exports = { web: env('WEB_URL', 'http://example.local'), }, dev: env('NODE_ENV', 'development') === 'development', + safe_mode: env('DISABLE_MUTATION', 'false') === 'true', security: { use_ephemeral: env('USE_EPHEMERAL_KEYS', 'true') === 'true', public_key: null, diff --git a/src/config/database.js b/src/config/database.js index c681d35..605593a 100644 --- a/src/config/database.js +++ b/src/config/database.js @@ -8,6 +8,7 @@ if (useSSL) { } const url = env('DATABASE_URL') + if (url) { const { URL } = require('url') const connectionUrl = new URL(url) @@ -17,6 +18,16 @@ if (url) { username: connectionUrl.username, password: connectionUrl.password, port: connectionUrl.port, + ssl: useSSL, + dialectOptions: useSSL ? { + ssl: { + rejectUnauthorized: false, + ca: Buffer.from( + env('DATABASE_CA_CERT', null) ?? '', + 'base64', + ).toString(), + }, + } : {}, log_queries: env('LOG_SQL_QUERIES', 'true') === 'true', } } else { diff --git a/src/core/errors/SafeModeError.js b/src/core/errors/SafeModeError.js new file mode 100644 index 0000000..f07e5c2 --- /dev/null +++ b/src/core/errors/SafeModeError.js @@ -0,0 +1,7 @@ +const HttpError = require('./HttpError') + +module.exports = class SafeModeError extends HttpError { + constructor() { + super(503, `This resource is operating in "safe mode", and is exclusively accepting read-only requests`) + } +} diff --git a/src/http/middleware/ErrorHandler.js b/src/http/middleware/ErrorHandler.js index 86b9e16..2998598 100644 --- a/src/http/middleware/ErrorHandler.js +++ b/src/http/middleware/ErrorHandler.js @@ -1,4 +1,5 @@ const HttpError = require('core/errors/HttpError') +const SafeModeError = require('core/errors/SafeModeError') const SentryReporter = require('./SentryReporter') module.exports = async (ctx, next) => { @@ -7,7 +8,11 @@ module.exports = async (ctx, next) => { try { await next(ctx) } catch (e) { - await SentryReporter.report(e, ctx) + if (e instanceof SafeModeError) { + console.error(e) + } else { + await SentryReporter.report(e, ctx) + } hasHandledError = true if (e instanceof HttpError) { diff --git a/src/http/middleware/SafeModeBlock.js b/src/http/middleware/SafeModeBlock.js new file mode 100644 index 0000000..f90ecd9 --- /dev/null +++ b/src/http/middleware/SafeModeBlock.js @@ -0,0 +1,10 @@ +const SafeModeError = require('core/errors/SafeModeError') + +module.exports = async (ctx, next) => { + const { config } = require('bootstrap') + const safeMode = config('app.safe_mode') + if (safeMode) { + throw new SafeModeError() + } + return await next() +} diff --git a/src/http/routers/routes_v2.js b/src/http/routers/routes_v2.js index 8f9ea77..45691f1 100644 --- a/src/http/routers/routes_v2.js +++ b/src/http/routers/routes_v2.js @@ -5,6 +5,7 @@ const router = new Router({ prefix: '/v2' }) const controller = (path, handler) => require(`../controllers/${path}`)[handler] const param = name => require(`../params/${name}`) const { env, config } = require('bootstrap') +const safemode = require('http/middleware/SafeModeBlock') apiMiddlewareGroup.forEach(middleware => router.use(middleware)) @@ -25,27 +26,28 @@ router.get( }), ) -router.post('/auth/login', controller('api/auth', 'login')) -router.post('/auth/register', controller('api/auth', 'register')) +router.post('/auth/login', safemode, controller('api/auth', 'login')) +router.post('/auth/register', safemode, controller('api/auth', 'register')) router.post( '/auth/password-reset', + safemode, controller('api/auth', 'triggerPasswordReset'), ) router.get('/self', controller('api/user', 'self')) router.get('/self/surveys', controller('api/v2/surveys', 'joined')) router.get('/self/bundles', controller('api/app', 'getBundles')) -router.put('/self/:property', controller('api/user', 'updateOne')) +router.put('/self/:property', safemode, controller('api/user', 'updateOne')) router.get('/metrics', controller('api/content', 'getWithin')) -router.post('/metrics', controller('api/content', 'postMetric')) +router.post('/metrics', safemode, controller('api/content', 'postMetric')) router.get('/images', noop) router.post('/images', noop) router.post('/images/:imageId/share', noop) router.get('/uploads', noop) -router.post('/uploads', controller('api/v2/uploads', 'createUpload')) +router.post('/uploads', safemode, controller('api/v2/uploads', 'createUpload')) router.get('/uploads/:upload_id', noop) router.delete('/uploads/:upload_id', noop) router.put('/uploads/:upload_id/:property', noop) @@ -57,12 +59,12 @@ router.get('/surveys', controller('api/v2/surveys', 'list')) router.get('/excerpts', controller('api/v2/surveys', 'listExcerpts')) router.get('/excerpts/:excerpt', controller('api/v2/surveys', 'getExcerpt')) router.get('/surveys/:survey', controller('api/v2/surveys', 'get')) -router.post('/surveys/:survey/membership', controller('api/v2/surveys', 'join')) -router.delete('/surveys/:survey/membership', controller('api/v2/surveys', 'leave')) +router.post('/surveys/:survey/membership', safemode, controller('api/v2/surveys', 'join')) +router.delete('/surveys/:survey/membership', safemode, controller('api/v2/surveys', 'leave')) if (config('app.dev')) { - router.post('/surveys/factory', controller('api/v2/factories', 'survey')) + router.post('/surveys/factory', safemode, controller('api/v2/factories', 'survey')) } -router.post('/an/ev', controller('api/analytics', 'track')) +router.post('/an/ev', safemode, controller('api/analytics', 'track')) module.exports = router diff --git a/src/http/routes.js b/src/http/routes.js index 622710f..c948e3b 100644 --- a/src/http/routes.js +++ b/src/http/routes.js @@ -17,6 +17,7 @@ const loaders = require('http/middleware/MountLoaders') const userGate = require('http/middleware/RequiresAuth') const authRedirect = require('http/middleware/RedirectToLogin') const device = require('http/middleware/DeviceProperties').extractDevice +const safemode = require('http/middleware/SafeModeBlock') const createOIDCServer = require('domain/auth/oidc/OIDCServer') @@ -103,61 +104,67 @@ function mount(api) { } }) - api.post('/metrics', controller('api/content', 'postMetric')) + api.post('/metrics', safemode, controller('api/content', 'postMetric')) api.get('/metrics', controller('api/content', 'getWithin')) api.get('/images', controller('api/storage', 'getFiles')) api.post( '/images', + safemode, upload.single('featured_image'), controller('api/storage', 'saveFile'), ) api.post( '/images/:imageId/feature', + safemode, controller('api/storage', 'featureImage'), ) /** @deprecated */ api.post( '/feature', + safemode, upload.single('featured_image'), controller('api/storage', 'saveFile'), ) api.get('/feed', controller('api/storage', 'feed')) - api.post('/feed/:fileId/like', controller('api/storage', 'like')) - api.post('/feed/:fileId/unlike', controller('api/storage', 'unlike')) + api.post('/feed/:fileId/like', safemode, controller('api/storage', 'like')) + api.post('/feed/:fileId/unlike', safemode, controller('api/storage', 'unlike')) - api.post('/register', controller('api/auth', 'register')) + api.post('/register', safemode, controller('api/auth', 'register')) api.post('/login', controller('api/auth', 'login')) - api.post('/auth/reset-token', controller('api/auth', 'triggerPasswordReset')) + api.post('/auth/reset-token', safemode, controller('api/auth', 'triggerPasswordReset')) api.post( '/auth/reset-password', + safemode, controller('api/auth', 'handlePasswordReset'), ) api.param('oauthClientId', param('oauth_client')) api.get('/oauth/clients', controller('api/oauth', 'listClients')) - api.post('/oauth/clients', controller('api/oauth', 'createClient')) + api.post('/oauth/clients', safemode, controller('api/oauth', 'createClient')) api.post( '/oauth/clients/:oauthClientId/redirects', + safemode, controller('api/oauth', 'addClientRedirect'), ) api.delete( '/oauth/clients/:oauthClientId/redirects', + safemode, controller('api/oauth', 'removeClientRedirect'), ) api.get('/self', controller('api/user', 'self')) api.get('/self/bundles', controller('api/app', 'getBundles')) - api.put('/self/:property', controller('api/user', 'updateOne')) + api.put('/self/:property', safemode, controller('api/user', 'updateOne')) api.post('/an/id', async ctx => {}) - api.post('/an/ev', controller('api/analytics', 'track')) + api.post('/an/ev', safemode, controller('api/analytics', 'track')) - api.post('/feedback', controller('api/feedback', 'send')) + api.post('/feedback', safemode, controller('api/feedback', 'send')) api.use(v2.allowedMethods()) api.use(v2.routes()) -- GitLab