From dea2ad2eb3b815ed45321b739d4cad435013d68d Mon Sep 17 00:00:00 2001 From: ersanKolay Date: Wed, 11 Mar 2026 03:06:59 +0300 Subject: [PATCH 1/6] fix(cookie_manager): deduplicate cookies when request is retried When a request is retried with the same RequestOptions, loadCookies merges previousCookies from headers with savedCookies from the jar without deduplication, causing cookies to accumulate on each retry. Filter out previousCookies that already exist in savedCookies by name, so jar cookies take precedence and no duplicates are produced. Closes https://github.com/cfug/dio/issues/2442 --- plugins/cookie_manager/CHANGELOG.md | 2 +- .../cookie_manager/lib/src/cookie_mgr.dart | 4 ++- plugins/cookie_manager/test/cookies_test.dart | 34 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/plugins/cookie_manager/CHANGELOG.md b/plugins/cookie_manager/CHANGELOG.md index 5c8f50138..11ec9141d 100644 --- a/plugins/cookie_manager/CHANGELOG.md +++ b/plugins/cookie_manager/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -*None.* +- Fix duplicate cookies when requests are retried with the same `RequestOptions`. ## 3.4.0 diff --git a/plugins/cookie_manager/lib/src/cookie_mgr.dart b/plugins/cookie_manager/lib/src/cookie_mgr.dart index a3a79d17a..4bae491b9 100644 --- a/plugins/cookie_manager/lib/src/cookie_mgr.dart +++ b/plugins/cookie_manager/lib/src/cookie_mgr.dart @@ -149,12 +149,14 @@ class CookieManager extends Interceptor { final savedCookies = await cookieJar.loadForRequest(options.uri); final previousCookies = options.headers[HttpHeaders.cookieHeader] as String?; + final savedCookieNames = savedCookies.map((c) => c.name).toSet(); final cookies = getCookies([ ...?previousCookies ?.split(';') .where((e) => e.isNotEmpty) .map((c) => _fromSetCookieValue(c)) - .whereType(), // Use .nonNulls when the minimum SDK is 3.0. + .whereType() // Use .nonNulls when the minimum SDK is 3.0. + .where((c) => !savedCookieNames.contains(c.name)), ...savedCookies, ]); return cookies; diff --git a/plugins/cookie_manager/test/cookies_test.dart b/plugins/cookie_manager/test/cookies_test.dart index b01f8ea41..86bd78b9a 100644 --- a/plugins/cookie_manager/test/cookies_test.dart +++ b/plugins/cookie_manager/test/cookies_test.dart @@ -228,6 +228,40 @@ void main() { }); }); + test('no duplicate cookies on retry', () async { + const List mockResponseCookies = [ + 'a=1; Path=/', + 'b=2; Path=/', + ]; + const exampleUrl = 'https://example.com'; + + final cookieJar = CookieJar(); + final cookieManager = CookieManager(cookieJar); + + // Save cookies from a response. + final requestOptions = RequestOptions(baseUrl: exampleUrl); + final mockResponse = Response( + requestOptions: requestOptions, + headers: Headers.fromMap( + {HttpHeaders.setCookieHeader: mockResponseCookies}, + ), + ); + await cookieManager.onResponse( + mockResponse, + MockResponseInterceptorHandler(), + ); + + // First request sets cookie header. + final firstOptions = RequestOptions(baseUrl: exampleUrl); + final firstHandler = MockRequestInterceptorHandler('a=1; b=2'); + await cookieManager.onRequest(firstOptions, firstHandler); + + // Simulate retry: onRequest is called again on the same RequestOptions + // which already has the cookie header from the first attempt. + final retryHandler = MockRequestInterceptorHandler('a=1; b=2'); + await cookieManager.onRequest(firstOptions, retryHandler); + }); + test('cookies replacement', () async { final cookies = [ Cookie('foo', 'bar')..path = '/', From 2651d732b20ab5d5f1668a960fe558b2c3c7bab4 Mon Sep 17 00:00:00 2001 From: ersanKolay Date: Fri, 13 Mar 2026 15:11:59 +0300 Subject: [PATCH 2/6] refactor(cookie_manager): align cookie deduplication with RFC 6265 Use (name, domain, path) identity per RFC 6265 Section 5.3 for cookie deduplication. Cookies parsed from the Cookie header lack domain/path metadata, so those fall back to name-only matching against saved cookies which are already URI-scoped by cookieJar.loadForRequest. --- .../cookie_manager/lib/src/cookie_mgr.dart | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/plugins/cookie_manager/lib/src/cookie_mgr.dart b/plugins/cookie_manager/lib/src/cookie_mgr.dart index 4bae491b9..a4cce2b04 100644 --- a/plugins/cookie_manager/lib/src/cookie_mgr.dart +++ b/plugins/cookie_manager/lib/src/cookie_mgr.dart @@ -133,6 +133,12 @@ class CookieManager extends Interceptor { } } + /// Returns a key that uniquely identifies a cookie per RFC 6265 Section 5.3: + /// a cookie is identified by (name, domain, path). + static String _cookieIdentity(Cookie c) { + return '${c.name}\0${c.domain ?? ''}\0${c.path ?? ''}'; + } + Cookie? _fromSetCookieValue(String value) { try { return Cookie.fromSetCookieValue(value); @@ -149,6 +155,14 @@ class CookieManager extends Interceptor { final savedCookies = await cookieJar.loadForRequest(options.uri); final previousCookies = options.headers[HttpHeaders.cookieHeader] as String?; + // Deduplicate per RFC 6265 Section 5.3: a cookie is uniquely + // identified by (name, domain, path). Cookies parsed from the + // Cookie header lack domain/path, so we fall back to name-only + // matching against saved cookies (which are already scoped to + // the request URI by cookieJar.loadForRequest). + final savedCookieIdentities = savedCookies + .map((c) => _cookieIdentity(c)) + .toSet(); final savedCookieNames = savedCookies.map((c) => c.name).toSet(); final cookies = getCookies([ ...?previousCookies @@ -156,7 +170,14 @@ class CookieManager extends Interceptor { .where((e) => e.isNotEmpty) .map((c) => _fromSetCookieValue(c)) .whereType() // Use .nonNulls when the minimum SDK is 3.0. - .where((c) => !savedCookieNames.contains(c.name)), + .where((c) { + // Header cookies lack domain/path metadata, so match by name + // against saved cookies that are already URI-scoped. + if (c.domain == null && c.path == null) { + return !savedCookieNames.contains(c.name); + } + return !savedCookieIdentities.contains(_cookieIdentity(c)); + }), ...savedCookies, ]); return cookies; From 6053c7d6e25cd0ec4ca2fba2144c58ec51e87f8c Mon Sep 17 00:00:00 2001 From: ersanKolay Date: Fri, 13 Mar 2026 15:19:20 +0300 Subject: [PATCH 3/6] test(cookie_manager): add test for same-name cookies with different paths Verifies that cookies with the same name but different paths (per RFC 6265 Section 5.3 identity) are both preserved and not incorrectly deduplicated on retry. --- plugins/cookie_manager/test/cookies_test.dart | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/plugins/cookie_manager/test/cookies_test.dart b/plugins/cookie_manager/test/cookies_test.dart index 86bd78b9a..ca1427883 100644 --- a/plugins/cookie_manager/test/cookies_test.dart +++ b/plugins/cookie_manager/test/cookies_test.dart @@ -262,6 +262,43 @@ void main() { await cookieManager.onRequest(firstOptions, retryHandler); }); + test('same-name cookies with different paths are preserved on retry', + () async { + // RFC 6265 Section 5.3: cookies are identified by (name, domain, path). + // Two cookies with the same name but different paths must both be kept. + const List mockResponseCookies = [ + 'session=abc; Path=/', + 'session=xyz; Path=/api', + ]; + const exampleUrl = 'https://example.com/api/endpoint'; + + final cookieJar = CookieJar(); + final cookieManager = CookieManager(cookieJar); + + final requestOptions = RequestOptions(baseUrl: exampleUrl); + final mockResponse = Response( + requestOptions: requestOptions, + headers: Headers.fromMap( + {HttpHeaders.setCookieHeader: mockResponseCookies}, + ), + ); + await cookieManager.onResponse( + mockResponse, + MockResponseInterceptorHandler(), + ); + + // First request: both cookies should be sent. + final firstOptions = RequestOptions(baseUrl: exampleUrl); + final firstHandler = + MockRequestInterceptorHandler('session=xyz; session=abc'); + await cookieManager.onRequest(firstOptions, firstHandler); + + // Retry: same cookies, no duplicates. + final retryHandler = + MockRequestInterceptorHandler('session=xyz; session=abc'); + await cookieManager.onRequest(firstOptions, retryHandler); + }); + test('cookies replacement', () async { final cookies = [ Cookie('foo', 'bar')..path = '/', From 7d51016c71a2e00a4d8f240fc43434708f647b01 Mon Sep 17 00:00:00 2001 From: ersanKolay Date: Sat, 14 Mar 2026 00:10:57 +0300 Subject: [PATCH 4/6] style: format cookie_mgr.dart --- plugins/cookie_manager/lib/src/cookie_mgr.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugins/cookie_manager/lib/src/cookie_mgr.dart b/plugins/cookie_manager/lib/src/cookie_mgr.dart index a4cce2b04..059395d9e 100644 --- a/plugins/cookie_manager/lib/src/cookie_mgr.dart +++ b/plugins/cookie_manager/lib/src/cookie_mgr.dart @@ -160,9 +160,8 @@ class CookieManager extends Interceptor { // Cookie header lack domain/path, so we fall back to name-only // matching against saved cookies (which are already scoped to // the request URI by cookieJar.loadForRequest). - final savedCookieIdentities = savedCookies - .map((c) => _cookieIdentity(c)) - .toSet(); + final savedCookieIdentities = + savedCookies.map((c) => _cookieIdentity(c)).toSet(); final savedCookieNames = savedCookies.map((c) => c.name).toSet(); final cookies = getCookies([ ...?previousCookies From 3cdcc70b4f5914a2fda0b16ea5de1e74f4c5680e Mon Sep 17 00:00:00 2001 From: ersanKolay Date: Sat, 14 Mar 2026 17:13:19 +0300 Subject: [PATCH 5/6] fix: replace null char separator to fix unnecessary_string_escapes lint --- plugins/cookie_manager/lib/src/cookie_mgr.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/cookie_manager/lib/src/cookie_mgr.dart b/plugins/cookie_manager/lib/src/cookie_mgr.dart index 059395d9e..32f4823f1 100644 --- a/plugins/cookie_manager/lib/src/cookie_mgr.dart +++ b/plugins/cookie_manager/lib/src/cookie_mgr.dart @@ -136,7 +136,7 @@ class CookieManager extends Interceptor { /// Returns a key that uniquely identifies a cookie per RFC 6265 Section 5.3: /// a cookie is identified by (name, domain, path). static String _cookieIdentity(Cookie c) { - return '${c.name}\0${c.domain ?? ''}\0${c.path ?? ''}'; + return '${c.name}|${c.domain ?? ''}|${c.path ?? ''}'; } Cookie? _fromSetCookieValue(String value) { From d10b2a5326344dabd3195706e40d459e0ece812c Mon Sep 17 00:00:00 2001 From: ersanKolay Date: Sun, 15 Mar 2026 02:53:28 +0300 Subject: [PATCH 6/6] refactor(cookie_manager): simplify dedup logic and improve readability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed unreachable domain/path branch — per RFC 6265 Section 4.2, the Cookie header only carries name=value pairs without domain or path metadata. Name-only matching is sufficient since saved cookies are already URI-scoped by cookieJar.loadForRequest. Extracted the previous-cookie filtering into a named variable for better readability. --- .../cookie_manager/lib/src/cookie_mgr.dart | 38 ++++++------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/plugins/cookie_manager/lib/src/cookie_mgr.dart b/plugins/cookie_manager/lib/src/cookie_mgr.dart index 32f4823f1..650027e6f 100644 --- a/plugins/cookie_manager/lib/src/cookie_mgr.dart +++ b/plugins/cookie_manager/lib/src/cookie_mgr.dart @@ -133,12 +133,6 @@ class CookieManager extends Interceptor { } } - /// Returns a key that uniquely identifies a cookie per RFC 6265 Section 5.3: - /// a cookie is identified by (name, domain, path). - static String _cookieIdentity(Cookie c) { - return '${c.name}|${c.domain ?? ''}|${c.path ?? ''}'; - } - Cookie? _fromSetCookieValue(String value) { try { return Cookie.fromSetCookieValue(value); @@ -155,28 +149,20 @@ class CookieManager extends Interceptor { final savedCookies = await cookieJar.loadForRequest(options.uri); final previousCookies = options.headers[HttpHeaders.cookieHeader] as String?; - // Deduplicate per RFC 6265 Section 5.3: a cookie is uniquely - // identified by (name, domain, path). Cookies parsed from the - // Cookie header lack domain/path, so we fall back to name-only - // matching against saved cookies (which are already scoped to - // the request URI by cookieJar.loadForRequest). - final savedCookieIdentities = - savedCookies.map((c) => _cookieIdentity(c)).toSet(); + // Per RFC 6265 Section 4.2, the Cookie header carries only name=value + // pairs without domain or path. The saved cookies are already scoped + // to the request URI by cookieJar.loadForRequest, so matching by name + // is sufficient to detect duplicates from a retried request. final savedCookieNames = savedCookies.map((c) => c.name).toSet(); + final previousList = previousCookies + ?.split(';') + .where((e) => e.isNotEmpty) + .map((c) => _fromSetCookieValue(c)) + .whereType() // Use .nonNulls when the minimum SDK is 3.0. + .where((c) => !savedCookieNames.contains(c.name)) + .toList(); final cookies = getCookies([ - ...?previousCookies - ?.split(';') - .where((e) => e.isNotEmpty) - .map((c) => _fromSetCookieValue(c)) - .whereType() // Use .nonNulls when the minimum SDK is 3.0. - .where((c) { - // Header cookies lack domain/path metadata, so match by name - // against saved cookies that are already URI-scoped. - if (c.domain == null && c.path == null) { - return !savedCookieNames.contains(c.name); - } - return !savedCookieIdentities.contains(_cookieIdentity(c)); - }), + ...?previousList, ...savedCookies, ]); return cookies;