mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-18 15:17:25 +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",
|
||||
"WEB_CONCURRENCY": 1,
|
||||
"SKIP_SSL_CHECK_KEY": "key",
|
||||
"ENABLE_STACKDRIVER_TRACING": "false"
|
||||
"ENABLE_STACKDRIVER_TRACING": "false",
|
||||
"BLOCKED_IPS": ""
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
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"',
|
||||
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.',
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
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