IP Blocking (#12015)

* start implementing an ip blocker

* fix comments and add to list of middlewares

* fix code, comment code and improve response

* wip tests

* fix order

* fixes and tests
This commit is contained in:
Matteo Pagliazzi
2020-03-28 15:44:54 +01:00
committed by GitHub
parent a00add46a7
commit 9ab9b0f553
8 changed files with 212 additions and 2 deletions

View File

@@ -74,5 +74,6 @@
"TRANSIFEX_SLACK_CHANNEL": "transifex", "TRANSIFEX_SLACK_CHANNEL": "transifex",
"WEB_CONCURRENCY": 1, "WEB_CONCURRENCY": 1,
"SKIP_SSL_CHECK_KEY": "key", "SKIP_SSL_CHECK_KEY": "key",
"ENABLE_STACKDRIVER_TRACING": "false" "ENABLE_STACKDRIVER_TRACING": "false",
"BLOCKED_IPS": ""
} }

View File

@@ -3,6 +3,7 @@ import {
CustomError, CustomError,
NotAuthorized, NotAuthorized,
BadRequest, BadRequest,
Forbidden,
InternalServerError, InternalServerError,
NotFound, NotFound,
NotificationNotFound, 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', () => { describe('InternalServerError', () => {
it('is an instance of CustomError', () => { it('is an instance of CustomError', () => {
const internalServerError = new InternalServerError(); const internalServerError = new InternalServerError();

View File

@@ -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);
});
});

View File

@@ -25,4 +25,6 @@ export default {
missingCustomerId: 'Missing "req.query.customerId"', missingCustomerId: 'Missing "req.query.customerId"',
missingPaypalBlock: 'Missing "req.session.paypalBlock"', missingPaypalBlock: 'Missing "req.session.paypalBlock"',
missingSubKey: 'Missing "req.query.sub"', 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.',
}; };

View File

@@ -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 { export class NotImplementedError extends CustomError {
constructor (str) { constructor (str) {
super(); super();

View File

@@ -41,6 +41,19 @@ export const { BadRequest } = common.errors;
*/ */
export const { NotFound } = 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 * @apiDefine NotificationNotFound

View File

@@ -21,6 +21,7 @@ import {
forceSSL, forceSSL,
forceHabitica, forceHabitica,
} from './redirects'; } from './redirects';
import ipBlocker from './ipBlocker';
import v1 from './v1'; import v1 from './v1';
import v2 from './v2'; import v2 from './v2';
import appRoutes from './appRoutes'; import appRoutes from './appRoutes';
@@ -45,7 +46,8 @@ export default function attachMiddlewares (app, server) {
if (!IS_PROD && !DISABLE_LOGGING) app.use(morgan('dev')); 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 // add res.respond and res.t
app.use(responseHandler); app.use(responseHandler);
@@ -56,6 +58,8 @@ export default function attachMiddlewares (app, server) {
app.use(maintenanceMode); app.use(maintenanceMode);
app.use(ipBlocker);
app.use(cors); app.use(cors);
app.use(forceSSL); app.use(forceSSL);
app.use(forceHabitica); app.use(forceHabitica);

View File

@@ -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();
}