+2
This commit is contained in:
parent
166bad95f6
commit
75a96a4bab
13 changed files with 407 additions and 28 deletions
52
README.md
52
README.md
|
@ -1,5 +1,5 @@
|
|||
# security
|
||||
[![version 0.0.0-alpha+1](https://img.shields.io/badge/pub-v0.0.0--alpha+1-red.svg)](https://pub.dartlang.org/packages/angel_security)
|
||||
[![version 0.0.0-alpha+2](https://img.shields.io/badge/pub-v0.0.0--alpha+2-red.svg)](https://pub.dartlang.org/packages/angel_security)
|
||||
[![build status](https://travis-ci.org/angel-dart/security.svg)](https://travis-ci.org/angel-dart/security)
|
||||
|
||||
Angel middleware designed to enhance application security by patching common Web security
|
||||
|
@ -7,6 +7,15 @@ holes.
|
|||
|
||||
Currently unfinished, with incomplete code coverage - **USE AT YOUR OWN RISK!!!**
|
||||
|
||||
* Generic Middleware
|
||||
* [Sanitizing HTML](#sanitizing-html)
|
||||
* [CSRF Tokens](#csrf-tokens)
|
||||
* [Banning by IP/Origin](#banning-by-ip)
|
||||
* [Trusted Proxy](#trusted-proxy)
|
||||
* [Throttling Requests](#throttling-requests)
|
||||
* [Helmet Port](#helmet)
|
||||
* [Service Hooks](#service-hooks)
|
||||
|
||||
## Sanitizing HTML
|
||||
|
||||
```dart
|
||||
|
@ -23,7 +32,7 @@ app.chain(verifyCsrfToken()).post('/form', ...);
|
|||
app.responseFinalizers.add(setCsrfToken());
|
||||
```
|
||||
|
||||
## Banning IP's
|
||||
## Banning by IP
|
||||
|
||||
```dart
|
||||
app.before.add(banIp('1.2.3.4'));
|
||||
|
@ -34,4 +43,43 @@ app.before.add(banIp('1.2.*.4'));
|
|||
|
||||
// Or multiple filters:
|
||||
app.before.add(banIp(['1.2.3.4', '192.*.*.*', new RegExp(r'1\.2.\3.\4')]));
|
||||
|
||||
// Also can ban origins
|
||||
app.before.add(banOrigin('*.known-attacker.com'));
|
||||
|
||||
// By default, `banOrigin` forces users to have an `Origin` header.
|
||||
// Use this flag to disable it:
|
||||
app.before.add(banOrigin('evil.site', allowEmptyOrigin: true));
|
||||
```
|
||||
|
||||
## Trusted Proxy
|
||||
Works well with Apache or Nginx.
|
||||
|
||||
```dart
|
||||
// ONLY trust localhost X-Forwarded-* headers
|
||||
app.before.add(trustProxy('127.0.0.1'));
|
||||
```
|
||||
|
||||
## Throttling Requests
|
||||
Throws a `429` error if the given rate limit is exceeded.
|
||||
|
||||
```dart
|
||||
// Example: 5 requests per minute
|
||||
app.before.add(throttleRequests(5, new Duration(minutes: 1)));
|
||||
|
||||
# Helmet
|
||||
`security` includes a port of [`helmetjs`](https://github.com/helmetjs/helmet).
|
||||
Helmet includes 11 middleware that attempt to enhance security via HTTP headers.
|
||||
|
||||
Call `helmet` to include all of them.
|
||||
|
||||
```dart
|
||||
import 'package:angel_security/helmet.dart';
|
||||
```
|
||||
|
||||
# Service Hooks
|
||||
Also included are a set of service hooks, [ported from FeathersJS](https://github.com/feathersjs/feathers-legacy-authentication-hooks).
|
||||
|
||||
```dart
|
||||
import 'package:angel_security/hooks.dart';
|
||||
```
|
|
@ -4,3 +4,5 @@ library angel_security;
|
|||
export 'src/ban.dart';
|
||||
export 'src/csrf.dart';
|
||||
export 'src/sanitize.dart';
|
||||
export 'src/throttle.dart';
|
||||
export 'src/trust_proxy.dart';
|
||||
|
|
48
lib/helmet.dart
Normal file
48
lib/helmet.dart
Normal file
|
@ -0,0 +1,48 @@
|
|||
library angel_security.helmet;
|
||||
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
|
||||
/// A collection of 11 middleware and handlers to help secure your application
|
||||
/// using HTTP headers.
|
||||
AngelConfigurer helmet() {
|
||||
throw new Exception("Helmet isn't ready just yet! ;)");
|
||||
|
||||
return (Angel app) async {
|
||||
app.before.add(waterfall([
|
||||
contentSecurityPolicy(),
|
||||
dnsPrefetchControl(),
|
||||
frameguard(),
|
||||
hpkp(),
|
||||
hsts(),
|
||||
ieNoOpen(),
|
||||
noCache(),
|
||||
noSniff(),
|
||||
referrerPolicy(),
|
||||
xssFilter()
|
||||
]));
|
||||
|
||||
app.responseFinalizers.addAll([hidePoweredBy]);
|
||||
};
|
||||
}
|
||||
|
||||
RequestMiddleware contentSecurityPolicy() {}
|
||||
|
||||
RequestMiddleware dnsPrefetchControl() {}
|
||||
|
||||
RequestMiddleware frameguard() {}
|
||||
|
||||
RequestMiddleware hidePoweredBy() {}
|
||||
|
||||
RequestMiddleware hpkp() {}
|
||||
|
||||
RequestMiddleware hsts() {}
|
||||
|
||||
RequestMiddleware ieNoOpen() {}
|
||||
|
||||
RequestMiddleware noCache() {}
|
||||
|
||||
RequestMiddleware noSniff() {}
|
||||
|
||||
RequestMiddleware referrerPolicy() {}
|
||||
|
||||
RequestMiddleware xssFilter() {}
|
2
lib/hooks.dart
Normal file
2
lib/hooks.dart
Normal file
|
@ -0,0 +1,2 @@
|
|||
/// Coming soon!
|
||||
library angel_security.hooks;
|
|
@ -44,7 +44,55 @@ RequestMiddleware banIp(filter,
|
|||
}
|
||||
|
||||
if (!check()) throw new AngelHttpException.forbidden(message: message);
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/// Throws a 403 Forbidden if the user's Origin header is banned.
|
||||
///
|
||||
/// [filter] can be:
|
||||
/// A `String`, `RegExp`, or an `Iterable`.
|
||||
///
|
||||
/// String can take the following formats:
|
||||
/// 1. example.com
|
||||
/// 2. *.example.com, a.b.*.d.e.f, etc.
|
||||
RequestMiddleware banOrigin(filter,
|
||||
{String message: 'You are forbidden from accessing this server.',
|
||||
bool allowEmptyOrigin: false}) {
|
||||
var filters = [];
|
||||
Iterable inputs = filter is Iterable ? filter : [filter];
|
||||
|
||||
for (var input in inputs) {
|
||||
if (input is RegExp)
|
||||
filters.add(input);
|
||||
else if (input is String) {
|
||||
if (!input.contains('*'))
|
||||
filters.add(input);
|
||||
else {
|
||||
filters.add(new RegExp(input.replaceAll('*', '[^\.]+')));
|
||||
}
|
||||
} else
|
||||
throw new ArgumentError('Cannot use $input as an origin filter.');
|
||||
}
|
||||
|
||||
return (RequestContext req, ResponseContext res) async {
|
||||
var origin = req.headers.value('origin');
|
||||
|
||||
if ((origin == null || origin.isEmpty) && !allowEmptyOrigin)
|
||||
throw new AngelHttpException.badRequest(
|
||||
message: "'Origin' header is required.");
|
||||
|
||||
bool check() {
|
||||
for (var input in filters) {
|
||||
if (input is RegExp && input.hasMatch(origin))
|
||||
return false;
|
||||
else if (input is String && input == origin) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!check()) throw new AngelHttpException.forbidden(message: message);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
|
54
lib/src/throttle.dart
Normal file
54
lib/src/throttle.dart
Normal file
|
@ -0,0 +1,54 @@
|
|||
import 'package:angel_framework/angel_framework.dart';
|
||||
|
||||
/// Prevents users from sending more than a given [max] number of
|
||||
/// requests within a given [duration].
|
||||
///
|
||||
/// Use [identify] to create a unique identifier for each request.
|
||||
/// The default is to identify requests by their source IP.
|
||||
///
|
||||
/// This works well attached to a `multiserver` instance.
|
||||
RequestMiddleware throttleRequests(int max, Duration duration,
|
||||
{String message: '429 Too Many Requests', identify(RequestContext req)}) {
|
||||
var identifyRequest = identify ?? (RequestContext req) async => req.ip;
|
||||
Map<String, int> table = {};
|
||||
Map<String, List<int>> times = {};
|
||||
|
||||
return (RequestContext req, ResponseContext res) async {
|
||||
var id = (await identifyRequest(req)).toString();
|
||||
int currentCount;
|
||||
|
||||
var now = new DateTime.now().millisecondsSinceEpoch;
|
||||
int firstVisit;
|
||||
|
||||
// If the user has visited within the given duration...
|
||||
if (times.containsKey(id)) {
|
||||
firstVisit = times[id].first;
|
||||
}
|
||||
|
||||
// If difference in times is greater than duration, reset counter ;)
|
||||
if (firstVisit != null) {
|
||||
if (now - firstVisit > duration.inMilliseconds) {
|
||||
table.remove(id);
|
||||
times.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Save to time table
|
||||
if (times.containsKey(id))
|
||||
times[id].add(now);
|
||||
else
|
||||
times[id] = [now];
|
||||
|
||||
if (table.containsKey(id))
|
||||
currentCount = table[id] = table[id] + 1;
|
||||
else
|
||||
currentCount = table[id] = 1;
|
||||
|
||||
if (currentCount > max) {
|
||||
throw new AngelHttpException(null,
|
||||
statusCode: 429, message: message ?? '429 Too Many Requests');
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
82
lib/src/trust_proxy.dart
Normal file
82
lib/src/trust_proxy.dart
Normal file
|
@ -0,0 +1,82 @@
|
|||
import 'dart:io';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
|
||||
/// Injects a [ForwardedClient] if the user comes from a
|
||||
/// trusted proxy.
|
||||
///
|
||||
/// [filter] can be:
|
||||
/// A `String`, `RegExp`, `InternetAddress`, or an `Iterable`.
|
||||
///
|
||||
/// String can take the following formats:
|
||||
/// 1. 1.2.3.4
|
||||
/// 2. 1.2.3.*, 1.2.*.*, etc.
|
||||
RequestMiddleware trustProxy(filter) {
|
||||
var filters = [];
|
||||
Iterable inputs = filter is Iterable ? filter : [filter];
|
||||
|
||||
for (var input in inputs) {
|
||||
if (input is RegExp || input is InternetAddress)
|
||||
filters.add(input);
|
||||
else if (input is String) {
|
||||
if (!input.contains('*'))
|
||||
filters.add(input);
|
||||
else {
|
||||
filters.add(new RegExp(input.replaceAll('*', '[0-9]+')));
|
||||
}
|
||||
} else
|
||||
throw new ArgumentError('Cannot use $input as a trusted proxy filter.');
|
||||
}
|
||||
|
||||
return (RequestContext req, ResponseContext res) async {
|
||||
var ip = req.ip;
|
||||
|
||||
bool check() {
|
||||
for (var input in filters) {
|
||||
if (input is RegExp && input.hasMatch(ip))
|
||||
return true;
|
||||
else if (input is InternetAddress && input.address == ip)
|
||||
return true;
|
||||
else if (input is String && input == ip) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (check()) {
|
||||
Map<String, List<String>> headers = {};
|
||||
|
||||
req.headers.forEach((k, v) {
|
||||
if (k.trim().toLowerCase().startsWith('x-forwarded')) headers[k] = v;
|
||||
});
|
||||
|
||||
req.inject(ForwardedClient, new _ForwardedClientImpl(headers));
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/// Presents information about the client forwarded by a trusted
|
||||
/// reverse proxy.
|
||||
abstract class ForwardedClient {
|
||||
Map<String, List<String>> get headers;
|
||||
|
||||
String get ip => headers['x-forwarded-for']?.join(',');
|
||||
String get host => headers['x-forwarded-host']?.join(',');
|
||||
String get protocol => headers['x-forwarded-proto']?.join(',');
|
||||
|
||||
int get port {
|
||||
var portString = headers['x-forwarded-proto']?.join(',');
|
||||
return portString != null ? int.parse(portString) : null;
|
||||
}
|
||||
}
|
||||
|
||||
class _ForwardedClientImpl extends ForwardedClient {
|
||||
final Map<String, List<String>> _headers;
|
||||
|
||||
_ForwardedClientImpl(this._headers);
|
||||
|
||||
@override
|
||||
Map<String, List<String>> get headers =>
|
||||
new Map<String, List<String>>.unmodifiable(_headers);
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
name: angel_security
|
||||
version: 0.0.0-alpha+1
|
||||
version: 0.0.0-alpha+2
|
||||
description: Angel middleware designed to enhance application security by patching common Web security holes.
|
||||
author: Tobe O <thosakwe@gmail.com>
|
||||
environment:
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_security/angel_security.dart';
|
||||
import 'package:angel_test/angel_test.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
main() {
|
||||
Angel app;
|
||||
TestClient client;
|
||||
|
||||
setUp(() async {
|
||||
app = new Angel()..chain(banIp('*.*.*.*')).get('/ban', 'WTF');
|
||||
|
||||
client = await connectTo(app);
|
||||
});
|
||||
|
||||
tearDown(() => client.close());
|
||||
|
||||
test('ban everyone', () async {
|
||||
var response = await client.get('/ban');
|
||||
print(response.body);
|
||||
expect(response, hasStatus(403));
|
||||
expect(response.body.contains('WTF'), isFalse);
|
||||
});
|
||||
}
|
51
test/ban_test.dart
Normal file
51
test/ban_test.dart
Normal file
|
@ -0,0 +1,51 @@
|
|||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_security/angel_security.dart';
|
||||
import 'package:angel_test/angel_test.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
main() {
|
||||
Angel app;
|
||||
TestClient client;
|
||||
|
||||
setUp(() async {
|
||||
app = new Angel()
|
||||
..chain(banIp('*.*.*.*')).get('/ban', 'WTF')
|
||||
..chain(banOrigin('*')).get('/ban-origin', 'WTF')
|
||||
..chain(banOrigin('*.foo.bar')).get('/allow-origin', 'YAY');
|
||||
|
||||
client = await connectTo(app);
|
||||
});
|
||||
|
||||
tearDown(() => client.close());
|
||||
|
||||
test('ban everyone', () async {
|
||||
var response = await client.get('/ban');
|
||||
print(response.body);
|
||||
expect(response, hasStatus(403));
|
||||
expect(response.body.contains('WTF'), isFalse);
|
||||
});
|
||||
|
||||
group('origin', () {
|
||||
test('ban everyone', () async {
|
||||
var response = await client
|
||||
.get('/ban-origin', headers: {'Origin': 'www.example.com'});
|
||||
print(response.body);
|
||||
expect(response, hasStatus(403));
|
||||
expect(response.body.contains('WTF'), isFalse);
|
||||
});
|
||||
|
||||
test('ban specific', () async {
|
||||
var response =
|
||||
await client.get('/allow-origin', headers: {'Origin': 'www.foo.bar'});
|
||||
print(response.body);
|
||||
expect(response, hasStatus(403));
|
||||
expect(response.body.contains('YAY'), isFalse);
|
||||
|
||||
response = await client
|
||||
.get('/allow-origin', headers: {'Origin': 'www.example.com'});
|
||||
print(response.body);
|
||||
expect(response, hasStatus(200));
|
||||
expect(response.body, contains('YAY'));
|
||||
});
|
||||
});
|
||||
}
|
33
test/throttle_test.dart
Normal file
33
test/throttle_test.dart
Normal file
|
@ -0,0 +1,33 @@
|
|||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_security/angel_security.dart';
|
||||
import 'package:angel_test/angel_test.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
main() {
|
||||
Angel app;
|
||||
TestClient client;
|
||||
|
||||
setUp(() async {
|
||||
app = new Angel();
|
||||
|
||||
app
|
||||
.chain(throttleRequests(1, new Duration(hours: 1)))
|
||||
.get('/once-per-hour', 'OK');
|
||||
|
||||
client = await connectTo(app);
|
||||
});
|
||||
|
||||
tearDown(() => client.close());
|
||||
|
||||
test('enforce limit', () async {
|
||||
// First request within the hour is fine
|
||||
var response = await client.get('/once-per-hour');
|
||||
print(response.body);
|
||||
expect(response.body, contains('OK'));
|
||||
|
||||
// Second request within an hour? No no no!
|
||||
response = await client.get('/once-per-hour');
|
||||
print(response.body);
|
||||
expect(response, hasStatus(429));
|
||||
});
|
||||
}
|
35
test/trust_proxy_test.dart
Normal file
35
test/trust_proxy_test.dart
Normal file
|
@ -0,0 +1,35 @@
|
|||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_security/angel_security.dart';
|
||||
import 'package:angel_test/angel_test.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
verifyProxy(RequestContext req) =>
|
||||
req.injections.containsKey(ForwardedClient) ? 'Yep' : 'Nope';
|
||||
|
||||
main() {
|
||||
Angel app;
|
||||
TestClient client;
|
||||
|
||||
setUp(() async {
|
||||
app = new Angel()
|
||||
..chain(trustProxy('127.*.*.*')).get('/hello', verifyProxy)
|
||||
..chain(trustProxy('1.2.3.4')).get('/foo', verifyProxy);
|
||||
client = await connectTo(app);
|
||||
});
|
||||
|
||||
tearDown(() => client.close());
|
||||
|
||||
test('wildcard', () async {
|
||||
var response =
|
||||
await client.get('/hello', headers: {'X-Forwarded-Host': 'foo'});
|
||||
print(response.body);
|
||||
expect(response.body, contains('Yep'));
|
||||
});
|
||||
|
||||
test('exclude unknown', () async {
|
||||
var response =
|
||||
await client.get('/foo', headers: {'X-Forwarded-Host': 'foo'});
|
||||
print(response.body);
|
||||
expect(response.body, contains('Nope'));
|
||||
});
|
||||
}
|
Loading…
Reference in a new issue