-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
/
cookie.cr
424 lines (376 loc) · 14.3 KB
/
cookie.cr
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
require "./common"
module HTTP
# Represents a cookie with all its attributes. Provides convenient access and modification of them.
class Cookie
# Possible values for the `SameSite` cookie as described in the [Same-site Cookies Draft](https://tools.ietf.org/html/draft-west-first-party-cookies-07#section-4.1.1).
enum SameSite
# The browser will send cookies with both cross-site requests and same-site requests.
#
# The `None` directive requires the `secure` attribute to be `true` to mitigate risks associated with cross-site access.
None
# Prevents the cookie from being sent by the browser in all cross-site browsing contexts.
Strict
# Allows the cookie to be sent by the browser during top-level navigations that use a [safe](https://tools.ietf.org/html/rfc7231#section-4.2.1) HTTP method.
Lax
end
getter name : String
getter value : String
property path : String?
property expires : Time?
property domain : String?
property secure : Bool
property http_only : Bool
property samesite : SameSite?
property extension : String?
property max_age : Time::Span?
getter creation_time : Time
def_equals_and_hash name, value, path, expires, domain, secure, http_only, samesite, extension
# Creates a new `Cookie` instance.
#
# Raises `IO::Error` if *name* or *value* are invalid as per [RFC 6265 §4.1.1](https://tools.ietf.org/html/rfc6265#section-4.1.1).
def initialize(name : String, value : String, @path : String? = nil,
@expires : Time? = nil, @domain : String? = nil,
@secure : Bool = false, @http_only : Bool = false,
@samesite : SameSite? = nil, @extension : String? = nil,
@max_age : Time::Span? = nil, @creation_time = Time.utc)
validate_name(name)
@name = name
validate_value(value)
@value = value
raise IO::Error.new("Invalid max_age") if @max_age.try { |max_age| max_age < Time::Span.zero }
end
# Sets the name of this cookie.
#
# Raises `IO::Error` if the value is invalid as per [RFC 6265 §4.1.1](https://tools.ietf.org/html/rfc6265#section-4.1.1).
def name=(name : String)
validate_name(name)
@name = name
end
private def validate_name(name)
raise IO::Error.new("Invalid cookie name") if name.empty?
name.each_byte do |byte|
# valid characters for cookie-name per https://tools.ietf.org/html/rfc6265#section-4.1.1
# and https://tools.ietf.org/html/rfc2616#section-2.2
# "!#$%&'*+-.0123456789ABCDEFGHIJKLMNOPQRSTUWVXYZ^_`abcdefghijklmnopqrstuvwxyz|~"
unless (0x21...0x7f).includes?(byte) && byte != 0x22 && byte != 0x28 && byte != 0x29 && byte != 0x2c && byte != 0x2f && !(0x3a..0x40).includes?(byte) && !(0x5b..0x5d).includes?(byte) && byte != 0x7b && byte != 0x7d
raise IO::Error.new("Invalid cookie name")
end
end
end
# Sets the value of this cookie.
#
# Raises `IO::Error` if the value is invalid as per [RFC 6265 §4.1.1](https://tools.ietf.org/html/rfc6265#section-4.1.1).
def value=(value : String)
validate_value(value)
@value = value
end
private def validate_value(value)
value.each_byte do |byte|
# valid characters for cookie-value per https://tools.ietf.org/html/rfc6265#section-4.1.1
# all printable ASCII characters except ' ', ',', '"', ';' and '\\'
unless (0x21...0x7f).includes?(byte) && byte != 0x22 && byte != 0x2c && byte != 0x3b && byte != 0x5c
raise IO::Error.new("Invalid cookie value")
end
end
end
def to_set_cookie_header : String
path = @path
expires = @expires
max_age = @max_age
domain = @domain
samesite = @samesite
String.build do |header|
to_cookie_header(header)
header << "; domain=#{domain}" if domain
header << "; path=#{path}" if path
header << "; expires=#{HTTP.format_time(expires)}" if expires
header << "; max-age=#{max_age.to_i}" if max_age
header << "; Secure" if @secure
header << "; HttpOnly" if @http_only
header << "; SameSite=#{samesite}" if samesite
header << "; #{@extension}" if @extension
end
end
def to_cookie_header : String
String.build(@name.bytesize + @value.bytesize + 1) do |io|
to_cookie_header(io)
end
end
def to_cookie_header(io) : Nil
io << @name
io << '='
io << @value
end
# Returns the expiration time of this cookie.
def expiration_time : Time?
if max_age = @max_age
@creation_time + max_age
else
@expires
end
end
# Returns the expiration status of this cookie as a `Bool`.
#
# *time_reference* can be passed to use a different reference time for
# comparison. Default is the current time (`Time.utc`).
def expired?(time_reference = Time.utc) : Bool
if @max_age.try &.zero?
true
elsif expiration_time = self.expiration_time
expiration_time <= time_reference
else
false
end
end
# :nodoc:
module Parser
module Regex
CookieName = /[^()<>@,;:\\"\/\[\]?={} \t\x00-\x1f\x7f]+/
CookieOctet = /[!#-+\--:<-\[\]-~]/
CookieValue = /(?:"#{CookieOctet}*"|#{CookieOctet}*)/
CookiePair = /(?<name>#{CookieName})=(?<value>#{CookieValue})/
DomainLabel = /[A-Za-z0-9\-]+/
DomainIp = /(?:\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/
Time = /(?:\d{2}:\d{2}:\d{2})/
Month = /(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)/
Weekday = /(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)/
Wkday = /(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)/
PathValue = /[^\x00-\x1f\x7f;]+/
DomainValue = /(?:#{DomainLabel}(?:\.#{DomainLabel})?|#{DomainIp})+/
Zone = /(?:UT|GMT|EST|EDT|CST|CDT|MST|MDT|PST|PDT|[+-]?\d{4})/
RFC1036Date = /#{Weekday}, \d{2}-#{Month}-\d{2} #{Time} GMT/
RFC1123Date = /#{Wkday}, \d{1,2} #{Month} \d{2,4} #{Time} #{Zone}/
IISDate = /#{Wkday}, \d{1,2}-#{Month}-\d{2,4} #{Time} GMT/
ANSICDate = /#{Wkday} #{Month} (?:\d{2}| \d) #{Time} \d{4}/
SaneCookieDate = /(?:#{RFC1123Date}|#{RFC1036Date}|#{IISDate}|#{ANSICDate})/
ExtensionAV = /(?<extension>[^\x00-\x1f\x7f]+)/
HttpOnlyAV = /(?<http_only>HttpOnly)/i
SameSiteAV = /SameSite=(?<samesite>\w+)/i
SecureAV = /(?<secure>Secure)/i
PathAV = /Path=(?<path>#{PathValue})/i
DomainAV = /Domain=\.?(?<domain>#{DomainValue})/i
MaxAgeAV = /Max-Age=(?<max_age>[0-9]*)/i
ExpiresAV = /Expires=(?<expires>#{SaneCookieDate})/i
CookieAV = /(?:#{ExpiresAV}|#{MaxAgeAV}|#{DomainAV}|#{PathAV}|#{SecureAV}|#{HttpOnlyAV}|#{SameSiteAV}|#{ExtensionAV})/
end
CookieString = /(?:^|; )#{Regex::CookiePair}/
SetCookieString = /^#{Regex::CookiePair}(?:;\s*#{Regex::CookieAV})*$/
def parse_cookies(header)
header.scan(CookieString).each do |pair|
value = pair["value"]
if value.starts_with?('"')
# Unwrap quoted cookie value
value = value.byte_slice(1, value.bytesize - 2)
end
yield Cookie.new(pair["name"], value)
end
end
def parse_cookies(header) : Array(Cookie)
cookies = [] of Cookie
parse_cookies(header) { |cookie| cookies << cookie }
cookies
end
def parse_set_cookie(header) : Cookie?
match = header.match(SetCookieString)
return unless match
expires = parse_time(match["expires"]?)
max_age = match["max_age"]?.try(&.to_i64.seconds)
Cookie.new(
match["name"], match["value"],
path: match["path"]?,
expires: expires,
domain: match["domain"]?,
secure: match["secure"]? != nil,
http_only: match["http_only"]? != nil,
samesite: match["samesite"]?.try { |v| SameSite.parse? v },
extension: match["extension"]?,
max_age: max_age,
)
end
private def parse_time(string)
return unless string
HTTP.parse_time(string)
end
extend self
end
end
# Represents a collection of cookies as it can be present inside
# a HTTP request or response.
class Cookies
include Enumerable(Cookie)
# Creates a new instance by parsing the `Cookie` and `Set-Cookie`
# headers in the given `HTTP::Headers`.
#
# See `HTTP::Request#cookies` and `HTTP::Client::Response#cookies`.
@[Deprecated("Use `.from_client_headers` or `.from_server_headers` instead.")]
def self.from_headers(headers) : self
new.tap { |cookies| cookies.fill_from_headers(headers) }
end
# Filling cookies by parsing the `Cookie` and `Set-Cookie`
# headers in the given `HTTP::Headers`.
@[Deprecated("Use `#fill_from_client_headers` or `#fill_from_server_headers` instead.")]
def fill_from_headers(headers)
fill_from_client_headers(headers)
fill_from_server_headers(headers)
self
end
# Creates a new instance by parsing the `Cookie` headers in the given `HTTP::Headers`.
#
# See `HTTP::Client::Response#cookies`.
def self.from_client_headers(headers) : self
new.tap { |cookies| cookies.fill_from_client_headers(headers) }
end
# Filling cookies by parsing the `Cookie` headers in the given `HTTP::Headers`.
def fill_from_client_headers(headers) : self
if values = headers.get?("Cookie")
values.each do |header|
Cookie::Parser.parse_cookies(header) { |cookie| self << cookie }
end
end
self
end
# Creates a new instance by parsing the `Set-Cookie` headers in the given `HTTP::Headers`.
#
# See `HTTP::Request#cookies`.
def self.from_server_headers(headers) : self
new.tap { |cookies| cookies.fill_from_server_headers(headers) }
end
# Filling cookies by parsing the `Set-Cookie` headers in the given `HTTP::Headers`.
def fill_from_server_headers(headers) : self
if values = headers.get?("Set-Cookie")
values.each do |header|
Cookie::Parser.parse_set_cookie(header).try { |cookie| self << cookie }
end
end
self
end
# Creates a new empty instance.
def initialize
@cookies = {} of String => Cookie
end
# Sets a new cookie in the collection with a string value.
# This creates a never expiring, insecure, not HTTP-only cookie with
# no explicit domain restriction and no path.
#
# ```
# require "http/client"
#
# request = HTTP::Request.new "GET", "/"
# request.cookies["foo"] = "bar"
# ```
def []=(key, value : String)
self[key] = Cookie.new(key, value)
end
# Sets a new cookie in the collection to the given `HTTP::Cookie`
# instance. The name attribute must match the given *key*, else
# `ArgumentError` is raised.
#
# ```
# require "http/client"
#
# response = HTTP::Client::Response.new(200)
# response.cookies["foo"] = HTTP::Cookie.new("foo", "bar", "/admin", Time.utc + 12.hours, secure: true)
# ```
def []=(key, value : Cookie)
unless key == value.name
raise ArgumentError.new("Cookie name must match the given key")
end
@cookies[key] = value
end
# Gets the current `HTTP::Cookie` for the given *key*.
#
# ```
# request.cookies["foo"].value # => "bar"
# ```
def [](key) : Cookie
@cookies[key]
end
# Gets the current `HTTP::Cookie` for the given *key* or `nil` if none is set.
#
# ```
# require "http/client"
#
# request = HTTP::Request.new "GET", "/"
# request.cookies["foo"]? # => nil
# request.cookies["foo"] = "bar"
# request.cookies["foo"]?.try &.value # > "bar"
# ```
def []?(key) : Cookie?
@cookies[key]?
end
# Returns `true` if a cookie with the given *key* exists.
#
# ```
# request.cookies.has_key?("foo") # => true
# ```
def has_key?(key) : Bool
@cookies.has_key?(key)
end
# Adds the given *cookie* to this collection, overrides an existing cookie
# with the same name if present.
#
# ```
# response.cookies << HTTP::Cookie.new("foo", "bar", http_only: true)
# ```
def <<(cookie : Cookie)
self[cookie.name] = cookie
end
# Clears the collection, removing all cookies.
def clear : Hash(String, HTTP::Cookie)
@cookies.clear
end
# Deletes and returns the `HTTP::Cookie` for the specified *key*, or
# returns `nil` if *key* cannot be found in the collection. Note that
# *key* should match the name attribute of the desired `HTTP::Cookie`.
def delete(key) : Cookie?
@cookies.delete(key)
end
# Yields each `HTTP::Cookie` in the collection.
def each(& : Cookie ->)
@cookies.each_value do |cookie|
yield cookie
end
end
# Returns an iterator over the cookies of this collection.
def each
@cookies.each_value
end
# Returns the number of cookies contained in this collection.
def size : Int32
@cookies.size
end
# Whether the collection contains any cookies.
def empty? : Bool
@cookies.empty?
end
# Adds `Cookie` headers for the cookies in this collection to the
# given `HTTP::Headers` instance and returns it. Removes any existing
# `Cookie` headers in it.
def add_request_headers(headers)
if empty?
headers.delete("Cookie")
else
capacity = sum { |cookie| cookie.name.bytesize + cookie.value.bytesize + 1 }
capacity += (size - 1) * 2
headers["Cookie"] = String.build(capacity) do |io|
join(io, "; ", &.to_cookie_header(io))
end
end
headers
end
# Adds `Set-Cookie` headers for the cookies in this collection to the
# given `HTTP::Headers` instance and returns it. Removes any existing
# `Set-Cookie` headers in it.
def add_response_headers(headers)
headers.delete("Set-Cookie")
each do |cookie|
headers.add("Set-Cookie", cookie.to_set_cookie_header)
end
headers
end
# Returns this collection as a plain `Hash`.
def to_h : Hash(String, Cookie)
@cookies.dup
end
end
end