Skip to content

Commit ee08929

Browse files
Restore cookie reading and writing (#863)
1 parent ef19263 commit ee08929

21 files changed

Lines changed: 659 additions & 237 deletions

File tree

API/Protocol/Cookie.cs

Lines changed: 0 additions & 58 deletions
This file was deleted.

API/Protocol/CookieOptions.cs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
namespace GenHTTP.Api.Protocol;
2+
3+
/// <summary>
4+
/// The attributes that can be set on a cookie in addition to its
5+
/// name and value (see RFC 6265).
6+
/// </summary>
7+
public readonly struct CookieOptions
8+
{
9+
10+
/// <summary>
11+
/// The duration after which the client should discard the cookie.
12+
/// </summary>
13+
/// <remarks>
14+
/// This is the modern, recommended way to expire a cookie - see the
15+
/// remarks on <see cref="Expires"/> for how the two attributes interact.
16+
/// </remarks>
17+
public TimeSpan? MaxAge { get; init; }
18+
19+
20+
/// <summary>
21+
/// The point in time after which the client should discard the cookie.
22+
/// </summary>
23+
/// <remarks>
24+
/// This is the legacy attribute for cookie expiration, kept for clients
25+
/// that predate <see cref="MaxAge"/>. Prefer <see cref="MaxAge"/> instead,
26+
/// as it is relative to the time the cookie is received and therefore not
27+
/// affected by clock drift between client and server. If both attributes
28+
/// are set, RFC 6265 requires clients that understand <see cref="MaxAge"/>
29+
/// to prefer it and ignore this value - this type does not enforce that
30+
/// exclusivity, so setting both will result in both being sent.
31+
/// </remarks>
32+
public DateTimeOffset? Expires { get; init; }
33+
34+
/// <summary>
35+
/// The hosts the cookie should be sent to.
36+
/// </summary>
37+
/// <remarks>
38+
/// Setting this attribute also makes the cookie available to subdomains
39+
/// of the given host, which widens the audience that can read the cookie.
40+
/// If left unset, the cookie is only sent back to the exact host that
41+
/// originally set it (and not to subdomains), which is the safer default
42+
/// for most use cases.
43+
/// </remarks>
44+
public ByteString? Domain { get; init; }
45+
46+
/// <summary>
47+
/// The path that must be present in the request URL for the cookie to be sent.
48+
/// </summary>
49+
/// <remarks>
50+
/// Unlike a browser receiving this header, this type does not default the
51+
/// path to the path of the request the cookie was set on - if left unset,
52+
/// no Path attribute is sent and the cookie is scoped by the client according
53+
/// to its own default behavior (typically the path of the request).
54+
/// </remarks>
55+
public ByteString? Path { get; init; }
56+
57+
/// <summary>
58+
/// Whether the cookie should only be sent to the server via encrypted connections.
59+
/// </summary>
60+
/// <remarks>
61+
/// Should be set whenever possible to prevent the cookie from being exposed
62+
/// over plain HTTP. This is also a requirement for <see cref="GenHTTP.Api.Protocol.SameSite.None"/>,
63+
/// which most clients will otherwise reject.
64+
/// </remarks>
65+
public bool Secure { get; init; }
66+
67+
/// <summary>
68+
/// Whether the cookie should be hidden from client side scripts.
69+
/// </summary>
70+
/// <remarks>
71+
/// Should be set whenever the cookie does not need to be read by JavaScript,
72+
/// as it mitigates the impact of cross-site scripting (XSS) attacks by
73+
/// keeping session identifiers and similar values out of reach of scripts.
74+
/// </remarks>
75+
public bool HttpOnly { get; init; }
76+
77+
/// <summary>
78+
/// Restricts whether the cookie is sent along with cross-site requests.
79+
/// </summary>
80+
/// <remarks>
81+
/// Leaving this unset relies on the client's default behavior, which varies
82+
/// across clients and may change over time (most current browsers default to
83+
/// <see cref="GenHTTP.Api.Protocol.SameSite.Lax"/>). Set this explicitly to
84+
/// get a predictable, future-proof outcome. Note that <see cref="GenHTTP.Api.Protocol.SameSite.None"/>
85+
/// additionally requires <see cref="Secure"/> to be set, or the cookie will
86+
/// be rejected by most clients.
87+
/// </remarks>
88+
public SameSite? SameSite { get; init; }
89+
90+
}

API/Protocol/ICookieCollection.cs

Lines changed: 0 additions & 9 deletions
This file was deleted.
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
namespace GenHTTP.Api.Protocol;
2+
3+
/// <summary>
4+
/// Allows cookies to be read from a header list (e.g. the "Cookie" request header).
5+
/// </summary>
6+
public static class CookieHeaderExtensions
7+
{
8+
9+
#region Functionality
10+
11+
/// <summary>
12+
/// Searches the given headers for a cookie with the specified name.
13+
/// </summary>
14+
/// <param name="headers">The headers to search (typically <see cref="IRequestHeader.Headers"/>)</param>
15+
/// <param name="key">The name of the cookie to be looked up</param>
16+
/// <returns>The value of the cookie, if found</returns>
17+
public static ByteString? GetCookie(this IKeyValueList headers, ByteString key)
18+
{
19+
for (var i = 0; i < headers.Count; i++)
20+
{
21+
var entry = headers[i];
22+
23+
if (entry.Key != KnownHeaders.Cookie)
24+
{
25+
continue;
26+
}
27+
28+
var found = FindCookie(entry.Value, key);
29+
30+
if (found is not null)
31+
{
32+
return found;
33+
}
34+
}
35+
36+
return null;
37+
}
38+
39+
/// <summary>
40+
/// Searches the given headers for a cookie with the specified name.
41+
/// </summary>
42+
/// <param name="headers">The headers to search (typically <see cref="IRequestHeader.Headers"/>)</param>
43+
/// <param name="key">The name of the cookie to be looked up</param>
44+
/// <returns>The value of the cookie, if found</returns>
45+
public static string? GetCookie(this IKeyValueList headers, string key)
46+
=> headers.GetCookie(new ByteString(key))?.ToString();
47+
48+
/// <summary>
49+
/// Collects all cookies found in the given headers into a list that
50+
/// can be iterated or queried by name.
51+
/// </summary>
52+
/// <param name="headers">The headers to search (typically <see cref="IRequestHeader.Headers"/>)</param>
53+
/// <returns>The cookies found in the given headers</returns>
54+
public static IKeyValueList GetCookies(this IKeyValueList headers)
55+
{
56+
var cookies = new List<KeyValuePair<ByteString, ByteString>>();
57+
58+
for (var i = 0; i < headers.Count; i++)
59+
{
60+
var entry = headers[i];
61+
62+
if (entry.Key == KnownHeaders.Cookie)
63+
{
64+
ParseCookies(entry.Value, cookies);
65+
}
66+
}
67+
68+
return new CookieList(cookies);
69+
}
70+
71+
#endregion
72+
73+
#region Parsing
74+
75+
private static ByteString? FindCookie(ByteString header, ByteString key)
76+
{
77+
var memory = header.Bytes;
78+
var span = memory.Span;
79+
80+
var keySpan = key.Bytes.Span;
81+
82+
var segments = new CookieSegments(span);
83+
84+
while (segments.MoveNext())
85+
{
86+
if (span[segments.Name].SequenceEqual(keySpan))
87+
{
88+
return new ByteString(memory[segments.Value]);
89+
}
90+
}
91+
92+
return null;
93+
}
94+
95+
private static void ParseCookies(ByteString header, List<KeyValuePair<ByteString, ByteString>> target)
96+
{
97+
var memory = header.Bytes;
98+
99+
var segments = new CookieSegments(memory.Span);
100+
101+
while (segments.MoveNext())
102+
{
103+
target.Add(new(new ByteString(memory[segments.Name]), new ByteString(memory[segments.Value])));
104+
}
105+
}
106+
107+
#endregion
108+
109+
#region Supporting Data Structures
110+
111+
/// <summary>
112+
/// Walks a "Cookie" header value, yielding the name/value ranges of
113+
/// each "name=value" pair it finds (separated by "; ").
114+
/// </summary>
115+
private ref struct CookieSegments(ReadOnlySpan<byte> header)
116+
{
117+
private readonly ReadOnlySpan<byte> _header = header;
118+
119+
private int _position = 0;
120+
121+
public Range Name { get; private set; }
122+
123+
public Range Value { get; private set; }
124+
125+
public bool MoveNext()
126+
{
127+
while (_position < _header.Length)
128+
{
129+
while (_position < _header.Length && _header[_position] == (byte)' ')
130+
{
131+
_position++;
132+
}
133+
134+
if (_position >= _header.Length)
135+
{
136+
return false;
137+
}
138+
139+
var start = _position;
140+
141+
var delimiter = _header[start..].IndexOf((byte)';');
142+
143+
var segmentEnd = delimiter < 0 ? _header.Length : start + delimiter;
144+
145+
var separator = _header[start..segmentEnd].IndexOf((byte)'=');
146+
147+
_position = segmentEnd + 1;
148+
149+
if (separator > -1)
150+
{
151+
Name = start..(start + separator);
152+
Value = (start + separator + 1)..segmentEnd;
153+
154+
return true;
155+
}
156+
}
157+
158+
return false;
159+
}
160+
161+
}
162+
163+
/// <summary>
164+
/// A read-only list of cookies that have been parsed from one or
165+
/// more "Cookie" header values.
166+
/// </summary>
167+
private sealed class CookieList(List<KeyValuePair<ByteString, ByteString>> entries) : IKeyValueList
168+
{
169+
170+
public int Count => entries.Count;
171+
172+
public KeyValuePair<ByteString, ByteString> this[int index] => entries[index];
173+
174+
}
175+
176+
#endregion
177+
178+
}

0 commit comments

Comments
 (0)