From 3556ae0b4f106c980edc53db862842f456d48652 Mon Sep 17 00:00:00 2001 From: Yuki Okushi Date: Sat, 11 Apr 2026 07:36:40 +0900 Subject: [PATCH] feat(web): add `cookie_raw` API (#4013) --- actix-web/CHANGES.md | 2 + actix-web/src/request.rs | 91 +++++++++++++++++++++++++++++++++++++--- actix-web/src/service.rs | 14 +++++++ 3 files changed, 102 insertions(+), 5 deletions(-) diff --git a/actix-web/CHANGES.md b/actix-web/CHANGES.md index 72f541653..b5a91cc95 100644 --- a/actix-web/CHANGES.md +++ b/actix-web/CHANGES.md @@ -2,6 +2,7 @@ ## Unreleased +- Add `HttpRequest::{cookies_raw,cookie_raw}` and `ServiceRequest::{cookies_raw,cookie_raw}` for reading request cookies without percent-decoding names and values. [#3542] - Enable dual-stack IPv6 sockets on Windows when possible so that Actix-created listeners bound to `[::]` also accept IPv4 connections. - Panic when calling `Route::to()` or `Route::service()` after `Route::wrap()` to prevent silently dropping route middleware. [#3944] - Fix `HttpRequest::{match_pattern,match_name}` reporting path-only matches when route guards disambiguate overlapping resources. [#3346] @@ -9,6 +10,7 @@ [#3944]: https://github.com/actix/actix-web/pull/3944 [#3346]: https://github.com/actix/actix-web/issues/3346 +[#3542]: https://github.com/actix/actix-web/issues/3542 ## 4.13.0 diff --git a/actix-web/src/request.rs b/actix-web/src/request.rs index 852a5a80f..5e86f11a2 100644 --- a/actix-web/src/request.rs +++ b/actix-web/src/request.rs @@ -29,6 +29,9 @@ use crate::{ #[cfg(feature = "cookies")] struct Cookies(Vec>); +#[cfg(feature = "cookies")] +struct RawCookies(Vec>); + /// An incoming request. #[derive(Clone)] pub struct HttpRequest { @@ -457,6 +460,8 @@ impl HttpRequest { /// Load request cookies. /// + /// The names and values of cookies are percent-decoded. + /// /// Any cookie that cannot be parsed is omitted from the result. /// This includes cookies with an empty name (e.g. `document.cookie = "=value"`). #[cfg(feature = "cookies")] @@ -481,16 +486,49 @@ impl HttpRequest { })) } + /// Load request cookies **without** percent-decoding their names and values. + /// + /// Any cookie that cannot be parsed is omitted from the result. + /// This includes cookies with an empty name (e.g. `document.cookie = "=value"`). + #[cfg(feature = "cookies")] + pub fn cookies_raw(&self) -> Result>>, CookieParseError> { + use actix_http::header::COOKIE; + + if self.extensions().get::().is_none() { + let mut cookies = Vec::new(); + for hdr in self.headers().get_all(COOKIE) { + let s = str::from_utf8(hdr.as_bytes()).map_err(CookieParseError::from)?; + for cookie_str in s.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) { + if let Ok(cookie) = Cookie::parse(cookie_str) { + cookies.push(cookie.into_owned()); + } + } + } + self.extensions_mut().insert(RawCookies(cookies)); + } + + Ok(Ref::map(self.extensions(), |ext| { + &ext.get::().unwrap().0 + })) + } + /// Return request cookie. #[cfg(feature = "cookies")] pub fn cookie(&self, name: &str) -> Option> { if let Ok(cookies) = self.cookies() { - for cookie in cookies.iter() { - if cookie.name() == name { - return Some(cookie.to_owned()); - } - } + return cookies.iter().find(|cookie| cookie.name() == name).cloned(); } + + None + } + + /// Return request cookie **without** percent-decoding its name and value. + #[cfg(feature = "cookies")] + pub fn cookie_raw(&self, name: &str) -> Option> { + if let Ok(cookies) = self.cookies_raw() { + return cookies.iter().find(|cookie| cookie.name() == name).cloned(); + } + None } } @@ -723,6 +761,49 @@ mod tests { assert!(cookie.is_none()); } + #[test] + #[cfg(feature = "cookies")] + fn test_request_cookies_raw() { + let req = TestRequest::default() + .append_header((header::COOKIE, "cookie1=hello%20world")) + .append_header((header::COOKIE, "cookie2=%db")) + .to_http_request(); + { + let cookies = req.cookies_raw().unwrap(); + assert_eq!(cookies.len(), 2); + assert_eq!(cookies[0].name(), "cookie1"); + assert_eq!(cookies[0].value(), "hello%20world"); + assert_eq!(cookies[1].name(), "cookie2"); + assert_eq!(cookies[1].value(), "%db"); + } + + let cookie = req.cookie_raw("cookie1"); + assert!(cookie.is_some()); + let cookie = cookie.unwrap(); + assert_eq!(cookie.name(), "cookie1"); + assert_eq!(cookie.value(), "hello%20world"); + + let cookie = req.cookie_raw("cookie2"); + assert!(cookie.is_some()); + let cookie = cookie.unwrap(); + assert_eq!(cookie.name(), "cookie2"); + assert_eq!(cookie.value(), "%db"); + } + + #[test] + #[cfg(feature = "cookies")] + fn test_request_cookies_raw_is_independent_from_encoded_cookies() { + let req = TestRequest::default() + .append_header((header::COOKIE, "cookie=%20")) + .to_http_request(); + + let cookie = req.cookie("cookie").unwrap(); + assert_eq!(cookie.value(), " "); + + let raw_cookie = req.cookie_raw("cookie").unwrap(); + assert_eq!(raw_cookie.value(), "%20"); + } + #[test] #[cfg(feature = "cookies")] fn test_empty_key() { diff --git a/actix-web/src/service.rs b/actix-web/src/service.rs index 1e819fcbc..812879864 100644 --- a/actix-web/src/service.rs +++ b/actix-web/src/service.rs @@ -297,6 +297,13 @@ impl ServiceRequest { self.req.cookies() } + /// Return request cookies **without** percent-decoding their names and values. + #[cfg(feature = "cookies")] + #[inline] + pub fn cookies_raw(&self) -> Result>>, CookieParseError> { + self.req.cookies_raw() + } + /// Return request cookie. #[cfg(feature = "cookies")] #[inline] @@ -304,6 +311,13 @@ impl ServiceRequest { self.req.cookie(name) } + /// Return request cookie **without** percent-decoding its name and value. + #[cfg(feature = "cookies")] + #[inline] + pub fn cookie_raw(&self, name: &str) -> Option> { + self.req.cookie_raw(name) + } + /// Set request payload. #[inline] pub fn set_payload(&mut self, payload: Payload) {