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