diff --git a/lib/src/common.dart b/lib/src/common.dart index 7687d24..4a75e7b 100644 --- a/lib/src/common.dart +++ b/lib/src/common.dart @@ -1,37 +1,35 @@ import 'dart:async'; import 'dart:convert'; + import 'package:hex/hex.dart'; import 'package:crypto/crypto.dart'; import 'package:quiver/cache.dart'; +import 'cookie.dart'; + +/// A collection of common methods. class Common { const Common(); /// The cache containing the cookies semi-persistently. - static MapCache cache = MapCache(); + static MapCache cache = MapCache(); - /// Add / Replace this [Vault] [value] for the specified [key]. + /// Add this key/value pair to the [cache]. /// - /// * [key]: the key - /// * [value]: the value - static Future storageSet(String key, String value) async { + /// If a key of other is already in this [cache], its value is overwritten. + static Future storageSet(String key, CookieJar value) async { await cache.set(key, value); } - /// Returns the stash value for the specified [key] - /// - /// * [key]: the key - /// - /// Returns a [String] - static Future storageGet(String key) async { + /// The value for the given [key], or `null` if [key] is not in the [cache]. + static Future storageGet(String key) async { return await cache.get(key); } - /// Removes the mapping for a [key] from this [Vault] if it is present. - /// - /// * [key]: key whose mapping is to be removed from the [Vault] + /// Removes [key] and its associated value, if present, from the [cache]. /// - /// Returns `true` if the removal of the mapping for the specified [key] was successful. + /// Returns `true` if the key/value pair was successfully removed, + /// `false` otherwise. static Future storageRemove(String key) async { await cache.invalidate(key); return await cache.get(key) == null; diff --git a/lib/src/cookie.dart b/lib/src/cookie.dart new file mode 100644 index 0000000..68bfa7f --- /dev/null +++ b/lib/src/cookie.dart @@ -0,0 +1,152 @@ +import 'package:quiver/collection.dart'; + +/// Error thrown when assigning an invalid key to a [Map]. +class KeyError implements Exception { + final String message; + + KeyError(this.message); +} + +/// Object representation of a cookie. +class Cookie { + final String _name; + final String _value; + static final Map validAttributes = { + "comment": String, + "domain": String, + "expires": String, + "httponly": bool, + "path": String, + "max-age": String, + "secure": bool, + "samesite": String, + }; + + final _attributes = {}; + + Cookie(this._name, this._value); + + /// The name of [this]. + String get name => _name; + + /// The value of [this]. + String get value => _value; + + /// Constructs a request header. + String output({String header = "Set-Cookie"}) { + String string = "$header: $name=$value"; + + _attributes.forEach((key, value) { + if (value.runtimeType == String && value.length == 0) { + return; + } + + if (value.runtimeType == bool && value == false) { + return; + } + + string += "; $key"; + if (value.runtimeType != bool) { + string += "=$value"; + } + }); + + return string; + } + + /// Removes [key] and its associated [value], if present, from the attributes. + String remove(String key) { + return _attributes.remove(key); + } + + /// The value for the given [key], or `null` if [key] is not + /// in the attributes. + String? operator [](String key) { + return _attributes[key.toLowerCase()]; + } + + /// Associates the [key] with the given [value]. + /// + /// Throws a [KeyError] if the key isn't a valid attribute, + /// see [this.validAttributes]. + void operator []=(String key, dynamic value) { + if (validAttributes.containsKey(key.toLowerCase())) { + var attributeType = validAttributes[key.toLowerCase()]; + + switch (attributeType) { + case bool: + case String: + if (value.runtimeType == attributeType) { + _attributes[key.toLowerCase()] = value; + } + return; + default: + } + } + throw KeyError("Input key is not valid: $key."); + } + + /// Same as [this.output()]. + @override + String toString() { + return output(); + } +} + +/// A custom [Map] that will store a sort of [List] of unique [Cookie]. +class CookieJar extends DelegatingMap { + final Map _cookies = {}; + + @override + Map get delegate => _cookies; + + /// Parses a string of cookies separated by commas into a [CookieJar]. + static CookieJar parseCookiesString(String cookiesString) { + cookiesString = cookiesString.trim(); + + var result = CookieJar(); + + // Matches commas that separate cookies. + // Commas shall not be between " or ' as it would be some json string. + // We assume that there will be no commas inside keys or values. + RegExp cookiesSplit = RegExp( + r"""(?[^=]+)=(?[^;]+);?(?.*)$", + ); + + // Matches the key / value pair of an attribute of a cookie. + RegExp attributesParse = RegExp( + r"(?[^;=\s]+)(?:=(?[^=;\n]+))?", + ); + + for (var rawCookie in cookiesString.split(cookiesSplit)) { + var parsedCookie = cookieParse.firstMatch(rawCookie); + if (parsedCookie != null) { + var name = parsedCookie.namedGroup("name")!; + var value = parsedCookie.namedGroup("value")!; + var rawAttributes = parsedCookie.namedGroup("raw_attributes")!; + + var cookie = Cookie(name, value); + + for (var parsedAttribute in attributesParse.allMatches(rawAttributes)) { + var attributeKey = parsedAttribute.namedGroup("key")!; + var attributeValue = parsedAttribute.namedGroup("value"); + + if (Cookie.validAttributes.containsKey(attributeKey.toLowerCase())) { + cookie[attributeKey] = attributeValue ?? true; + } + } + + result[name] = cookie; + } + } + + return result; + } +} diff --git a/lib/src/requests.dart b/lib/src/requests.dart index 2baae9b..d3f6b5b 100644 --- a/lib/src/requests.dart +++ b/lib/src/requests.dart @@ -7,6 +7,7 @@ import 'package:http/io_client.dart' as io_client; import 'common.dart'; import 'event.dart'; +import 'cookie.dart'; enum RequestBodyEncoding { JSON, FormURLEncoded, PlainText } enum HttpMethod { GET, PUT, PATCH, POST, DELETE, HEAD } @@ -65,75 +66,53 @@ class Requests { static const int DEFAULT_TIMEOUT_SECONDS = 10; static const RequestBodyEncoding DEFAULT_BODY_ENCODING = RequestBodyEncoding.FormURLEncoded; - static final Set _cookiesKeysToIgnore = { - 'samesite', - 'path', - 'domain', - 'max-age', - 'expires', - 'secure', - 'httponly' - }; - - static Map extractResponseCookies(responseHeaders) { - var cookies = {}; - for (var key in responseHeaders.keys) { - if (Common.equalsIgnoreCase(key, 'set-cookie')) { - var cookie = responseHeaders[key].trim(); - - var separators = [',', ';', ' ']; - var lastSeparator = -1, endSeparator = 0; - cookie.split('').asMap().forEach((i, char) { - if (separators.contains(char)) { - lastSeparator = i; - } - - if(char == '=' && i > endSeparator) { - endSeparator = cookie.indexOf(';', i) > 0 ? cookie.indexOf(';', i) : cookie.length; - var value = cookie.substring(i+1, endSeparator); - var key = cookie.substring(lastSeparator+1, i).trim(); - - if (_cookiesKeysToIgnore.contains(key.toLowerCase())) { - endSeparator = 0; - } else { - cookies[key] = value; - } - } - }); - break; - } + + /// Gets the cookies of a [Response.headers] in the form of a [CookieJar]. + static CookieJar extractResponseCookies(Map responseHeaders) { + var result = CookieJar(); + var keys = responseHeaders.keys.map((e) => e.toLowerCase()); + + if (keys.contains("set-cookie")) { + var cookies = responseHeaders["set-cookie"]!; + result = CookieJar.parseCookiesString(cookies); } - return cookies; + return result; } static Future> _constructRequestHeaders( String hostname, Map? customHeaders) async { - var cookies = await getStoredCookies(hostname); - var cookie = cookies.keys.map((key) => '$key=${cookies[key]}').join('; '); var requestHeaders = {}; + + var cookies = (await getStoredCookies(hostname)).values; + var cookie = cookies.map((e) => '${e.name}=${e.value}').join("; "); + requestHeaders['cookie'] = cookie; + if (customHeaders != null) { requestHeaders.addAll(customHeaders); } + return requestHeaders; } - static Future> getStoredCookies(String hostname) async { + /// Get the [CookieJar] for the given [hostname], or an empty [CookieJar] + /// if the [hostname] is not in the cache. + static Future getStoredCookies(String hostname) async { var hostnameHash = Common.hashStringSHA256(hostname); - var cookiesJson = await Common.storageGet('cookies-$hostnameHash'); - var cookies = Common.fromJson(cookiesJson); + var cookies = await Common.storageGet('cookies-$hostnameHash'); - return cookies != null ? Map.from(cookies) : {}; + return cookies ?? CookieJar(); } + /// Associates the [hostname] with the given [cookies] into the cache. static Future setStoredCookies( - String hostname, Map cookies) async { + String hostname, CookieJar cookies) async { var hostnameHash = Common.hashStringSHA256(hostname); - var cookiesJson = Common.toJson(cookies); - await Common.storageSet('cookies-$hostnameHash', cookiesJson); + await Common.storageSet('cookies-$hostnameHash', cookies); } + /// Removes [hostname] and its associated value, if present, from the cache. static Future clearStoredCookies(String hostname) async { var hostnameHash = Common.hashStringSHA256(hostname); await Common.storageRemove('cookies-$hostnameHash'); diff --git a/test/requests_test.dart b/test/requests_test.dart index 96f4656..0e1b34b 100644 --- a/test/requests_test.dart +++ b/test/requests_test.dart @@ -1,5 +1,6 @@ import 'package:requests/requests.dart'; import 'package:requests/src/common.dart'; +import 'package:requests/src/cookie.dart'; import 'package:test/test.dart'; void _validateResponse(Response r) { @@ -142,8 +143,9 @@ void main() { String hostname = Requests.getHostname(url); expect('reqres.in:443', hostname); await Requests.clearStoredCookies(hostname); - await Requests.setStoredCookies(hostname, {'session': 'bla'}); - var cookies = await Requests.getStoredCookies(hostname); + var cookies = CookieJar.parseCookiesString("session=bla"); + await Requests.setStoredCookies(hostname, cookies); + cookies = await Requests.getStoredCookies(hostname); expect(cookies.keys.length, 1); await Requests.clearStoredCookies(hostname); cookies = await Requests.getStoredCookies(hostname); @@ -199,18 +201,68 @@ void main() { r.raiseForStatus(); }); + test('multiple Set-Cookie response header', () async { + var r = await Requests.get("http://samesitetest.com/cookies/set"); + var cookies = await Requests.extractResponseCookies(r.headers); + + expect( + cookies["StrictCookie"]!.output(), + "Set-Cookie: StrictCookie=Cookie set with SameSite=Strict; path=/; httponly; samesite=strict", + ); + expect( + cookies["LaxCookie"]!.output(), + "Set-Cookie: LaxCookie=Cookie set with SameSite=Lax; path=/; httponly; samesite=lax", + ); + expect( + cookies["SecureNoneCookie"]!.output(), + "Set-Cookie: SecureNoneCookie=Cookie set with SameSite=None and Secure; path=/; secure; httponly; samesite=none", + ); + expect( + cookies["NoneCookie"]!.output(), + "Set-Cookie: NoneCookie=Cookie set with SameSite=None; path=/; httponly; samesite=none", + ); + expect( + cookies["DefaultCookie"]!.output(), + "Set-Cookie: DefaultCookie=Cookie set without a SameSite attribute; path=/; httponly", + ); + }); + test('cookie parsing', () async { var headers = Map(); - var cookiesString = 'session=mySecret; path=/myPath; expires=Xxx, x-x-x x:x:x XXX,data=1=2=3=4; _ga=GA1.4..1563550573; ; ; ; textsize=NaN; tp_state=true; _ga=GA1.3..1563550573; __browsiUID=03b1cb22-d18d-&{"bt":"Browser","os":"Windows","osv":"10.0","m":"Desktop|Emulator","v":"Unknown","b":"Chrome","p":2}; _cb_ls=1; _cb=CaBNIWCf-db-3i9ro; _chartbeat2=..414141414.1..1; AMUUID=%; _fbp=fb.2..; adblockerfound=true '; + var cookiesString = """ + session=mySecret; path=/myPath; expires=Xxx, x-x-x x:x:x XXX, + data=1=2=3=4; _ga=GA1.4..1563550573; ; ; ; textsize=NaN; tp_state=true; _ga=GA1.3..1563550573, + __browsiUID=03b1cb22-d18d-&{"bt":"Browser","os":"Windows","osv":"10.0","m":"Desktop|Emulator","v":"Unknown","b":"Chrome","p":2}, + _cb_ls=1; _cb=CaBNIWCf-db-3i9ro; _chartbeat2=..414141414.1..1; AMUUID=%; _fbp=fb.2.., + adblockerfound=true + """; headers['set-cookie'] = cookiesString; var cookies = await Requests.extractResponseCookies(headers); - expect(cookies['session'], "mySecret"); - expect(cookies['adblockerfound'], "true"); - expect(cookies['textsize'], "NaN"); - expect(cookies['data'], "1=2=3=4"); - expect(cookies['__browsiUID'], - '03b1cb22-d18d-&{"bt":"Browser","os":"Windows","osv":"10.0","m":"Desktop|Emulator","v":"Unknown","b":"Chrome","p":2}'); + expect( + cookies["session"]!.output(), + "Set-Cookie: session=mySecret; path=/myPath; expires=Xxx, x-x-x x:x:x XXX", + ); + + expect( + cookies['data']!.output(), + "Set-Cookie: data=1=2=3=4", + ); + + expect( + cookies['__browsiUID']!.output(), + 'Set-Cookie: __browsiUID=03b1cb22-d18d-&{"bt":"Browser","os":"Windows","osv":"10.0","m":"Desktop|Emulator","v":"Unknown","b":"Chrome","p":2}', + ); + + expect( + cookies['_cb_ls']!.output(), + "Set-Cookie: _cb_ls=1", + ); + + expect( + cookies['adblockerfound']!.output(), + "Set-Cookie: adblockerfound=true", + ); }); test('from json', () async { @@ -218,4 +270,4 @@ void main() { expect(Common.fromJson(null), null); }); }); -} +} \ No newline at end of file