diff --git a/config.json.example b/config.json.example index 306e1bb24e..3f6ba7769e 100644 --- a/config.json.example +++ b/config.json.example @@ -74,5 +74,6 @@ "TRANSIFEX_SLACK_CHANNEL": "transifex", "WEB_CONCURRENCY": 1, "SKIP_SSL_CHECK_KEY": "key", - "ENABLE_STACKDRIVER_TRACING": "false" + "ENABLE_STACKDRIVER_TRACING": "false", + "BLOCKED_IPS": "" } diff --git a/test/api/unit/libs/errors.test.js b/test/api/unit/libs/errors.test.js index 4de2e4499f..5662fa3ced 100644 --- a/test/api/unit/libs/errors.test.js +++ b/test/api/unit/libs/errors.test.js @@ -3,6 +3,7 @@ import { CustomError, NotAuthorized, BadRequest, + Forbidden, InternalServerError, NotFound, NotificationNotFound, @@ -113,6 +114,32 @@ describe('Custom Errors', () => { }); }); + describe('Forbidden', () => { + it('is an instance of CustomError', () => { + const forbiddenError = new Forbidden(); + + expect(forbiddenError).to.be.an.instanceOf(CustomError); + }); + + it('it returns an http code of 401', () => { + const forbiddenError = new Forbidden(); + + expect(forbiddenError.httpCode).to.eql(403); + }); + + it('returns a default message', () => { + const forbiddenError = new Forbidden(); + + expect(forbiddenError.message).to.eql('Access forbidden.'); + }); + + it('allows a custom message', () => { + const forbiddenError = new Forbidden('Custom Error Message'); + + expect(forbiddenError.message).to.eql('Custom Error Message'); + }); + }); + describe('InternalServerError', () => { it('is an instance of CustomError', () => { const internalServerError = new InternalServerError(); diff --git a/test/api/unit/middlewares/ipBlocker.test.js b/test/api/unit/middlewares/ipBlocker.test.js new file mode 100644 index 0000000000..c55534e4fe --- /dev/null +++ b/test/api/unit/middlewares/ipBlocker.test.js @@ -0,0 +1,94 @@ +import nconf from 'nconf'; +import requireAgain from 'require-again'; +import { + generateRes, + generateReq, + generateNext, +} from '../../../helpers/api-unit.helper'; +import { Forbidden } from '../../../../website/server/libs/errors'; +import apiError from '../../../../website/server/libs/apiError'; + +function checkErrorThrown (next) { + expect(next).to.have.been.calledOnce; + const calledWith = next.getCall(0).args; + expect(calledWith[0].message).to.equal(apiError('ipAddressBlocked')); + expect(calledWith[0] instanceof Forbidden).to.equal(true); +} + +function checkErrorNotThrown (next) { + expect(next).to.have.been.calledOnce; + const calledWith = next.getCall(0).args; + expect(typeof calledWith[0] === 'undefined').to.equal(true); +} + +describe('ipBlocker middleware', () => { + const pathToIpBlocker = '../../../../website/server/middlewares/ipBlocker'; + + let res; let req; let next; + + beforeEach(() => { + res = generateRes(); + req = generateReq(); + next = generateNext(); + }); + + it('is disabled when the env var is not defined', () => { + sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns(undefined); + const attachIpBlocker = requireAgain(pathToIpBlocker).default; + attachIpBlocker(req, res, next); + + checkErrorNotThrown(next); + }); + + it('is disabled when the env var is an empty string', () => { + sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns(''); + const attachIpBlocker = requireAgain(pathToIpBlocker).default; + attachIpBlocker(req, res, next); + + checkErrorNotThrown(next); + }); + + it('is disabled when the env var contains comma separated empty strings', () => { + sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns(' , , '); + const attachIpBlocker = requireAgain(pathToIpBlocker).default; + attachIpBlocker(req, res, next); + + checkErrorNotThrown(next); + }); + + it('does not throw when the ip does not match', () => { + req.headers['x-forwarded-for'] = '192.168.1.1'; + sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns('192.168.1.2'); + const attachIpBlocker = requireAgain(pathToIpBlocker).default; + attachIpBlocker(req, res, next); + + checkErrorNotThrown(next); + }); + + it('throws when a matching ip exist in x-forwarded-for', () => { + req.headers['x-forwarded-for'] = '192.168.1.1'; + sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns('192.168.1.1'); + const attachIpBlocker = requireAgain(pathToIpBlocker).default; + attachIpBlocker(req, res, next); + + checkErrorThrown(next); + }); + + it('trims ips in x-forwarded-for', () => { + req.headers['x-forwarded-for'] = '192.168.1.1'; + sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns(', 192.168.1.1 , 192.168.1.4, '); + const attachIpBlocker = requireAgain(pathToIpBlocker).default; + attachIpBlocker(req, res, next); + + checkErrorThrown(next); + }); + + it('works when multiple ips are passed in x-forwarded-for', () => { + req.headers['x-forwarded-for'] = '192.168.1.4'; + sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns('192.168.1.1, 192.168.1.4, 192.168.1.3'); + const attachIpBlocker = requireAgain(pathToIpBlocker).default; + attachIpBlocker(req, res, next); + + checkErrorThrown(next); + }); +}); diff --git a/website/common/script/errors/apiErrorMessages.js b/website/common/script/errors/apiErrorMessages.js index 1a99ec1d2e..e8a3edce8c 100644 --- a/website/common/script/errors/apiErrorMessages.js +++ b/website/common/script/errors/apiErrorMessages.js @@ -25,4 +25,6 @@ export default { missingCustomerId: 'Missing "req.query.customerId"', missingPaypalBlock: 'Missing "req.session.paypalBlock"', missingSubKey: 'Missing "req.query.sub"', + + ipAddressBlocked: 'This IP address has been blocked from accessing Habitica. This may be due to a breach of our Terms of Service or technical issue originating at this IP address. For details or to ask to be unblocked, please email admin@habitica.com or ask your parent or guardian to email them. Include your Habitica @ Username or User Id in the email if you have one.', }; diff --git a/website/common/script/libs/errors.js b/website/common/script/libs/errors.js index acdbe237d2..00d917d16b 100644 --- a/website/common/script/libs/errors.js +++ b/website/common/script/libs/errors.js @@ -42,6 +42,15 @@ export class NotFound extends CustomError { } } +export class Forbidden extends CustomError { + constructor (customMessage) { + super(); + this.name = this.constructor.name; + this.httpCode = 403; + this.message = customMessage || 'Access forbidden.'; + } +} + export class NotImplementedError extends CustomError { constructor (str) { super(); diff --git a/website/server/libs/errors.js b/website/server/libs/errors.js index 193e95f349..f0d505fb65 100644 --- a/website/server/libs/errors.js +++ b/website/server/libs/errors.js @@ -41,6 +41,19 @@ export const { BadRequest } = common.errors; */ export const { NotFound } = common.errors; +/** + * @apiDefine Forbidden + * @apiError Forbidden The requested resource was not found. + * + * @apiErrorExample Error-Response: + * HTTP/1.1 403 Forbidden + * { + * "error": "Forbidden", + * "message": "Access forbidden." + * } + */ +export const { Forbidden } = common.errors; + /** * @apiDefine NotificationNotFound diff --git a/website/server/middlewares/index.js b/website/server/middlewares/index.js index 580cf3a73b..e392dda8b5 100644 --- a/website/server/middlewares/index.js +++ b/website/server/middlewares/index.js @@ -21,6 +21,7 @@ import { forceSSL, forceHabitica, } from './redirects'; +import ipBlocker from './ipBlocker'; import v1 from './v1'; import v2 from './v2'; import appRoutes from './appRoutes'; @@ -45,7 +46,8 @@ export default function attachMiddlewares (app, server) { if (!IS_PROD && !DISABLE_LOGGING) app.use(morgan('dev')); - app.use(helmet()); // See https://helmetjs.github.io/ for the list of headers enabled by default + // See https://helmetjs.github.io/ for the list of headers enabled by default + app.use(helmet()); // add res.respond and res.t app.use(responseHandler); @@ -56,6 +58,8 @@ export default function attachMiddlewares (app, server) { app.use(maintenanceMode); + app.use(ipBlocker); + app.use(cors); app.use(forceSSL); app.use(forceHabitica); diff --git a/website/server/middlewares/ipBlocker.js b/website/server/middlewares/ipBlocker.js new file mode 100644 index 0000000000..69c2a48c1a --- /dev/null +++ b/website/server/middlewares/ipBlocker.js @@ -0,0 +1,60 @@ +import nconf from 'nconf'; +import { + Forbidden, +} from '../libs/errors'; +import apiError from '../libs/apiError'; + +// Middleware to block unwanted IP addresses + +// NOTE: it's meant to be used behind a proxy (for example a load balancer) +// that uses the 'x-forwarded-for' header to forward the original IP addresses. + +// A list of comma separated IPs to block +// It works fine as long as the list is short, +// if the list becomes too long for an env variable we'll switch to Redis. +const BLOCKED_IPS_RAW = nconf.get('BLOCKED_IPS'); + +const blockedIps = BLOCKED_IPS_RAW + ? BLOCKED_IPS_RAW + .trim() + .split(',') + .map(blockedIp => blockedIp.trim()) + .filter(blockedIp => Boolean(blockedIp)) + : []; + +export default function ipBlocker (req, res, next) { + // If there are no IPs to block, skip the middleware + if (blockedIps.length === 0) return next(); + + // If x-forwarded-for is undefined we're not behind the production proxy + const originIpsRaw = req.header('x-forwarded-for'); + if (!originIpsRaw) return next(); + + // Format xxx.xxx.xxx.xxx, xxx.xxx.xxx.xxx (comma separated list of ip) + const originIps = originIpsRaw + .split(',') + .map(originIp => originIp.trim()); + + // We try to match any of the origins IPs against the blocked IPs list. + // + // In case we're behind a Google Cloud Load Balancer the last ip + // in the list is added by the load balancer. + // See https://cloud.google.com/load-balancing/docs/https#target-proxies + // In particular: + // << A Google Cloud external HTTP(S) load balancer adds two IP addresses to the header: + // the IP address of the requesting client and the external IP address of the load balancer's + // forwarding rule, in that order. + // Therefore, the IP address that immediately precedes the Google Cloud load balancer's + // IP address is the IP address of the system that contacts the load balancer. + // The system might be a client, or it might be another proxy server, outside Google Cloud, + // that forwards requests on behalf of a client. >> + + const match = originIps.find(originIp => blockedIps.includes(originIp)) !== undefined; + + if (match === true) { + // Not translated because no user is loaded at this point + return next(new Forbidden(apiError('ipAddressBlocked'))); + } + + return next(); +}