mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-18 23:27:26 +01:00
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:
@@ -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": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
94
test/api/unit/middlewares/ipBlocker.test.js
Normal file
94
test/api/unit/middlewares/ipBlocker.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
60
website/server/middlewares/ipBlocker.js
Normal file
60
website/server/middlewares/ipBlocker.js
Normal 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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user