Wipe existing project

This commit is contained in:
Tobe O 2019-08-14 14:43:03 -04:00
parent c1b1c5a06e
commit 46733f311c
28 changed files with 1 additions and 1768 deletions

View file

@ -1 +0,0 @@

View file

@ -1,9 +0,0 @@
/// Angel middleware designed to enhance application security.
library angel_security;
export 'src/ban.dart';
export 'src/csrf.dart';
export 'src/permissions.dart';
export 'src/sanitize.dart';
export 'src/throttle.dart';
export 'src/trust_proxy.dart';

View file

@ -1,11 +0,0 @@
/// Service hooks to lock down user data.
library angel_security.hooks;
export 'src/hooks/add_user_to_params.dart';
export 'src/hooks/associate_current_user.dart';
export 'src/hooks/hash_password.dart';
export 'src/hooks/is_server_side.dart';
export 'src/hooks/query_with_current_user.dart';
export 'src/hooks/resrict_to_authenticated.dart';
export 'src/hooks/restrict_to_owner.dart';
export 'src/hooks/variant_permission.dart';

View file

@ -1,98 +0,0 @@
import 'dart:io';
import 'package:angel_framework/angel_framework.dart';
/// Throws a 403 Forbidden if the user's IP is banned.
///
/// [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.
RequestHandler banIp(filter,
{String message =
'Your IP address is forbidden from accessing this server.'}) {
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(RegExp(input.replaceAll('*', '[0-9]+')));
}
} else
throw ArgumentError('Cannot use $input as an IP 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 false;
else if (input is InternetAddress && input.address == ip)
return false;
else if (input is String && input == ip) return false;
}
return true;
}
if (!check()) throw 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.
RequestHandler 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(RegExp(input.replaceAll('*', '[^\.]+')));
}
} else
throw 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 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 AngelHttpException.forbidden(message: message);
return true;
};
}

View file

@ -1,58 +0,0 @@
import 'dart:io';
import 'package:angel_framework/angel_framework.dart';
import 'package:uuid/uuid.dart';
final Uuid _uuid = Uuid();
/// Ensures that the request contains a correct CSRF token.
///
/// For `POST` methods, or anything requiring, provide [parseBody] as `true`.
///
/// Note that this is useless without [setCsrfToken]. If no [name] is present in the
/// session, clients will pass through.
RequestHandler verifyCsrfToken(
{bool allowCookie = false,
bool allowQuery = true,
bool parseBody = false,
String name = 'csrf_token'}) {
return (RequestContext req, res) async {
if (!req.session.containsKey(name)) {
throw AngelHttpException.forbidden(
message: 'You cannot access this resource without a CSRF token.');
}
String csrfToken;
if (parseBody) {
await req.parseBody();
}
if (allowQuery && req.queryParameters.containsKey(name))
csrfToken = req.queryParameters[name];
else if (req.hasParsedBody && req.bodyAsMap.containsKey(name))
csrfToken = req.bodyAsMap[name];
else if (allowCookie) {
var cookie =
req.cookies.firstWhere((c) => c.name == name, orElse: () => null);
if (cookie != null) csrfToken = cookie.value;
}
if (csrfToken == null)
throw AngelHttpException.badRequest(message: 'Missing CSRF token.');
String correctToken = req.session[name];
if (csrfToken != correctToken)
throw AngelHttpException.badRequest(message: 'Invalid CSRF token.');
return true;
};
}
/// Adds a CSRF token to the session, if none is present.
RequestHandler setCsrfToken({String name = 'csrf_token', bool cookie = false}) {
return (RequestContext req, res) async {
if (!req.session.containsKey(name)) req.session[name] = _uuid.v4();
if (cookie) res.cookies.add(Cookie(name, req.session[name]));
return true;
};
}

View file

@ -1,9 +0,0 @@
import 'package:angel_framework/angel_framework.dart';
/// Adds the authed user to `e.params`, only if present in `req.container`.
HookedServiceEventListener addUserToParams<User>({String as}) {
return (HookedServiceEvent e) async {
var user = await e.request?.container?.makeAsync<User>();
if (user != null) e.params[as ?? 'user'] = user;
};
}

View file

@ -1,58 +0,0 @@
import 'dart:async';
import 'dart:mirrors';
import 'package:angel_framework/angel_framework.dart';
import 'errors.dart';
import 'is_server_side.dart';
/// Adds the authed user's id to `data`.
///
///Default [idField] is `'id'`.
/// Default [ownerField] is `'userId'`.
HookedServiceEventListener associateCurrentUser<Id, Data, User>(
{String idField,
String ownerField,
String errorMessage,
bool allowNullUserId = false,
FutureOr<Id> Function(User) getId,
FutureOr<Data> Function(Id, Data) assignUserId}) {
return (HookedServiceEvent e) async {
var fieldName = ownerField?.isNotEmpty == true ? ownerField : 'userId';
var user = await e.request?.container?.makeAsync<User>();
if (user == null) {
if (!isServerSide(e))
throw AngelHttpException.forbidden(
message: errorMessage ?? Errors.NOT_LOGGED_IN);
else
return;
}
Future<Id> _getId(User user) async {
if (getId != null)
return await getId(user);
else if (user is Map)
return user[idField ?? 'id'];
else
return reflect(user).getField(Symbol(idField ?? 'id')).reflectee;
}
var id = await _getId(user);
if (id == null && allowNullUserId != true)
throw AngelHttpException.notProcessable(
message: 'Current user is missing a $fieldName field.');
Future<Data> _assignUserId(Id id, Data obj) async {
if (assignUserId != null)
return assignUserId(id, obj);
else if (obj is Map)
return obj..[fieldName] = id;
else {
reflect(obj).setField(Symbol(fieldName), id);
return obj;
}
}
e.data = await _assignUserId(id, e.data);
};
}

View file

@ -1,6 +0,0 @@
abstract class Errors {
static const String NOT_LOGGED_IN =
'You must be logged in to perform this action.';
static const String INSUFFICIENT_PERMISSIONS =
'You have insufficient permissions to access this resource.';
}

View file

@ -1,62 +0,0 @@
import 'dart:async';
import 'dart:mirrors';
import 'package:crypto/crypto.dart';
import 'package:angel_framework/angel_framework.dart';
/// Hashes a user's password using a [Hash] algorithm (Default: [sha256]).
///
/// You may provide your own functions to obtain or set a user's password,
/// or just provide a [passwordField] if you are only ever going to deal with Maps.
@deprecated
HookedServiceEventListener hashPassword<Password, User>(
{Hash hasher,
String passwordField,
FutureOr<Password> Function(User) getPassword,
setPassword(password, user)}) {
Hash h = hasher ?? sha256;
return (HookedServiceEvent e) async {
_getPassword(user) {
if (getPassword != null)
return getPassword(user);
else if (user is Map)
return user[passwordField ?? 'password'];
else if (passwordField == 'password')
return user?.password;
else
return reflect(user)
.getField(Symbol(passwordField ?? 'password'))
.reflectee;
}
_setPassword(password, user) {
if (setPassword != null)
return setPassword(password, user);
else if (user is Map)
user[passwordField ?? 'password'] = password;
else
reflect(user).setField(Symbol(passwordField ?? 'password'), password);
}
if (e.data != null) {
applyHash(user) async {
var password = (await _getPassword(user))?.toString();
if (password != null) {
var digest = h.convert(password.codeUnits);
return _setPassword(String.fromCharCodes(digest.bytes), user);
}
}
if (e.data is Iterable) {
var futures = await Future.wait(e.data.map((data) async {
await applyHash(data);
return data;
}));
e.data = futures.toList();
} else
await applyHash(e.data);
}
};
}

View file

@ -1,4 +0,0 @@
import 'package:angel_framework/angel_framework.dart';
/// Returns `true` if the event was triggered server-side.
bool isServerSide(HookedServiceEvent e) => !e.params.containsKey('provider');

View file

@ -1,50 +0,0 @@
import 'dart:async';
import 'dart:mirrors';
import 'package:angel_framework/angel_framework.dart';
import 'errors.dart';
import 'is_server_side.dart';
/// Adds the authed user's id to `params['query']`.
///
/// Default [as] is `'user_id'`.
/// Default [userKey] is `'user'`.
HookedServiceEventListener queryWithCurrentUser<Id, User>(
{String as,
String idField,
String errorMessage,
bool allowNullUserId = false,
FutureOr<Id> Function(User) getId}) {
return (HookedServiceEvent e) async {
var fieldName = idField?.isNotEmpty == true ? idField : 'id';
var user = await e.request?.container?.makeAsync<User>();
if (user == null) {
if (!isServerSide(e))
throw AngelHttpException.forbidden(
message: errorMessage ?? Errors.NOT_LOGGED_IN);
else
return;
}
Future<Id> _getId(User user) async {
if (getId != null)
return getId(user);
else if (user is Map)
return user[fieldName] as Id;
else
return reflect(user).getField(Symbol(fieldName)).reflectee as Id;
}
var id = await _getId(user);
if (id == null && allowNullUserId != true)
throw AngelHttpException.notProcessable(
message: 'Current user is missing a \'$fieldName\' field.');
var data = {as?.isNotEmpty == true ? as : 'user_id': id};
e.params['query'] = e.params.containsKey('query')
? (e.params['query']..addAll(data))
: data;
};
}

View file

@ -1,19 +0,0 @@
import 'package:angel_framework/angel_framework.dart';
import 'errors.dart';
import 'is_server_side.dart';
/// Restricts the service method to authed users only.
HookedServiceEventListener restrictToAuthenticated<User>(
{String errorMessage}) {
return (HookedServiceEvent e) async {
var user = await e.request?.container?.makeAsync<User>();
if (user == null) {
if (!isServerSide(e))
throw AngelHttpException.forbidden(
message: errorMessage ?? Errors.NOT_LOGGED_IN);
else
return;
}
};
}

View file

@ -1,62 +0,0 @@
import 'dart:async';
import 'dart:mirrors';
import 'package:angel_framework/angel_framework.dart';
import 'errors.dart';
import 'is_server_side.dart';
/// Restricts users to accessing only their own resources.
HookedServiceEventListener restrictToOwner<Id, Data, User>(
{String idField,
String ownerField,
String errorMessage,
FutureOr<Id> Function(User) getId,
FutureOr<Id> Function(Data) getOwnerId}) {
return (HookedServiceEvent e) async {
if (!isServerSide(e)) {
var user = await e.request?.container?.makeAsync<User>();
if (user == null)
throw AngelHttpException.notAuthenticated(
message:
'The current user is missing. You must not be authenticated.');
Future<Id> _getId(User user) async {
if (getId != null)
return getId(user);
else if (user is Map)
return user[idField ?? 'id'];
else
return reflect(user).getField(Symbol(idField ?? 'id')).reflectee;
}
var id = await _getId(user);
if (id == null) throw Exception('The current user has no ID.');
var resource = await e.service.read(
e.id,
{}
..addAll(e.params ?? {})
..remove('provider')) as Data;
if (resource != null) {
Future<Id> _getOwner(Data obj) async {
if (getOwnerId != null)
return await getOwnerId(obj);
else if (obj is Map)
return obj[ownerField ?? 'user_id'];
else
return reflect(obj)
.getField(Symbol(ownerField ?? 'userId'))
.reflectee;
}
var ownerId = await _getOwner(resource);
if ((ownerId is Iterable && !ownerId.contains(id)) || ownerId != id)
throw AngelHttpException.forbidden(
message: errorMessage ?? Errors.INSUFFICIENT_PERMISSIONS);
}
}
};
}

View file

@ -1,32 +0,0 @@
import 'package:angel_framework/angel_framework.dart';
import '../permissions.dart';
/// Generates a [Permission] based on the situation, and runs it as a hook.
///
/// This is ideal for cases when you want to limit permissions to a dynamic
/// resource.
HookedServiceEventListener variantPermission(
createPermission(HookedServiceEvent e),
{String errorMessage,
userKey,
bool owner = false,
getRoles(user),
getId(user),
getOwner(obj)}) {
return (HookedServiceEvent e) async {
var permission = await createPermission(e);
if (permission is PermissionBuilder) permission = permission.toPermission();
if (permission is! Permission)
throw ArgumentError(
'createPermission must generate a Permission, whether synchronously or asynchronously.');
await permission.toHook(
errorMessage: errorMessage,
userKey: userKey,
owner: owner,
getRoles: getRoles,
getId: getId,
getOwner: getOwner)(e);
};
}

View file

@ -1,164 +0,0 @@
import 'dart:async';
import 'package:angel_framework/angel_framework.dart';
import 'hooks/errors.dart';
import 'hooks/restrict_to_owner.dart';
/// Easy mechanism to restrict access to services or routes.
class Permission {
/// A string representation of the minimum required privilege required
/// to access a resource.
final String minimum;
Permission(this.minimum);
/// Creates a hook that restricts a service method to users with this
/// permission, or if they are the resource [owner].
///
/// [getId] and [getOwner] are passed to [restrictToOwner], along with
/// [idField], [ownerField], [userKey] and [errorMessage].
HookedServiceEventListener toHook<Id, Data, User>(
{String errorMessage,
String idField,
String ownerField,
bool owner = false,
FutureOr<Iterable<String>> Function(User) getRoles,
FutureOr<Id> Function(User) getId,
FutureOr<Id> Function(Data) getOwnerId}) {
return (HookedServiceEvent e) async {
if (e.params.containsKey('provider')) {
var user = await e.request?.container?.makeAsync<User>();
if (user == null)
throw AngelHttpException.forbidden(
message: errorMessage ?? Errors.INSUFFICIENT_PERMISSIONS);
var roleFinder = getRoles ?? (user) => <String>[];
var roles = (await roleFinder(user)).toList();
if (!roles.any(verify)) {
// Try owner if the roles are not in-place
if (owner == true) {
var listener = restrictToOwner<Id, Data, User>(
idField: idField,
ownerField: ownerField,
errorMessage: errorMessage,
getId: getId,
getOwnerId: getOwnerId);
await listener(e);
} else
throw AngelHttpException.forbidden(
message: errorMessage ?? Errors.INSUFFICIENT_PERMISSIONS);
}
}
};
}
/// Restricts a route to users who have sufficient permissions.
RequestHandler toMiddleware<User>({String message, getRoles(user)}) {
return (RequestContext req, ResponseContext res) async {
var user = await req.container.makeAsync<User>();
if (user == null)
throw AngelHttpException.forbidden(
message: message ??
'You have insufficient permissions to perform this action.');
var roleFinder = getRoles ?? (user) async => user.roles ?? [];
List<String> roles = (await roleFinder(user)).toList();
if (!roles.any(verify))
throw AngelHttpException.forbidden(
message: message ??
'You have insufficient permissions to perform this action.');
return true;
};
}
/// Returns `true` if the [given] permission string
/// represents a sufficient permission, matching the [minimum].
bool verify(String given) {
bool verifyOne(String minimum) {
if (minimum == '*') return true;
var minSplit = minimum.split(':');
var split = given.split(':');
for (int i = 0; i < minSplit.length; i++) {
if (i >= split.length) return false;
var min = minSplit[i], giv = split[i];
if (min == '*' || min == giv) {
if (i >= minSplit.length - 1)
return true;
else
continue;
} else
return false;
}
return false;
}
var minima = minimum
.split('|')
.map((str) => str.trim())
.where((str) => str.isNotEmpty);
return minima.any(verifyOne);
}
@override
String toString() => 'Permission: $minimum';
}
/// Builds [Permission]s.
class PermissionBuilder {
String _min;
/// A minimum
PermissionBuilder(this._min);
factory PermissionBuilder.wildcard() => PermissionBuilder('*');
PermissionBuilder operator +(other) {
if (other is String)
return add(other);
else if (other is PermissionBuilder)
return add(other._min);
else if (other is Permission)
return add(other.minimum);
else
throw ArgumentError(
'Cannot add a ${other.runtimeType} to a PermissionBuilder.');
}
PermissionBuilder operator |(other) {
if (other is String)
return or(PermissionBuilder(other));
else if (other is PermissionBuilder)
return or(other);
else if (other is Permission)
return or(PermissionBuilder(other.minimum));
else
throw ArgumentError(
'Cannot or a ${other.runtimeType} and a PermissionBuilder.');
}
/// Adds another level of [constraint].
PermissionBuilder add(String constraint) =>
PermissionBuilder('$_min:$constraint');
/// Adds a wildcard permission.
PermissionBuilder allowAll() => add('*');
/// Duplicates this builder.
PermissionBuilder clone() => PermissionBuilder(_min);
/// Allows an alternative permission.
PermissionBuilder or(PermissionBuilder other) =>
PermissionBuilder('$_min | ${other._min}');
/// Builds a [Permission].
Permission toPermission() => Permission(_min);
}

View file

@ -1,56 +0,0 @@
import 'dart:convert';
import 'package:angel_framework/angel_framework.dart';
final Map<Pattern, String> defaultSanitizers = {
RegExp(r'<\s*s\s*c\s*r\s*i\s*p\s*t\s*>.*<\s*\/\s*s\s*c\s*r\s*i\s*p\s*t\s*>',
caseSensitive: false): ''
};
/// Mitigates XSS risk by sanitizing user HTML input.
///
/// You can also provide a Map of patterns to [replace].
///
/// You can sanitize the [body] or [query] (both `true` by default).
RequestHandler sanitizeHtmlInput(
{bool body = true,
bool query = true,
Map<Pattern, String> replace = const {}}) {
var sanitizers = Map<Pattern, String>.from(defaultSanitizers)
..addAll(replace ?? {});
return (req, res) async {
if (body) {
await req.parseBody();
_sanitizeMap(req.bodyAsMap, sanitizers);
}
if (query) _sanitizeMap(req.queryParameters, sanitizers);
return true;
};
}
_sanitize(v, Map<Pattern, String> sanitizers) {
if (v is String) {
var str = v;
sanitizers.forEach((needle, replace) {
str = str.replaceAll(needle, replace);
});
return htmlEscape.convert(str);
} else if (v is Map) {
_sanitizeMap(v, sanitizers);
return v;
} else if (v is Iterable) {
bool isList = v is List;
var mapped = v.map((x) => _sanitize(x, sanitizers));
return isList ? mapped.toList() : mapped;
} else
return v;
}
void _sanitizeMap(Map data, Map<Pattern, String> sanitizers) {
data.forEach((k, v) {
data[k] = _sanitize(v, sanitizers);
});
}

View file

@ -1,54 +0,0 @@
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.
RequestHandler 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 = 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 AngelHttpException(null,
statusCode: 429, message: message ?? '429 Too Many Requests');
}
return true;
};
}

View file

@ -1,83 +0,0 @@
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.
RequestHandler 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(RegExp(input.replaceAll('*', '[0-9]+')));
}
} else
throw 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.container
.registerSingleton<ForwardedClient>(_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 =>
Map<String, List<String>>.unmodifiable(_headers);
}

View file

@ -6,7 +6,7 @@ homepage: https://github.com/angel-dart/security
environment: environment:
sdk: ">=2.0.0-dev <3.0.0" sdk: ">=2.0.0-dev <3.0.0"
dependencies: dependencies:
angel_framework: ^2.0.0-alpha angel_framework: ^2.0.0
dev_dependencies: dev_dependencies:
angel_auth: ^2.0.0 angel_auth: ^2.0.0
angel_test: ^2.0.0 angel_test: ^2.0.0
@ -14,6 +14,3 @@ dev_dependencies:
console: ^3.0.0 console: ^3.0.0
pedantic: ^1.0.0 pedantic: ^1.0.0
test: ^1.0.0 test: ^1.0.0
dependency_overrides:
angel_framework:
path: ../framework

View file

@ -1,52 +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 = Angel()
..chain([banIp('*.*.*.*')]).get('/ban', (req, res) => 'WTF')
..chain([banOrigin('*')]).get('/ban-origin', (req, res) => 'WTF')
..chain([banOrigin('*.foo.bar')])
.get('/allow-origin', (req, res) => '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'));
});
});
}

View file

@ -1,52 +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:http/http.dart' as http;
import 'package:test/test.dart';
final RegExp _sessId = RegExp(r'DARTSESSID=([^;]+);');
main() async {
Angel app;
TestClient client;
setUp(() async {
app = Angel();
app
..chain([verifyCsrfToken()]).get('/valid', (req, res) => 'Valid!')
..fallback(setCsrfToken());
client = await connectTo(app);
});
tearDown(() => client.close());
test('need pre-existing token', () async {
var response = await client.get('/valid?csrf_token=evil');
print(response.body);
expect(response, hasStatus(403));
});
test('fake token', () async {
// Get a valid CSRF, but ignore it.
var response = await client.get('/');
var sessionId = getCookie(response);
response = await client.get(
Uri(path: '/valid', queryParameters: {'csrf_token': 'evil'}),
headers: {'cookie': 'DARTSESSID=$sessionId'});
print(response.body);
expect(response, hasStatus(400));
expect(response.body.contains('Valid'), isFalse);
expect(response.body, contains('Invalid CSRF token'));
});
}
String getCookie(http.Response response) {
if (response.headers.containsKey('set-cookie')) {
var header = response.headers['set-cookie'];
var match = _sessId.firstMatch(header);
return match?.group(1);
} else
return null;
}

View file

@ -1,134 +0,0 @@
import 'dart:async';
import 'package:angel_framework/angel_framework.dart';
import 'package:angel_security/angel_security.dart';
import 'package:angel_security/hooks.dart' as hooks;
import 'package:angel_test/angel_test.dart';
import 'package:crypto/crypto.dart';
import 'package:logging/logging.dart';
import 'package:pedantic/pedantic.dart';
import 'package:test/test.dart';
import 'pretty_logging.dart';
void main() {
Angel app;
TestClient client;
setUp(() async {
var logger = Logger.detached('hooks_test')..onRecord.listen(prettyLog);
app = Angel(logger: logger);
client = await connectTo(app);
HookedService<String, T, Service<String, T>> serve<T>(String path,
T Function(Map) encoder, Map<String, dynamic> Function(T) decoder) {
var inner = MapService();
var mapped = inner.map(encoder, decoder);
return app.use<String, T, Service<String, T>>(path, mapped);
}
var userService =
serve<User>('/api/users', User.fromMap, (u) => u.toJson());
var houseService =
serve<House>('/api/houses', House.fromMap, (h) => h.toJson());
// Seed things up.
var pSherman = await userService.create(User('0', 'P Sherman'));
await houseService.create(House('0', pSherman.id, '42 Wallaby Way'));
await houseService.create(House('1', pSherman.id, 'Finding Nemo'));
await houseService
.create(House('1', '4', 'Should Not Appear for P. Sherman'));
// Inject a user depending on the authorization header.
app.container.registerFactory<Future<User>>((container) async {
var req = container.make<RequestContext>();
var authValue =
req.headers.value('authorization')?.replaceAll('Bearer', '')?.trim();
if (authValue == null)
throw AngelHttpException.badRequest(
message: 'Missing "authorization".');
var user = await userService.read(authValue).catchError((_) => null);
if (user == null)
throw AngelHttpException.notAuthenticated(
message: 'Invalid "authorization" ($authValue).');
return user;
});
// ACCESS CONTROL:
// A user can only see their own houses.
houseService.beforeIndexed.listen(hooks.queryWithCurrentUser<String, User>(
as: 'owner_id',
getId: (user) => user.id,
));
// A house is associated with the current user.
houseService.beforeCreated
.listen(hooks.associateCurrentUser<String, House, User>(
getId: (user) => user.id,
assignUserId: (id, house) => house.withOwner(id),
));
});
tearDown(() async {
app.logger.clearListeners();
await app.close();
unawaited(client.close());
});
test('query with current user', () async {
client.authToken = '0';
var houseService = client.service('/api/houses');
expect(await houseService.index(), [
{'id': '0', 'owner_id': '0', 'address': '42 Wallaby Way'},
{'id': '1', 'owner_id': '0', 'address': 'Finding Nemo'}
]);
});
test('associate current user', () async {
client.authToken = '0';
var houseService = client.service('/api/houses');
expect(
await houseService.create({'address': 'Hello'}),
allOf(
containsPair('address', 'Hello'),
containsPair('owner_id', '0'),
));
});
}
class User {
final String id;
final String name;
User(this.id, this.name);
static User fromMap(Map map) =>
User(map['id'] as String, map['name'] as String);
Map<String, dynamic> toJson() {
return {'id': id, 'name': name};
}
}
class House {
final String id;
final String ownerId;
final String address;
House(this.id, this.ownerId, this.address);
static House fromMap(Map map) {
print('In $map');
return House(map['id'] as String, map['owner_id'] as String,
map['address'] as String);
}
House withOwner(String newOwnerId) {
return House(id, newOwnerId, address);
}
Map<String, dynamic> toJson() {
print('Out ${{'id': id, 'owner_id': ownerId, 'address': address}}');
return {'id': id, 'owner_id': ownerId, 'address': address};
}
}

View file

@ -1,286 +0,0 @@
import 'package:angel_framework/angel_framework.dart';
import 'package:angel_security/angel_security.dart';
import 'package:angel_security/hooks.dart' as hooks;
import 'package:angel_test/angel_test.dart';
import 'package:crypto/crypto.dart';
import 'package:test/test.dart';
main() {
Angel app;
TestClient client;
setUp(() async {
app = Angel()
..fallback((req, res) async {
var xUser = req.headers.value('X-User');
if (xUser != null) {
req.container.registerSingleton(
User(id: xUser, roles: xUser == 'John' ? ['foo:bar'] : []));
}
return true;
});
app
..use('/user_data', UserDataService())
..use('/artists', ArtistService())
..use('/roled', RoledService());
(app.findService('user_data') as HookedService)
..beforeIndexed.listen(hooks.queryWithCurrentUser<String, User>())
..beforeCreated.listen(hooks.hashPassword());
app.findService('artists') as HookedService
..beforeIndexed.listen(hooks.restrictToAuthenticated<Artist>())
..beforeRead.listen(hooks.restrictToOwner<String, Artist, Artist>())
..beforeCreated
.listen(hooks.associateCurrentUser<String, Artist, Artist>());
(app.findService('roled') as HookedService)
..beforeIndexed.listen(Permission('foo:*').toHook())
..beforeRead.listen(Permission('foo:*').toHook(owner: true));
var errorHandler = app.errorHandler;
app.errorHandler = (e, req, res) {
print(e.toJson());
print(e.stackTrace);
return errorHandler(e, req, res);
};
client = await connectTo(app);
});
tearDown(() => client.close());
group('associateCurrentUser', () {
test('fail', () async {
try {
var response = await client.service('artists').create({'foo': 'bar'});
print(response);
throw StateError('Creating without userId bad request');
} catch (e) {
print(e);
expect(e, const TypeMatcher<AngelHttpException>());
var err = e as AngelHttpException;
expect(err.statusCode, equals(403));
}
});
test('succeed', () async {
var response = await client
.post('/artists', headers: {'X-User': 'John'}, body: {'foo': 'bar'});
print('Response: ${response.body}');
print('Status: ${response.statusCode}');
expect(response, allOf(hasStatus(201), isJson({'foo': 'bar'})));
});
});
group('queryWithCurrentUser', () {
test('fail', () async {
try {
var response = await client.service('user_data').index();
print(response);
throw StateError('Indexing without user forbidden');
} catch (e) {
print(e);
expect(e, const TypeMatcher<AngelHttpException>());
var err = e as AngelHttpException;
expect(err.statusCode, equals(403));
}
});
test('succeed', () async {
var response = await client.get('user_data', headers: {'X-User': 'John'});
print('Response: ${response.body}');
expect(response, allOf(hasStatus(200), isJson(['foo', 'bar'])));
});
});
test('hashPassword', () async {
var response = await client
.service('user_data')
.create({'username': 'foo', 'password': 'jdoe1'});
print('Response: ${response}');
expect(response, equals({'foo': 'bar'}));
});
group('restrictToAuthenticated', () {
test('fail', () async {
try {
var response = await client.service('artists').index();
print(response);
throw StateError('Indexing without user forbidden');
} catch (e) {
print(e);
expect(e, const TypeMatcher<AngelHttpException>());
var err = e as AngelHttpException;
expect(err.statusCode, equals(403));
}
});
test('succeed', () async {
var response = await client.get('/artists', headers: {'X-User': 'John'});
print('Response: ${response.body}');
expect(
response,
allOf(
hasStatus(200),
isJson([
{
"id": "king_of_pop",
"userId": "John",
"name": "Michael Jackson"
},
{"id": "raymond", "userId": "Bob", "name": "Usher"}
])));
});
});
group('restrictToOwner', () {
test('fail', () async {
try {
var response = await client.service('artists').read('king_of_pop');
print(response);
throw StateError('Reading without owner forbidden');
} catch (e) {
print(e);
expect(e, const TypeMatcher<AngelHttpException>());
var err = e as AngelHttpException;
expect(err.statusCode, equals(401));
}
});
test('succeed', () async {
var response =
await client.get('/artists/king_of_pop', headers: {'X-User': 'John'});
print('Response: ${response.body}');
expect(
response,
allOf(
hasStatus(200),
isJson({
"id": "king_of_pop",
"userId": "John",
"name": "Michael Jackson"
})));
});
});
group('permission restrict', () {
test('fail', () async {
try {
var response = await client.service('roled').index();
print(response);
throw StateError('Reading without roles forbidden');
} catch (e) {
print(e);
expect(e, const TypeMatcher<AngelHttpException>());
var err = e as AngelHttpException;
expect(err.statusCode, equals(403));
}
});
test('succeed', () async {
var response =
await client.get('/roled/king_of_pop', headers: {'X-User': 'John'});
print('Response: ${response.body}');
expect(
response,
allOf(
hasStatus(200),
isJson({
"id": "king_of_pop",
"userId": "John",
"name": "Michael Jackson"
})));
});
test('owner', () async {
var response =
await client.get('/roled/raymond', headers: {'X-User': 'Bob'});
print('Response: ${response.body}');
expect(
response,
allOf(hasStatus(200),
isJson({"id": "raymond", "userId": "Bob", "name": "Usher"})));
});
});
}
class User {
String id;
List<String> roles;
User({this.id, this.roles = const []});
}
class UserDataService extends Service {
static const Map<String, List> _data = const {
'John': const ['foo', 'bar']
};
@override
index([Map params]) async {
print('Params: $params');
if (params?.containsKey('query') != true)
throw AngelHttpException.badRequest(message: 'query required');
String name = params['query']['userId']?.toString();
if (!_data.containsKey(name))
throw AngelHttpException.notFound(
message: "No data found for user '$name'.");
return _data[name];
}
@override
create(data, [Map params]) async {
if (data is! Map || !data.containsKey('password'))
throw AngelHttpException.badRequest(message: 'Required password!');
var expected =
String.fromCharCodes(sha256.convert('jdoe1'.codeUnits).bytes);
if (data['password'] != (expected))
throw AngelHttpException.conflict(message: 'Passwords do not match.');
return {'foo': 'bar'};
}
}
class ArtistService extends Service<String, Artist> {
static const List<Artist> _ARTISTS = const [_MICHAEL_JACKSON, _USHER];
@override
index([params]) async => _ARTISTS;
@override
read(id, [params]) async => _ARTISTS.firstWhere((a) => a.id == id);
@override
create(data, [params]) async {
return data;
// if (data is! Map || !data.containsKey('userId'))
// throw AngelHttpException.badRequest(message: 'Required userId');
// return {'foo': 'bar'};
}
}
class Artist {
final String id, userId, name;
const Artist({this.id, this.userId, this.name});
}
const Artist _USHER = const Artist(id: 'raymond', userId: 'Bob', name: 'Usher');
const Artist _MICHAEL_JACKSON =
const Artist(id: 'king_of_pop', userId: 'John', name: 'Michael Jackson');
class RoledService extends Service<String, Artist> {
@override
index([params]) async {
return [Artist(name: 'foo')];
// return ['foo'];
}
@override
read(id, [params]) async =>
ArtistService._ARTISTS.firstWhere((a) => a.id == id);
}

View file

@ -1,151 +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';
class User {
final List<String> roles;
User(this.roles);
}
main() {
Angel app;
TestClient client;
setUp(() async {
app = Angel();
app.fallback((req, res) async {
// In real life, you'd use auth to check user roles,
// but in this case, let's just set the user manually
var xRoles = req.headers['X-Roles'];
if (xRoles?.isNotEmpty == true) {
req.container.registerSingleton(User(xRoles));
}
return true;
});
app.chain([PermissionBuilder.wildcard().toPermission().toMiddleware()]).get(
'/', (req, res) => 'Hello, world!');
app.chain([Permission('foo').toMiddleware()]).get(
'/one', (req, res) => 'Hello, world!');
app.chain([Permission('two:foo').toMiddleware()]).get(
'/two', (req, res) => 'Hello, world!');
app.chain([Permission('two:*').toMiddleware()]).get(
'/two-star', (req, res) => 'Hello, world!');
app.chain([Permission('three:foo:bar').toMiddleware()]).get(
'/three', (req, res) => 'Hello, world!');
app.chain([Permission('three:*:bar').toMiddleware()]).get(
'/three-star', (req, res) => 'Hello, world!');
app.chain([
PermissionBuilder('super')
.add('specific')
.add('permission')
.allowAll()
.or(PermissionBuilder('admin'))
.toPermission()
.toMiddleware()
]).get('/or', (req, res) => 'Hello, world!');
client = await connectTo(app);
});
tearDown(() => client.close());
test('open permission', () async {
var response = await client.get('/', headers: {'X-Roles': 'foo'});
print('Response: ${response.body}');
expect(response, hasStatus(200));
expect(response, isJson('Hello, world!'));
});
group('restrict', () {
test('one', () async {
var response = await client.get('/one', headers: {'X-Roles': 'foo'});
print('Response: ${response.body}');
expect(response, hasStatus(200));
expect(response, isJson('Hello, world!'));
response = await client.get('/one', headers: {'X-Roles': 'bar'});
print('Response: ${response.body}');
expect(response, hasStatus(403));
});
test('two', () async {
var response = await client.get('/two', headers: {'X-Roles': 'two:foo'});
print('Response: ${response.body}');
expect(response, hasStatus(200));
expect(response, isJson('Hello, world!'));
response = await client.get('/two', headers: {'X-Roles': 'two:bar'});
print('Response: ${response.body}');
expect(response, hasStatus(403));
});
test('two with star', () async {
var response =
await client.get('/two-star', headers: {'X-Roles': 'two:foo'});
print('Response: ${response.body}');
expect(response, hasStatus(200));
expect(response, isJson('Hello, world!'));
response =
await client.get('/two-star', headers: {'X-Roles': 'three:foo'});
print('Response: ${response.body}');
expect(response, hasStatus(403));
});
test('three', () async {
var response =
await client.get('/three', headers: {'X-Roles': 'three:foo:bar'});
print('Response: ${response.body}');
expect(response, hasStatus(200));
expect(response, isJson('Hello, world!'));
response =
await client.get('/three', headers: {'X-Roles': 'three:foo:baz'});
print('Response: ${response.body}');
expect(response, hasStatus(403));
response =
await client.get('/three', headers: {'X-Roles': 'three:foz:bar'});
print('Response: ${response.body}');
expect(response, hasStatus(403));
});
test('three with star', () async {
var response = await client
.get('/three-star', headers: {'X-Roles': 'three:foo:bar'});
print('Response: ${response.body}');
expect(response, hasStatus(200));
expect(response, isJson('Hello, world!'));
response = await client
.get('/three-star', headers: {'X-Roles': 'three:foz:bar'});
print('Response: ${response.body}');
expect(response, hasStatus(200));
expect(response, isJson('Hello, world!'));
response = await client
.get('/three-star', headers: {'X-Roles': 'three:foo:baz'});
print('Response: ${response.body}');
expect(response, hasStatus(403));
});
});
test('or', () async {
var response = await client.get('/or', headers: {'X-Roles': 'admin'});
print('Response: ${response.body}');
expect(response, hasStatus(200));
expect(response, isJson('Hello, world!'));
response = await client
.get('/or', headers: {'X-Roles': 'not:specific:enough:i:guess'});
print('Response: ${response.body}');
expect(response, hasStatus(403));
});
}

View file

@ -1,29 +0,0 @@
import 'package:console/console.dart';
import 'package:logging/logging.dart';
/// Prints the contents of a [LogRecord] with pretty colors.
prettyLog(LogRecord record) async {
var pen = TextPen();
chooseLogColor(pen.reset(), record.level);
pen(record.toString());
if (record.error != null) pen(record.error.toString());
if (record.stackTrace != null) pen(record.stackTrace.toString());
pen();
}
/// Chooses a color based on the logger [level].
void chooseLogColor(TextPen pen, Level level) {
if (level == Level.SHOUT)
pen.darkRed();
else if (level == Level.SEVERE)
pen.red();
else if (level == Level.WARNING)
pen.yellow();
else if (level == Level.INFO)
pen.magenta();
else if (level == Level.FINER)
pen.blue();
else if (level == Level.FINEST) pen.darkBlue();
}

View file

@ -1,126 +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:angel_validate/server.dart';
import 'package:http_parser/http_parser.dart';
import 'package:logging/logging.dart';
import 'package:matcher/matcher.dart';
import 'package:test/test.dart';
import 'pretty_logging.dart';
final Validator untrustedSchema = Validator({'html*': isString});
main() async {
Angel app;
TestClient client;
setUp(() async {
var logger = Logger.detached('angel_security')..onRecord.listen(prettyLog);
app = Angel(logger: logger);
app.chain([validate(untrustedSchema), sanitizeHtmlInput()])
..post('/untrusted', (RequestContext req, ResponseContext res) async {
String untrusted = req.bodyAsMap['html'];
res
..contentType = MediaType('text', 'html')
..write('''
<!DOCTYPE html>
<html>
<head>
<title>Potential Security Hole</title>
</head>
<body>$untrusted</body>
</html>''');
})
..post('/attribute', (RequestContext req, ResponseContext res) async {
String untrusted = req.bodyAsMap['html'];
res
..contentType = MediaType('text', 'html')
..write('''
<!DOCTYPE html>
<html>
<head>
<title>Potential Security Hole</title>
</head>
<body>
<img src="$untrusted" />
</body>
</html>''');
});
var oldHandler = app.errorHandler;
app.errorHandler = (e, req, res) {
app.logger.severe(e, e.error, e.stackTrace);
return oldHandler(e, req, res);
};
client = await connectTo(app);
});
tearDown(() => client.close());
group('script tag', () {
test('normal', () async {
var xss = "<script>alert('XSS')</script>";
var response = await client.post('/untrusted', body: {'html': xss});
print(response.body);
expect(response.body.contains(xss), isFalse);
expect(response.body.toLowerCase().contains('<script>'), isFalse);
});
test('mixed case', () async {
var xss = "<scRIpT>alert('XSS')</sCRIpt>";
var response = await client.post('/untrusted', body: {'html': xss});
print(response.body);
expect(response.body.contains(xss), isFalse);
expect(response.body.toLowerCase().contains('<script>'), isFalse);
});
test('spaces', () async {
var xss = "< s c rip t>alert('XSS')</scr ip t>";
var response = await client.post('/untrusted', body: {'html': xss});
print(response.body);
expect(response.body.contains(xss), isFalse);
expect(response.body.toLowerCase().contains('<script>'), isFalse);
});
test('lines', () async {
var xss = "<scri\npt>\n\nalert('XSS')\t\n</sc\nri\npt>";
var response = await client.post('/untrusted', body: {'html': xss});
print(response.body);
expect(response.body.contains(xss), isFalse);
expect(response.body.toLowerCase().contains('<script>'), isFalse);
});
test('accents', () async {
var xss = '''<IMG SRC=`javascript:alert("RSnake says, 'XSS'")`>''';
var response = await client.post('/untrusted', body: {'html': xss});
print(response.body);
expect(response.body.contains(xss), isFalse);
expect(response.body.toLowerCase().contains('<script>'), isFalse);
});
});
test('quotes', () async {
var xss = '" onclick="<script>alert(\'XSS!\')</script>"';
var response = await client.post('/attribute', body: {'html': xss});
print(response.body);
expect(response.body.contains(xss), isFalse);
expect(response.body.toLowerCase().contains('<script>'), isFalse);
});
test('javascript:evil', () async {
var xss = 'javascript:alert(\'XSS!\')';
var response = await client.post('/attribute', body: {'html': xss});
print(response.body);
expect(response.body.contains(xss), isFalse);
expect(response.body.toLowerCase().contains(xss), isFalse);
});
test('style attribute', () async {
var xss = "background-image: url(jaVAscRiPt:alert('XSS'))";
var response = await client.post('/attribute', body: {'html': xss});
print(response.body);
expect(response.body.contains(xss), isFalse);
expect(response.body.toLowerCase().contains(xss), isFalse);
});
}

View file

@ -1,63 +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 = Angel();
app.chain([throttleRequests(1, Duration(hours: 1))]).get(
'/once-per-hour', (req, res) => 'OK');
app.chain([throttleRequests(3, Duration(minutes: 1))]).get(
'/thrice-per-minute', (req, res) => 'OK');
client = await connectTo(app);
});
tearDown(() => client.close());
test('once per hour', () async {
// First request within the hour is fine
var response = await client.get('/once-per-hour', headers: {
'accept': 'application/json',
});
print(response.body);
expect(response, hasBody('"OK"'));
// Second request within an hour? No no no!
response = await client.get('/once-per-hour', headers: {
'accept': 'application/json',
});
print(response.body);
expect(response, isAngelHttpException(statusCode: 429));
});
test('thrice per minute', () async {
// First request within the minute is fine
var response = await client.get('/thrice-per-minute');
print(response.body);
expect(response, hasBody('"OK"'));
// Second request within the minute is fine
response = await client.get('/thrice-per-minute');
print(response.body);
expect(response, hasBody('"OK"'));
// Third request within the minute is fine
response = await client.get('/thrice-per-minute');
print(response.body);
expect(response, hasBody('"OK"'));
// Fourth request within a minute? No no no!
response = await client.get('/thrice-per-minute', headers: {
'accept': 'application/json',
});
print(response.body);
expect(response, isAngelHttpException(statusCode: 429));
});
}

View file

@ -1,35 +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';
verifyProxy(RequestContext req, ResponseContext res) =>
req.container.has<ForwardedClient>() ? 'Yep' : 'Nope';
main() {
Angel app;
TestClient client;
setUp(() async {
app = 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'));
});
}