Skip to content

Commit

Permalink
feat: support safeCurl for SSRF protection (#32)
Browse files Browse the repository at this point in the history
  • Loading branch information
dead-horse authored Mar 27, 2018
1 parent abc33d1 commit eba4555
Show file tree
Hide file tree
Showing 15 changed files with 277 additions and 0 deletions.
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,35 @@ Defaulting to "SAMEORIGIN", only allow iframe embed by same origin.

- disable Defaulting to `false`,same as `1; mode=block`.

### SSRF Protection

In a [Server-Side Request Forgery (SSRF)]((https://www.owasp.org/index.php/Server_Side_Request_Forgery)) attack, the attacker can abuse functionality on the server to read or update internal resources.

`egg-security` provide `ctx.safeCurl`, `app.safeCurl` and `agent.safeCurl` to provide http request(like `ctx.curl`, `app.curl` and `agent.curl`) with SSRF protection.

#### Configuration

* ipBlackList(Array) - specific which ip are illegal when request with `safeCurl`.
* checkAddress(Function) - determine the ip by the function's return value, `false` means illegal ip.

```js
// config/config.default.js
exports.security = {
ssrf: {
// support both cidr subnet or specific ip
ipBlackList: [
'10.0.0.0/8',
'127.0.0.1',
'0.0.0.0/32',
],
// checkAddress has higher priority than ipBlackList
checkAddress(ip) {
return ip !== '127.0.0.1';
}
},
};
```

## Other

* Forbidden `trace` `track` `options` http method.
Expand Down
7 changes: 7 additions & 0 deletions agent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use strict';

const utils = require('./lib/utils');

module.exports = agent => {
utils.processSSRFConfig(agent.config.security.ssrf);
};
3 changes: 3 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

const safeRedirect = require('./lib/safe_redirect');
const utils = require('./lib/utils');

module.exports = app => {
app.config.coreMiddleware.push('securities');
Expand All @@ -11,4 +12,6 @@ module.exports = app => {

// patch response.redirect
safeRedirect(app);

utils.processSSRFConfig(app.config.security.ssrf);
};
7 changes: 7 additions & 0 deletions app/extend/agent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use strict';

const safeCurl = require('../../lib/extend/safe_curl');

module.exports = {
safeCurl,
};
4 changes: 4 additions & 0 deletions app/extend/application.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use strict';

const safeCurl = require('../../lib/extend/safe_curl');

const INPUT_CSRF = '\r\n<input type="hidden" name="_csrf" value="{{ctx.csrf}}" /></form>';

exports.injectCsrf = function injectCsrf(tmplStr) {
Expand Down Expand Up @@ -30,3 +32,5 @@ const INJECTION_DEFENSE = '<!--for injection--><!--</html>--><!--for injection--
exports.injectHijackingDefense = function injectHijackingDefense(tmplStr) {
return INJECTION_DEFENSE + tmplStr + INJECTION_DEFENSE;
};

exports.safeCurl = safeCurl;
3 changes: 3 additions & 0 deletions app/extend/context.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use strict';

const safeCurl = require('../../lib/extend/safe_curl');
const isSafeDomainUtil = require('../../lib/utils').isSafeDomain;
const rndm = require('rndm');
const Tokens = require('csrf');
Expand Down Expand Up @@ -154,4 +155,6 @@ module.exports = {
this.logger.warn(`${msg}. See https://eggjs.org/zh-cn/core/security.html#安全威胁csrf的防范`);
}
},

safeCurl,
};
5 changes: 5 additions & 0 deletions config/config.default.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ module.exports = () => {
enable: false,
policy: {},
},

ssrf: {
ipBlackList: null,
checkAddress: null,
},
};

exports.helper = {
Expand Down
18 changes: 18 additions & 0 deletions lib/extend/safe_curl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
'use strict';

/**
* safe curl with ssrf protect
* @param {String} url request url
* @param {Object} options request options
* @return {Promise} response
*/
module.exports = function safeCurl(url, options = {}) {
const config = this.config || this.app.config;
if (config.security.ssrf && config.security.ssrf.checkAddress) {
options.checkAddress = config.security.ssrf.checkAddress;
} else {
this.logger.warn('[egg-security] please configure `config.security.ssrf` first');
}

return this.curl(url, options);
};
22 changes: 22 additions & 0 deletions lib/utils.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

const normalize = require('path').normalize;
const IP = require('ip');

exports.isSafeDomain = function isSafeDomain(domain, domain_white_list) {
// add prefix `.`, because all domains in white list are start with `.`
Expand Down Expand Up @@ -80,3 +81,24 @@ exports.merge = function merge(origin, opts) {
}
return res;
};

exports.processSSRFConfig = function(config) {
// transfor ssrf.ipBlackList to ssrf.checkAddress
// checkAddress has higher priority than ipBlackList
if (config && config.ipBlackList && !config.checkAddress) {
const containsList = config.ipBlackList.map(getContains);
config.checkAddress = ip => {
for (const contains of containsList) {
if (contains(ip)) return false;
}
return true;
};
}
};

function getContains(ip) {
if (IP.isV4Format(ip) || IP.isV6Format(ip)) {
return _ip => ip === _ip;
}
return IP.cidrSubnet(ip).contains;
}
50 changes: 50 additions & 0 deletions test/benchmark/cidr_subnet.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use strict';

const ip = require('ip');
const Benchmark = require('benchmark');
const benchmarks = require('beautify-benchmark');
const suite = new Benchmark.Suite();

const parsed1 = ip.cidrSubnet('10.0.0.0/8');
const parsed2 = ip.cidrSubnet('0.0.0.0/32');

console.log('10.0.0.0/8 contains 10.255.168.1', parsed1.contains('10.255.168.1'));
console.log('10.0.0.0/8 contains 11.255.168.1', parsed1.contains('11.255.168.1'));
console.log('0.0.0.0/32 contains 0.0.0.0', parsed2.contains('0.0.0.0'));
console.log('0.0.0.0/32 contains 0.0.0.1', parsed2.contains('0.0.0.1'));

suite

.add('10.0.0/8 match', () => {
parsed1.contains('10.255.168.1');
})
.add('10.0.0/8 not match', () => {
parsed1.contains('11.255.168.1');
})
.add('0.0.0/32 match', () => {
parsed1.contains('0.0.0.0');
})
.add('0.0.0/32 not match', () => {
parsed1.contains('0.0.0.1');
})
.on('cycle', event => {
benchmarks.add(event.target);
})
.on('start', event => {
console.log('\n ip.cidrsubnet().contains() Benchmark\n node version: %s, date: %s\n Starting...',
process.version, Date());
})
.on('complete', () => {
benchmarks.log();
})
.run({ 'async': false });

// ip.cidrsubnet().contains() Benchmark
// node version: v8.9.1, date: Tue Mar 27 2018 12:04:41 GMT+0800 (CST)
// Starting...
// 4 tests completed.

// 10.0.0/8 match x 338,567 ops/sec ±2.98% (84 runs sampled)
// 10.0.0/8 not match x 315,822 ops/sec ±5.29% (81 runs sampled)
// 0.0.0/32 match x 366,250 ops/sec ±4.47% (78 runs sampled)
// 0.0.0/32 not match x 370,959 ops/sec ±4.23% (82 runs sampled)
14 changes: 14 additions & 0 deletions test/fixtures/apps/ssrf-check-address/config/config.default.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'use strict';

exports.security = {
ssrf: {
ipBlackList: [
'10.0.0.0/8',
'127.0.0.1',
'0.0.0.0/32',
],
checkAddress(ip) {
return ip !== '127.0.0.2';
},
},
};
3 changes: 3 additions & 0 deletions test/fixtures/apps/ssrf-check-address/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"name": "ssrf-ip-check-address"
}
11 changes: 11 additions & 0 deletions test/fixtures/apps/ssrf-ip-black-list/config/config.default.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
'use strict';

exports.security = {
ssrf: {
ipBlackList: [
'10.0.0.0/8',
'127.0.0.1',
'0.0.0.0/32',
],
},
};
3 changes: 3 additions & 0 deletions test/fixtures/apps/ssrf-ip-black-list/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"name": "ssrf-ip-black-list"
}
98 changes: 98 additions & 0 deletions test/ssrf.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
'use strict';

const mm = require('egg-mock');
const dns = require('dns');
const assert = require('assert');

let app;
describe('test/ssrf.test.js', function() {
afterEach(mm.restore);

describe('no ssrf config', () => {
before(() => {
app = mm.app({ baseDir: 'apps/csrf' });
return app.ready();
});

it('should safeCurl work', async () => {
const ctx = app.createAnonymousContext();
const url = 'https://127.0.0.1';
mm.data(app, 'curl', 'response');
mm.data(app.agent, 'curl', 'response');
mm.data(ctx, 'curl', 'response');

let count = 0;
function mockWarn(msg) {
count++;
assert(msg === '[egg-security] please configure `config.security.ssrf` first');
}

mm(app.logger, 'warn', mockWarn);
mm(app.agent.logger, 'warn', mockWarn);
mm(ctx.logger, 'warn', mockWarn);

const r1 = await app.safeCurl(url);
const r2 = await app.agent.safeCurl(url);
const r3 = await ctx.safeCurl(url);
assert(r1 === 'response');
assert(r2 === 'response');
assert(r3 === 'response');
assert(count === 3);
});
});


describe('ipBlackList', () => {
before(() => {
app = mm.app({ baseDir: 'apps/ssrf-ip-black-list' });
return app.ready();
});

it('should safeCurl work', async () => {
const urls = [
'https://127.0.0.1/foo',
'http://10.1.2.3/foo?bar=1',
'https://0.0.0.0/',
'https://www.google.com/',
];
mm.data(dns, 'lookup', '127.0.0.1');
const ctx = app.createAnonymousContext();

for (const url of urls) {
await checkIllegalAddressError(app, url);
await checkIllegalAddressError(app.agent, url);
await checkIllegalAddressError(ctx, url);
}
});
});

describe('checkAddress', () => {
before(() => {
app = mm.app({ baseDir: 'apps/ssrf-check-address' });
return app.ready();
});

it('should safeCurl work', async () => {
const urls = [
'https://127.0.0.2/foo',
'https://www.google.com/foo',
];
mm.data(dns, 'lookup', '127.0.0.2');
const ctx = app.createAnonymousContext();
for (const url of urls) {
await checkIllegalAddressError(app, url);
await checkIllegalAddressError(app.agent, url);
await checkIllegalAddressError(ctx, url);
}
});
});
});

async function checkIllegalAddressError(instance, url) {
try {
await instance.safeCurl(url);
throw new Error('should not execute');
} catch (err) {
assert(err.name === 'IllegalAddressError');
}
}

0 comments on commit eba4555

Please sign in to comment.