Cookie.cs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. using System;
  2. using System.Collections.Generic;
  3. using System.IO;
  4. using System.Linq;
  5. using Best.HTTP.Shared;
  6. using Best.HTTP.Shared.Extensions;
  7. using Best.HTTP.Shared.Logger;
  8. namespace Best.HTTP.Cookies
  9. {
  10. /// <summary>
  11. /// The Cookie implementation based on <see href="http://tools.ietf.org/html/rfc6265">RFC-6265</see>.
  12. /// </summary>
  13. public sealed class Cookie : IComparable<Cookie>, IEquatable<Cookie>
  14. {
  15. private const int Version = 1;
  16. #region Public Properties
  17. /// <summary>
  18. /// The name of the cookie.
  19. /// </summary>
  20. public string Name { get; private set; }
  21. /// <summary>
  22. /// The value of the cookie.
  23. /// </summary>
  24. public string Value { get; private set; }
  25. /// <summary>
  26. /// The Date when the Cookie is registered.
  27. /// </summary>
  28. public DateTime Date { get; internal set; }
  29. /// <summary>
  30. /// When this Cookie last used in a request.
  31. /// </summary>
  32. public DateTime LastAccess { get; set; }
  33. /// <summary>
  34. /// The Expires attribute indicates the maximum lifetime of the cookie, represented as the date and time at which the cookie expires.
  35. /// The user agent is not required to retain the cookie until the specified date has passed.
  36. /// In fact, user agents often evict cookies due to memory pressure or privacy concerns.
  37. /// </summary>
  38. public DateTime Expires { get; private set; }
  39. /// <summary>
  40. /// The Max-Age attribute indicates the maximum lifetime of the cookie, represented as the number of seconds until the cookie expires.
  41. /// The user agent is not required to retain the cookie for the specified duration.
  42. /// In fact, user agents often evict cookies due to memory pressure or privacy concerns.
  43. /// </summary>
  44. public long MaxAge { get; private set; }
  45. /// <summary>
  46. /// If a cookie has neither the Max-Age nor the Expires attribute, the user agent will retain the cookie until "the current session is over".
  47. /// </summary>
  48. public bool IsSession { get; private set; }
  49. /// <summary>
  50. /// The Domain attribute specifies those hosts to which the cookie will be sent.
  51. /// For example, if the value of the Domain attribute is "example.com", the user agent will include the cookie
  52. /// in the Cookie header when making HTTP requests to example.com, www.example.com, and www.corp.example.com.
  53. /// If the server omits the Domain attribute, the user agent will return the cookie only to the origin server.
  54. /// </summary>
  55. public string Domain { get; internal set; }
  56. /// <summary>
  57. /// The scope of each cookie is limited to a set of paths, controlled by the Path attribute.
  58. /// If the server omits the Path attribute, the user agent will use the "directory" of the request-uri's path component as the default value.
  59. /// </summary>
  60. public string Path { get; internal set; }
  61. /// <summary>
  62. /// The Secure attribute limits the scope of the cookie to "secure" channels (where "secure" is defined by the user agent).
  63. /// When a cookie has the Secure attribute, the user agent will include the cookie in an HTTP request only if the request is
  64. /// transmitted over a secure channel (typically HTTP over Transport Layer Security (TLS)).
  65. /// </summary>
  66. public bool IsSecure { get; private set; }
  67. /// <summary>
  68. /// The HttpOnly attribute limits the scope of the cookie to HTTP requests.
  69. /// In particular, the attribute instructs the user agent to omit the cookie when providing access to
  70. /// cookies via "non-HTTP" APIs (such as a web browser API that exposes cookies to scripts).
  71. /// </summary>
  72. public bool IsHttpOnly { get; private set; }
  73. /// <summary>
  74. /// SameSite prevents the browser from sending this cookie along with cross-site requests.
  75. /// The main goal is mitigate the risk of cross-origin information leakage.
  76. /// It also provides some protection against cross-site request forgery attacks.
  77. /// Possible values for the flag are lax or strict.
  78. /// </summary>
  79. /// <remarks>
  80. /// More details can be found here:
  81. /// <list type="bullet">
  82. /// <item><description><see href="https://web.dev/samesite-cookies-explained/">SameSite cookies explained</see></description></item>
  83. /// </list>
  84. /// </remarks>
  85. public string SameSite { get; private set; }
  86. #endregion
  87. #region Public Constructors
  88. public Cookie(string name, string value)
  89. :this(name, value, "/", string.Empty)
  90. {}
  91. public Cookie(string name, string value, string path)
  92. : this(name, value, path, string.Empty)
  93. {}
  94. public Cookie(string name, string value, string path, string domain)
  95. :this() // call the parameter-less constructor to set default values
  96. {
  97. this.Name = name;
  98. this.Value = value;
  99. this.Path = path;
  100. this.Domain = domain;
  101. }
  102. public Cookie(Uri uri, string name, string value, DateTime expires, bool isSession = true)
  103. :this(name, value, uri.AbsolutePath, uri.Host)
  104. {
  105. this.Expires = expires;
  106. this.IsSession = isSession;
  107. this.Date = DateTime.Now;
  108. }
  109. public Cookie(Uri uri, string name, string value, long maxAge = -1, bool isSession = true)
  110. :this(name, value, uri.AbsolutePath, uri.Host)
  111. {
  112. this.MaxAge = maxAge;
  113. this.IsSession = isSession;
  114. this.Date = DateTime.Now;
  115. this.SameSite = "none";
  116. }
  117. #endregion
  118. internal Cookie()
  119. {
  120. // If a cookie has neither the Max-Age nor the Expires attribute, the user agent will retain the cookie
  121. // until "the current session is over" (as defined by the user agent).
  122. IsSession = true;
  123. MaxAge = -1;
  124. LastAccess = DateTime.Now;
  125. }
  126. public bool WillExpireInTheFuture()
  127. {
  128. // No Expires or Max-Age value sent from the server, we will fake the return value so we will not delete a freshly received cookie
  129. if (IsSession)
  130. return true;
  131. // If a cookie has both the Max-Age and the Expires attribute, the Max-Age attribute has precedence and controls the expiration date of the cookie.
  132. return MaxAge != -1 ?
  133. Math.Max(0, (long)(DateTime.Now - Date).TotalSeconds) < MaxAge :
  134. Expires > DateTime.Now;
  135. }
  136. /// <summary>
  137. /// Guess the storage size of the cookie.
  138. /// </summary>
  139. /// <returns></returns>
  140. public uint GuessSize()
  141. {
  142. return (uint)((this.Name != null ? this.Name.Length * sizeof(char) : 0) +
  143. (this.Value != null ? this.Value.Length * sizeof(char) : 0) +
  144. (this.Domain != null ? this.Domain.Length * sizeof(char) : 0) +
  145. (this.Path != null ? this.Path.Length * sizeof(char) : 0) +
  146. (this.SameSite != null ? this.SameSite.Length * sizeof(char) : 0) +
  147. (sizeof(long) * 4) +
  148. (sizeof(bool) * 3));
  149. }
  150. public static Cookie Parse(string header, Uri defaultDomain, LoggingContext context)
  151. {
  152. Cookie cookie = new Cookie();
  153. try
  154. {
  155. var kvps = ParseCookieHeader(header);
  156. foreach (var kvp in kvps)
  157. {
  158. switch (kvp.Key.ToLowerInvariant())
  159. {
  160. case "path":
  161. // https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4
  162. // If the attribute-value is empty or if the first character of the attribute-value is not %x2F ("/"):
  163. // Let cookie-path be the default-path.
  164. cookie.Path = string.IsNullOrEmpty(kvp.Value) || !kvp.Value.StartsWith("/") ? "/" : cookie.Path = kvp.Value;
  165. break;
  166. case "domain":
  167. // If the attribute-value is empty, the behavior is undefined. However, the user agent SHOULD ignore the cookie-av entirely.
  168. if (string.IsNullOrEmpty(kvp.Value))
  169. return null;
  170. // If the first character of the attribute-value string is %x2E ("."):
  171. // Let cookie-domain be the attribute-value without the leading %x2E (".") character.
  172. cookie.Domain = kvp.Value.StartsWith(".") ? kvp.Value.Substring(1) : kvp.Value;
  173. break;
  174. case "expires":
  175. cookie.Expires = kvp.Value.ToDateTime(DateTime.FromBinary(0));
  176. cookie.IsSession = false;
  177. break;
  178. case "max-age":
  179. cookie.MaxAge = kvp.Value.ToInt64(-1);
  180. cookie.IsSession = false;
  181. break;
  182. case "secure":
  183. cookie.IsSecure = true;
  184. break;
  185. case "httponly":
  186. cookie.IsHttpOnly = true;
  187. break;
  188. case "samesite":
  189. cookie.SameSite = kvp.Value;
  190. break;
  191. default:
  192. // check whether name is already set to avoid overwriting it with a non-listed setting
  193. if (string.IsNullOrEmpty(cookie.Name))
  194. {
  195. cookie.Name = kvp.Key;
  196. cookie.Value = kvp.Value;
  197. }
  198. break;
  199. }
  200. }
  201. // Some user agents provide users the option of preventing persistent storage of cookies across sessions.
  202. // When configured thusly, user agents MUST treat all received cookies as if the persistent-flag were set to false.
  203. if (CookieJar.IsSessionOverride)
  204. cookie.IsSession = true;
  205. // http://tools.ietf.org/html/rfc6265#section-4.1.2.3
  206. // WARNING: Some existing user agents treat an absent Domain attribute as if the Domain attribute were present and contained the current host name.
  207. // For example, if example.com returns a Set-Cookie header without a Domain attribute, these user agents will erroneously send the cookie to www.example.com as well.
  208. if (string.IsNullOrEmpty(cookie.Domain))
  209. cookie.Domain = defaultDomain.Host;
  210. // http://tools.ietf.org/html/rfc6265#section-5.3 section 7:
  211. // If the cookie-attribute-list contains an attribute with an attribute-name of "Path",
  212. // set the cookie's path to attribute-value of the last attribute in the cookie-attribute-list with an attribute-name of "Path".
  213. // __Otherwise, set the cookie's path to the default-path of the request-uri.__
  214. if (string.IsNullOrEmpty(cookie.Path))
  215. {
  216. // https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4
  217. // 1. Let uri-path be the path portion of the request-uri if such a portion exists
  218. cookie.Path = defaultDomain.AbsolutePath;
  219. // 2. If the uri-path is empty or if the first character of the uri-path is not a % x2F("/") character,
  220. // output % x2F("/") and skip the remaining steps.
  221. // Uri's AbsolutePath always emits at least a '/' character.
  222. // 3. If the uri-path contains no more than one %x2F ("/") character,
  223. // output % x2F("/") and skip the remaining step.
  224. int slashCount = 1;
  225. int lastSlashIdx = 0;
  226. for (int i = 1; i < cookie.Path.Length; i++)
  227. {
  228. if (cookie.Path[i] == '/')
  229. {
  230. slashCount++;
  231. lastSlashIdx = i;
  232. }
  233. }
  234. if (slashCount == 1)
  235. {
  236. cookie.Path = "/";
  237. }
  238. else
  239. {
  240. // 4. Output the characters of the uri-path from the first character up to,
  241. // but not including, the right-most % x2F("/").
  242. cookie.Path = cookie.Path.Substring(0, lastSlashIdx);
  243. }
  244. }
  245. cookie.Date = cookie.LastAccess = DateTime.Now;
  246. }
  247. catch (Exception ex)
  248. {
  249. HTTPManager.Logger.Warning("Cookie", "Parse - Couldn't parse header: " + header + " exception: " + ex.ToString() + " " + ex.StackTrace, context);
  250. }
  251. return cookie;
  252. }
  253. #region Save & Load
  254. internal void SaveTo(BinaryWriter stream)
  255. {
  256. stream.Write(Version);
  257. stream.Write(Name ?? string.Empty);
  258. stream.Write(Value ?? string.Empty);
  259. stream.Write(Date.ToBinary());
  260. stream.Write(LastAccess.ToBinary());
  261. stream.Write(Expires.ToBinary());
  262. stream.Write(MaxAge);
  263. stream.Write(IsSession);
  264. stream.Write(Domain ?? string.Empty);
  265. stream.Write(Path ?? string.Empty);
  266. stream.Write(IsSecure);
  267. stream.Write(IsHttpOnly);
  268. }
  269. internal void LoadFrom(BinaryReader stream)
  270. {
  271. /*int version = */stream.ReadInt32();
  272. this.Name = stream.ReadString();
  273. this.Value = stream.ReadString();
  274. this.Date = DateTime.FromBinary(stream.ReadInt64());
  275. this.LastAccess = DateTime.FromBinary(stream.ReadInt64());
  276. this.Expires = DateTime.FromBinary(stream.ReadInt64());
  277. this.MaxAge = stream.ReadInt64();
  278. this.IsSession = stream.ReadBoolean();
  279. this.Domain = stream.ReadString();
  280. this.Path = stream.ReadString();
  281. this.IsSecure = stream.ReadBoolean();
  282. this.IsHttpOnly = stream.ReadBoolean();
  283. }
  284. #endregion
  285. #region Overrides and new Equals function
  286. public override string ToString()
  287. {
  288. return string.Concat(this.Name, "=", this.Value);
  289. }
  290. public override bool Equals(object obj)
  291. {
  292. if (obj == null)
  293. return false;
  294. return this.Equals(obj as Cookie);
  295. }
  296. public bool Equals(Cookie cookie)
  297. {
  298. if (cookie == null)
  299. return false;
  300. if (Object.ReferenceEquals(this, cookie))
  301. return true;
  302. return this.Name.Equals(cookie.Name, StringComparison.Ordinal) &&
  303. ((this.Domain == null && cookie.Domain == null) || this.Domain.Equals(cookie.Domain, StringComparison.Ordinal)) &&
  304. ((this.Path == null && cookie.Path == null) || this.Path.Equals(cookie.Path, StringComparison.Ordinal));
  305. }
  306. public override int GetHashCode()
  307. {
  308. return this.ToString().GetHashCode();
  309. }
  310. #endregion
  311. #region Private Helper Functions
  312. private static string ReadValue(string str, ref int pos)
  313. {
  314. string result = string.Empty;
  315. if (str == null)
  316. return result;
  317. return str.Read(ref pos, ';');
  318. }
  319. private static List<HeaderValue> ParseCookieHeader(string str)
  320. {
  321. List<HeaderValue> result = new List<HeaderValue>();
  322. if (str == null)
  323. return result;
  324. int idx = 0;
  325. // process the rest of the text
  326. while (idx < str.Length)
  327. {
  328. // Read key
  329. string key = str.Read(ref idx, (ch) => ch != '=' && ch != ';').Trim();
  330. HeaderValue qp = new HeaderValue(key);
  331. if (idx < str.Length && str[idx - 1] == '=')
  332. qp.Value = ReadValue(str, ref idx);
  333. result.Add(qp);
  334. }
  335. return result;
  336. }
  337. #endregion
  338. #region IComparable<Cookie> implementation
  339. public int CompareTo(Cookie other)
  340. {
  341. return this.LastAccess.CompareTo(other.LastAccess);
  342. }
  343. #endregion
  344. }
  345. }