diff --git a/example/main.dart b/example/main.dart index e69de29b..8b137891 100644 --- a/example/main.dart +++ b/example/main.dart @@ -0,0 +1 @@ + diff --git a/lib/angel_cors.dart b/lib/angel_cors.dart index 1699a387..8d376f89 100644 --- a/lib/angel_cors.dart +++ b/lib/angel_cors.dart @@ -1,82 +1,100 @@ /// Angel CORS middleware. library angel_cors; +import 'dart:async'; + import 'package:angel_framework/angel_framework.dart'; import 'src/cors_options.dart'; export 'src/cors_options.dart'; /// Determines if a request origin is CORS-able. -typedef bool CorsFilter(String origin); +typedef bool _CorsFilter(String origin); -bool _isOriginAllowed(String origin, allowedOrigin) { +bool _isOriginAllowed(String origin, [allowedOrigin]) { allowedOrigin ??= []; - if (allowedOrigin is List) { + if (allowedOrigin is Iterable) { return allowedOrigin.any((x) => _isOriginAllowed(origin, x)); } else if (allowedOrigin is String) { return origin == allowedOrigin; } else if (allowedOrigin is RegExp) { return origin != null && allowedOrigin.hasMatch(origin); - } else if (origin != null && allowedOrigin is CorsFilter) { + } else if (origin != null && allowedOrigin is _CorsFilter) { return allowedOrigin(origin); } else { return allowedOrigin != false; } } +/// On-the-fly configures the [cors] handler. Use this when the context of the surrounding request +/// is necessary to decide how to handle an incoming request. +Future Function(RequestContext, ResponseContext) dynamicCors( + FutureOr Function(RequestContext, ResponseContext) f) { + return (req, res) async { + var opts = await f(req, res); + var handler = cors(opts); + return await handler(req, res); + }; +} + /// Applies the given [CorsOptions]. -RequestMiddleware cors([CorsOptions options]) { - final opts = options ?? new CorsOptions(); +Future Function(RequestContext, ResponseContext) cors( + [CorsOptions options]) { + options ??= CorsOptions(); - return (RequestContext req, ResponseContext res) async { - // Access-Control-Allow-Credentials - if (opts.credentials == true) { - res.headers['Access-Control-Allow-Credentials'] = 'true'; + return (req, res) async { + // access-control-allow-credentials + if (options.credentials == true) { + res.headers['access-control-allow-credentials'] = 'true'; } - // Access-Control-Allow-Headers - if (req.method == 'OPTIONS' && opts.allowedHeaders.isNotEmpty) { - res.headers['Access-Control-Allow-Headers'] = - opts.allowedHeaders.join(','); - } else if (req.headers['Access-Control-Request-Headers'] != null) { - res.headers['Access-Control-Allow-Headers'] = - req.headers.value('Access-Control-Request-Headers'); + // access-control-allow-headers + if (req.method == 'OPTIONS' && options.allowedHeaders.isNotEmpty) { + res.headers['access-control-allow-headers'] = + options.allowedHeaders.join(','); + } else if (req.headers['access-control-request-headers'] != null) { + res.headers['access-control-allow-headers'] = + req.headers.value('access-control-request-headers'); } - // Access-Control-Expose-Headers - if (opts.exposedHeaders.isNotEmpty) { - res.headers['Access-Control-Expose-Headers'] = - opts.exposedHeaders.join(','); + // access-control-expose-headers + if (options.exposedHeaders.isNotEmpty) { + res.headers['access-control-expose-headers'] = + options.exposedHeaders.join(','); } - // Access-Control-Allow-Methods - if (req.method == 'OPTIONS' && opts.methods.isNotEmpty) { - res.headers['Access-Control-Allow-Methods'] = opts.methods.join(','); + // access-control-allow-methods + if (req.method == 'OPTIONS' && options.methods.isNotEmpty) { + res.headers['access-control-allow-methods'] = options.methods.join(','); } - // Access-Control-Max-Age - if (req.method == 'OPTIONS' && opts.maxAge != null) { - res.headers['Access-Control-Max-Age'] = opts.maxAge.toString(); + // access-control-max-age + if (req.method == 'OPTIONS' && options.maxAge != null) { + res.headers['access-control-max-age'] = options.maxAge.toString(); } - // Access-Control-Allow-Origin - if (opts.origin == false || opts.origin == '*') { - res.headers['Access-Control-Allow-Origin'] = '*'; - } else if (opts.origin is String) { + // access-control-allow-origin + if (options.origin == false || options.origin == '*') { + res.headers['access-control-allow-origin'] = '*'; + } else if (options.origin is String) { res - ..headers['Access-Control-Allow-Origin'] = opts.origin - ..headers['Vary'] = 'Origin'; + ..headers['access-control-allow-origin'] = options.origin as String + ..headers['vary'] = 'origin'; } else { bool isAllowed = - _isOriginAllowed(req.headers.value('Origin'), opts.origin); + _isOriginAllowed(req.headers.value('origin'), options.origin); - res.headers['Access-Control-Allow-Origin'] = - isAllowed ? req.headers.value('Origin') : false.toString(); + res.headers['access-control-allow-origin'] = + isAllowed ? req.headers.value('origin') : false.toString(); if (isAllowed) { - res.headers['Vary'] = 'Origin'; + res.headers['vary'] = 'origin'; } } - return req.method != 'OPTIONS' || opts.preflightContinue; + if (req.method != 'OPTIONS') return true; + res.statusCode = options.successStatus ?? 204; + res.contentLength = 0; + await res.close(); + return options.preflightContinue; }; } diff --git a/lib/src/cors_options.dart b/lib/src/cors_options.dart index f8bce33a..34f07f40 100644 --- a/lib/src/cors_options.dart +++ b/lib/src/cors_options.dart @@ -1,5 +1,5 @@ /// CORS configuration options. -/// +/// /// The default configuration is the equivalent of: /// ///```json @@ -14,7 +14,7 @@ class CorsOptions { final List allowedHeaders = []; /// Configures the **Access-Control-Allow-Credentials** CORS header. Set to `true` to pass the header, otherwise it is omitted. - bool credentials; + final bool credentials; /// Configures the **Access-Control-Expose-Headers** CORS header. Expects a comma-delimited string (ex: 'Content-Range,X-Content-Range') or an array (ex: `['Content-Range', 'X-Content-Range']`). If not specified, no custom headers are exposed. final List exposedHeaders = []; @@ -22,7 +22,10 @@ class CorsOptions { /// Configures the **Access-Control-Max-Age** CORS header. Set to an integer to pass the header, otherwise it is omitted. /// /// Default: `null` - int maxAge = null; + final int maxAge; + + /// The status code to be sent on successful `OPTIONS` requests, if [preflightContinue] is `false`. + final int successStatus; /// Configures the **Access-Control-Allow-Methods** CORS header. Expects a comma-delimited string (ex: 'GET,PUT,POST') or an array (ex: `['GET', 'PUT', 'POST']`). /// @@ -35,21 +38,21 @@ class CorsOptions { /// - `String` - set `origin` to a specific origin. For example if you set it to `"http://example.com"` only requests from "http://example.com" will be allowed. /// - `RegExp` - set `origin` to a regular expression pattern which will be used to test the request origin. If it's a match, the request origin will be reflected. For example the pattern `/example\.com$/` will reflect any request that is coming from an origin ending with "example.com". /// - `Array` - set `origin` to an array of valid origins. Each origin can be a `String` or a `RegExp`. For example `["http://example1.com", /\.example2\.com$/]` will accept any request from "http://example1.com" or from a subdomain of "example2.com". - /// - `Function` - set `origin` to a function implementing some custom logic. The function takes the request origin as the first parameter and a callback (which expects the signature `err [object], allow [bool]`) as the second. + /// - `bool Function(String)` - set `origin` to a function implementing some custom logic. The function takes the request origin as the first parameter and returns a [bool]. /// /// Default: `'*'` - var origin; + final origin; - /// Pass the CORS preflight response to the next handler. + /// If `false`, then the [cors] handler will terminate the response after performing its logic. /// /// Default: `false` - bool preflightContinue; + final bool preflightContinue; CorsOptions( - {List allowedHeaders: const [], + {Iterable allowedHeaders = const [], this.credentials, this.maxAge, - List methods: const [ + Iterable methods = const [ 'GET', 'HEAD', 'PUT', @@ -57,9 +60,10 @@ class CorsOptions { 'POST', 'DELETE' ], - this.origin: '*', - this.preflightContinue: false, - List exposedHeaders: const []}) { + this.origin = '*', + this.successStatus = 204, + this.preflightContinue = false, + Iterable exposedHeaders = const []}) { if (allowedHeaders != null) this.allowedHeaders.addAll(allowedHeaders); if (methods != null) this.methods.addAll(methods); diff --git a/test/basic_test.dart b/test/basic_test.dart deleted file mode 100644 index 66c11978..00000000 --- a/test/basic_test.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'dart:io'; -import 'package:angel_framework/angel_framework.dart'; -import 'package:angel_cors/angel_cors.dart'; -import 'package:http/http.dart' as http; -import 'package:test/test.dart'; - -main() { - Angel app; - http.Client client; - HttpServer server; - String url; - - setUp(() async { - app = new Angel() - ..before.add(cors(new CorsOptions())) - ..post('/', (req, res) async { - res.write('hello world'); - return false; - }) - ..all('*', () { - throw new AngelHttpException.notFound(); - }); - - server = await app.startServer(); - url = 'http://${server.address.address}:${server.port}'; - client = new http.Client(); - }); - - tearDown(() async { - await server.close(force: true); - app = null; - client = null; - url = null; - }); - - test('POST works', () async { - final response = await client.post(url); - expect(response.statusCode, equals(200)); - print('Response: ${response.body}'); - print('Headers: ${response.headers}'); - expect(response.headers['access-control-allow-origin'], equals('*')); - }); - - test('mirror headers', () async { - final response = await client - .post(url, headers: {'access-control-request-headers': 'foo'}); - expect(response.statusCode, equals(200)); - print('Response: ${response.body}'); - print('Headers: ${response.headers}'); - expect(response.headers['access-control-allow-headers'], equals('foo')); - }); -} diff --git a/test/cors_test.dart b/test/cors_test.dart new file mode 100644 index 00000000..e347a013 --- /dev/null +++ b/test/cors_test.dart @@ -0,0 +1,174 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:angel_cors/angel_cors.dart'; +import 'package:http/http.dart' as http; +import 'package:test/test.dart'; + +main() { + Angel app; + AngelHttp server; + http.Client client; + + setUp(() async { + app = Angel() + ..options('/credentials', cors(CorsOptions(credentials: true))) + ..options('/credentials_d', + dynamicCors((req, res) => CorsOptions(credentials: true))) + ..options( + '/headers', cors(CorsOptions(exposedHeaders: ['x-foo', 'x-bar']))) + ..options('/max_age', cors(CorsOptions(maxAge: 250))) + ..options('/methods', cors(CorsOptions(methods: ['GET', 'POST']))) + ..get( + '/originl', + chain([ + cors(CorsOptions( + origin: ['foo.bar', 'baz.quux'], + )), + (req, res) => req.headers['origin'] + ])) + ..get( + '/origins', + chain([ + cors(CorsOptions( + origin: 'foo.bar', + )), + (req, res) => req.headers['origin'] + ])) + ..get( + '/originr', + chain([ + cors(CorsOptions( + origin: RegExp(r'^foo\.[^x]+$'), + )), + (req, res) => req.headers['origin'] + ])) + ..get( + '/originp', + chain([ + cors(CorsOptions( + origin: (String s) => s.endsWith('.bar'), + )), + (req, res) => req.headers['origin'] + ])) + ..options('/status', cors(CorsOptions(successStatus: 418))) + ..fallback(cors(CorsOptions())) + ..post('/', (req, res) async { + res.write('hello world'); + }) + ..fallback((req, res) => throw AngelHttpException.notFound()); + + server = AngelHttp(app); + await server.startServer('127.0.0.1', 0); + client = http.Client(); + }); + + tearDown(() async { + await server.close(); + app = null; + client = null; + }); + + test('status 204 by default', () async { + var rq = http.Request('OPTIONS', server.uri.replace(path: '/max_age')); + var response = await client.send(rq).then(http.Response.fromStream); + expect(response.statusCode, 204); + }); + + test('content length 0 by default', () async { + var rq = http.Request('OPTIONS', server.uri.replace(path: '/max_age')); + var response = await client.send(rq).then(http.Response.fromStream); + expect(response.contentLength, 0); + }); + + test('custom successStatus', () async { + var rq = http.Request('OPTIONS', server.uri.replace(path: '/status')); + var response = await client.send(rq).then(http.Response.fromStream); + expect(response.statusCode, 418); + }); + + test('max age', () async { + var rq = http.Request('OPTIONS', server.uri.replace(path: '/max_age')); + var response = await client.send(rq).then(http.Response.fromStream); + expect(response.headers['access-control-max-age'], '250'); + }); + + test('methods', () async { + var rq = http.Request('OPTIONS', server.uri.replace(path: '/methods')); + var response = await client.send(rq).then(http.Response.fromStream); + expect(response.headers['access-control-allow-methods'], 'GET,POST'); + }); + + test('dynamicCors.credentials', () async { + var rq = + http.Request('OPTIONS', server.uri.replace(path: '/credentials_d')); + var response = await client.send(rq).then(http.Response.fromStream); + expect(response.headers['access-control-allow-credentials'], 'true'); + }); + + test('credentials', () async { + var rq = http.Request('OPTIONS', server.uri.replace(path: '/credentials')); + var response = await client.send(rq).then(http.Response.fromStream); + expect(response.headers['access-control-allow-credentials'], 'true'); + }); + + test('exposed headers', () async { + var rq = http.Request('OPTIONS', server.uri.replace(path: '/headers')); + var response = await client.send(rq).then(http.Response.fromStream); + expect(response.headers['access-control-expose-headers'], 'x-foo,x-bar'); + }); + + test('invalid origin', () async { + var response = await client.get(server.uri.replace(path: '/originl'), + headers: {'origin': 'foreign'}); + expect(response.headers['access-control-allow-origin'], 'false'); + }); + + test('list origin', () async { + var response = await client.get(server.uri.replace(path: '/originl'), + headers: {'origin': 'foo.bar'}); + expect(response.headers['access-control-allow-origin'], 'foo.bar'); + expect(response.headers['vary'], 'origin'); + response = await client.get(server.uri.replace(path: '/originl'), + headers: {'origin': 'baz.quux'}); + expect(response.headers['access-control-allow-origin'], 'baz.quux'); + expect(response.headers['vary'], 'origin'); + }); + + test('string origin', () async { + var response = await client.get(server.uri.replace(path: '/origins'), + headers: {'origin': 'foo.bar'}); + expect(response.headers['access-control-allow-origin'], 'foo.bar'); + expect(response.headers['vary'], 'origin'); + }); + + test('regex origin', () async { + var response = await client.get(server.uri.replace(path: '/originr'), + headers: {'origin': 'foo.bar'}); + expect(response.headers['access-control-allow-origin'], 'foo.bar'); + expect(response.headers['vary'], 'origin'); + }); + + test('predicate origin', () async { + var response = await client.get(server.uri.replace(path: '/originp'), + headers: {'origin': 'foo.bar'}); + expect(response.headers['access-control-allow-origin'], 'foo.bar'); + expect(response.headers['vary'], 'origin'); + }); + + test('POST works', () async { + final response = await client.post(server.uri); + expect(response.statusCode, equals(200)); + print('Response: ${response.body}'); + print('Headers: ${response.headers}'); + expect(response.headers['access-control-allow-origin'], equals('*')); + }); + + test('mirror headers', () async { + final response = await client + .post(server.uri, headers: {'access-control-request-headers': 'foo'}); + expect(response.statusCode, equals(200)); + print('Response: ${response.body}'); + print('Headers: ${response.headers}'); + expect(response.headers['access-control-allow-headers'], equals('foo')); + }); +}