src/cookie.bend

emerging-ezhttp@0.9.0.0 · 542 lines · 0 definitions

1# ezhttp/src/cookie: Set-Cookie and Cookie (RFC 6265 §§4–5). A thin jar: parse
2# attributes, serialize the request header, and domain/path/secure matching.
3# Expires is stored, not evaluated against a clock. SameSite is stored; there
4# is no browsing context to suppress cross-site sends.
5import Base
6import emerging-ezhttp@0.9.0.0/src/http.bend as Http
7
8# one cookie. host_only means the Domain attribute was absent (exact host).
9type Cookie is Data:
10  Cookie{
11    name: String
12    value: String
13    expires: String
14    max_age: String
15    domain: String
16    path: String
17    secure: Bool
18    http_only: Bool
19    same_site: String
20    host_only: Bool
21  }
22
23# a session cookie with no attributes yet
24def cookie.new(name: String, value: String) -> Cookie:
25  Cookie{name, value, "", "", "", "", False{}, False{}, "", True{}}
26
27# replace Expires
28def cookie.expires(c: Cookie, when: String) -> Cookie:
29  match c:
30    case Cookie{name, value, expires, max_age, domain, path, secure, http_only,
31      same_site, host_only}:
32      Cookie{name, value, when, max_age, domain, path, secure, http_only,
33        same_site, host_only}
34
35# replace Max-Age
36def cookie.age(c: Cookie, age: String) -> Cookie:
37  match c:
38    case Cookie{name, value, expires, max_age, domain, path, secure, http_only,
39      same_site, host_only}:
40      Cookie{name, value, expires, age, domain, path, secure, http_only,
41        same_site, host_only}
42
43# replace Domain and clear host-only
44def cookie.domain(c: Cookie, host: String) -> Cookie:
45  match c:
46    case Cookie{name, value, expires, max_age, domain, path, secure, http_only,
47      same_site, host_only}:
48      Cookie{name, value, expires, max_age, host, path, secure, http_only,
49        same_site, False{}}
50
51# replace Path
52def cookie.dir(c: Cookie, dir: String) -> Cookie:
53  match c:
54    case Cookie{name, value, expires, max_age, domain, path, secure, http_only,
55      same_site, host_only}:
56      Cookie{name, value, expires, max_age, domain, dir, secure, http_only,
57        same_site, host_only}
58
59# set the Secure flag
60def cookie.secure_on(c: Cookie) -> Cookie:
61  match c:
62    case Cookie{name, value, expires, max_age, domain, path, secure, http_only,
63      same_site, host_only}:
64      Cookie{name, value, expires, max_age, domain, path, True{}, http_only,
65        same_site, host_only}
66
67# set the HttpOnly flag
68def cookie.http_on(c: Cookie) -> Cookie:
69  match c:
70    case Cookie{name, value, expires, max_age, domain, path, secure, http_only,
71      same_site, host_only}:
72      Cookie{name, value, expires, max_age, domain, path, secure, True{},
73        same_site, host_only}
74
75# replace SameSite
76def cookie.site_set(c: Cookie, site: String) -> Cookie:
77  match c:
78    case Cookie{name, value, expires, max_age, domain, path, secure, http_only,
79      same_site, host_only}:
80      Cookie{name, value, expires, max_age, domain, path, secure, http_only,
81        site, host_only}
82
83# None, or unrecognized
84def cookie.site.none(_low: String, hit: Bool) -> String:
85  match hit:
86    case True{}:
87      "None"
88    case False{}:
89      ""
90
91# Strict, or continue
92def cookie.site.strict(+low: String, hit: Bool) -> String:
93  match hit:
94    case True{}:
95      "Strict"
96    case False{}:
97      cookie.site.none(low, String.eq(low, "none"))
98
99# Lax / Strict / None, canonical spelling (RFC 6265bis SameSite)
100def cookie.site.lax(+low: String, hit: Bool) -> String:
101  match hit:
102    case True{}:
103      "Lax"
104    case False{}:
105      cookie.site.strict(low, String.eq(low, "strict"))
106
107# canonicalize a SameSite value; anything else is dropped
108def cookie.site(+raw: String) -> String:
109  +low = String.to_lower(raw)
110  cookie.site.lax(low, String.eq(low, "lax"))
111
112# keep a numeric Max-Age, ignore the attribute when it is not digits
113def cookie.age_ok(c: Cookie, +raw: String, m: Maybe<&2, Nat>) -> Cookie:
114  match m:
115    case None{}:
116      c
117    case Some{n}:
118      cookie.age(c, raw)
119
120# HttpOnly flag, or leave the cookie alone
121def cookie.flag.http(c: Cookie, _name: String, hit: Bool) -> Cookie:
122  match hit:
123    case True{}:
124      cookie.http_on(c)
125    case False{}:
126      c
127
128# Secure flag, else HttpOnly
129def cookie.flag.secure(c: Cookie, +name: String, hit: Bool) -> Cookie:
130  match hit:
131    case True{}:
132      cookie.secure_on(c)
133    case False{}:
134      cookie.flag.http(c, name, String.eq(name, "httponly"))
135
136# a flag attribute (no equals sign)
137def cookie.flag(c: Cookie, +name: String) -> Cookie:
138  cookie.flag.secure(c, name, String.eq(name, "secure"))
139
140# SameSite, or an unrecognized attribute
141def cookie.named.site(c: Cookie, _name: String, value: String, hit: Bool)
142  -> Cookie:
143  match hit:
144    case True{}:
145      cookie.site_set(c, cookie.site(value))
146    case False{}:
147      c
148
149# Path
150def cookie.named.path(c: Cookie, +name: String, +value: String, hit: Bool)
151  -> Cookie:
152  match hit:
153    case True{}:
154      cookie.dir(c, value)
155    case False{}:
156      cookie.named.site(c, name, value, String.eq(name, "samesite"))
157
158# Domain, stored lowercase
159def cookie.named.domain(c: Cookie, +name: String, +value: String, hit: Bool)
160  -> Cookie:
161  match hit:
162    case True{}:
163      cookie.domain(c, String.to_lower(value))
164    case False{}:
165      cookie.named.path(c, name, value, String.eq(name, "path"))
166
167# Max-Age
168def cookie.named.age(c: Cookie, +name: String, +value: String, hit: Bool)
169  -> Cookie:
170  match hit:
171    case True{}:
172      cookie.age_ok(c, value, Http.digits.read(value))
173    case False{}:
174      cookie.named.domain(c, name, value, String.eq(name, "domain"))
175
176# Expires, stored as the HTTP-date text
177def cookie.named.expires(c: Cookie, +name: String, +value: String, hit: Bool)
178  -> Cookie:
179  match hit:
180    case True{}:
181      cookie.expires(c, value)
182    case False{}:
183      cookie.named.age(c, name, value, String.eq(name, "max-age"))
184
185# one name=value attribute
186def cookie.named(c: Cookie, +name: String, +value: String) -> Cookie:
187  cookie.named.expires(c, name, value, String.eq(name, "expires"))
188
189# one attribute piece, flag or name=value
190def cookie.apply.cut(c: Cookie, +piece: String, cut: Http.Cut) -> Cookie:
191  match cut:
192    case Http.NoCut{}:
193      cookie.flag(c, String.to_lower(String.trim(piece)))
194    case Http.Cut{before, after}:
195      cookie.named(c, String.to_lower(String.trim(before)), String.trim(after))
196
197# one semicolon-separated attribute
198def cookie.apply(c: Cookie, +piece: String) -> Cookie:
199  cookie.apply.cut(c, piece, Http.divide(piece, "="))
200
201# walk attributes. The list is the shrinking argument.
202def cookie.fold(ps: List<&2, String>, c: Cookie) -> Cookie:
203  match ps:
204    case []:
205      c
206    case h <> t:
207      cookie.fold(t, cookie.apply(c, String.trim(h)))
208
209# reject an empty name (RFC 6265 §4.1.1)
210def cookie.from.name(+name: String, value: String, attrs: List<&2, String>,
211  empty: Bool) -> Maybe<&2, Cookie>:
212  match empty:
213    case True{}:
214      None{}
215    case False{}:
216      Some{cookie.fold(attrs, cookie.new(name, value))}
217
218# the name=value pair, then the attribute list
219def cookie.from(_pair: String, attrs: List<&2, String>, cut: Http.Cut)
220  -> Maybe<&2, Cookie>:
221  match cut:
222    case Http.NoCut{}:
223      None{}
224    case Http.Cut{before, after}:
225      +name = String.trim(before)
226      cookie.from.name(name, String.trim(after), attrs, String.eq(name, ""))
227
228# Set-Cookie split on `;`
229def cookie.parse.parts(ps: List<&2, String>) -> Maybe<&2, Cookie>:
230  match ps:
231    case []:
232      None{}
233    case h <> t:
234      +pair = String.trim(h)
235      cookie.from(pair, t, Http.divide(pair, "="))
236
237# parse one Set-Cookie line (the field value, not the header name)
238def cookie.parse(line: String) -> Maybe<&2, Cookie>:
239  cookie.parse.parts(String.split(line, ';'))
240
241# `; Name=value` when value is present
242def cookie.opt.of(prefix: String, value: String, empty: Bool) -> String:
243  match empty:
244    case True{}:
245      ""
246    case False{}:
247      prefix ++ value
248
249# an optional attribute clause
250def cookie.opt(prefix: String, +value: String) -> String:
251  cookie.opt.of(prefix, value, String.eq(value, ""))
252
253# `; Domain=…` only when a Domain attribute was set
254def cookie.domain_text(domain: String, host_only: Bool) -> String:
255  match host_only:
256    case True{}:
257      ""
258    case False{}:
259      cookie.opt("; Domain=", domain)
260
261# `; Secure` / `; HttpOnly` when the flag is set
262def cookie.mark(label: String, on: Bool) -> String:
263  match on:
264    case False{}:
265      ""
266    case True{}:
267      "; " ++ label
268
269# Set-Cookie field value (RFC 6265 §4.1.1)
270def cookie.line(c: Cookie) -> String:
271  match c:
272    case Cookie{name, value, expires, max_age, domain, path, secure, http_only,
273      same_site, host_only}:
274      name ++ "=" ++ value
275        ++ cookie.opt("; Expires=", expires)
276        ++ cookie.opt("; Max-Age=", max_age)
277        ++ cookie.domain_text(domain, host_only)
278        ++ cookie.opt("; Path=", path)
279        ++ cookie.mark("Secure", secure)
280        ++ cookie.mark("HttpOnly", http_only)
281        ++ cookie.opt("; SameSite=", same_site)
282
283# a Set-Cookie header
284def cookie.set(c: Cookie) -> Http.Header:
285  Http.H{"Set-Cookie", cookie.line(c)}
286
287# keep name=value when the name is not empty
288def cookie.pairs.keep(name: String, value: String, empty: Bool,
289  rest: Unit -> List<&2, String>) -> List<&2, String>:
290  match empty:
291    case True{}:
292      rest(Unit{})
293    case False{}:
294      (name ++ "=" ++ value) <> rest(Unit{})
295
296# one cookie as a request pair, dropped when the name is empty
297def cookie.pairs.one(c: Cookie, rest: Unit -> List<&2, String>)
298  -> List<&2, String>:
299  match c:
300    case Cookie{+name, value, expires, max_age, domain, path, secure, http_only,
301      same_site, host_only}:
302      cookie.pairs.keep(name, value, String.eq(name, ""), rest)
303
304# name=value pieces, empty names removed
305def cookie.pairs(cs: List<&2, Cookie>) -> List<&2, String>:
306  match cs:
307    case []:
308      []
309    case h <> t:
310      cookie.pairs.one(h, _u => cookie.pairs(t))
311
312# Cookie field value: pairs joined by `; ` (RFC 6265 §5.4)
313def cookie.join(cs: List<&2, Cookie>) -> String:
314  String.join(cookie.pairs(cs), "; ")
315
316# a Cookie request header
317def cookie.request(cs: List<&2, Cookie>) -> Http.Header:
318  Http.H{"Cookie", cookie.join(cs)}
319
320# count `/` in a path
321def cookie.slashes.ch(slash: Bool, rest: Unit -> Nat) -> Nat:
322  match slash:
323    case False{}:
324      rest(Unit{})
325    case True{}:
326      Nat.add(1n, rest(Unit{}))
327
328# how many slashes a path holds
329def cookie.slashes(p: String) -> Nat:
330  match p:
331    case SNil{}:
332      0n
333    case SCon{h, t}:
334      cookie.slashes.ch(Char.is_eq(h, '/'), _u => cookie.slashes(t))
335
336# drop the reversed prefix through the first slash
337def cookie.skip.ch(hit: Bool, +tail: String, rest: Unit -> String) -> String:
338  match hit:
339    case True{}:
340      tail
341    case False{}:
342      rest(Unit{})
343
344# reversed path with the last segment removed
345def cookie.skip(p: String) -> String:
346  match p:
347    case SNil{}:
348      ""
349    case SCon{h, +t}:
350      cookie.skip.ch(Char.is_eq(h, '/'), t, _u => cookie.skip(t))
351
352# characters before the right-most slash
353def cookie.before(p: String) -> String:
354  String.reverse(cookie.skip(String.reverse(p)))
355
356# `/` when the path has fewer than two slashes, else the directory prefix
357def cookie.default_of(+p: String, few: Bool) -> String:
358  match few:
359    case True{}:
360      "/"
361    case False{}:
362      cookie.before(p)
363
364# default-path (RFC 6265 §5.1.4)
365def cookie.default_path(+p: String) -> String:
366  cookie.default_of(p, Nat.is_lt(cookie.slashes(p), 2n))
367
368# host-only cookies take the response host; Domain stays as parsed
369def cookie.fill_domain(domain: String, host_only: Bool, host: String) -> String:
370  match host_only:
371    case True{}:
372      host
373    case False{}:
374      domain
375
376# an absent Path becomes default-path
377def cookie.fill_path.of(path: String, req: String, empty: Bool) -> String:
378  match empty:
379    case True{}:
380      cookie.default_path(req)
381    case False{}:
382      path
383
384# an absent Path becomes default-path
385def cookie.fill_path(+path: String, req: String) -> String:
386  cookie.fill_path.of(path, req, String.eq(path, ""))
387
388# store the response host on a host-only cookie and fill default-path
389def cookie.receive(host: String, req_path: String, c: Cookie) -> Cookie:
390  match c:
391    case Cookie{name, value, expires, max_age, domain, path, secure, http_only,
392      same_site, +host_only}:
393      Cookie{name, value, expires, max_age,
394        cookie.fill_domain(domain, host_only, String.to_lower(host)),
395        cookie.fill_path(path, req_path), secure, http_only, same_site,
396        host_only}
397
398# a dot continues the scan, a digit keeps it, anything else stops
399def cookie.ipv4.step(dot: Bool, digit: Bool, if_dot: Unit -> Bool,
400  if_digit: Unit -> Bool) -> Bool:
401  match dot digit:
402    case True{} _:
403      if_dot(Unit{})
404    case False{} True{}:
405      if_digit(Unit{})
406    case False{} False{}:
407      False{}
408
409# true when the host is only digits and dots and contains a dot
410def cookie.ipv4.go(host: String, seen: Bool) -> Bool:
411  match host:
412    case SNil{}:
413      seen
414    case SCon{h, +t}:
415      +u = Char.to_u32(h)
416      cookie.ipv4.step(U32.is_eq(u, 46),
417        Bool.and(U32.is_ge(u, 48), U32.is_le(u, 57)),
418        _u => cookie.ipv4.go(t, True{}), _d => cookie.ipv4.go(t, seen))
419
420# IPv4 literal (no subdomain match, RFC 6265 §5.1.3)
421def cookie.ipv4(host: String) -> Bool:
422  cookie.ipv4.go(host, False{})
423
424# suffix match when the host is not an IPv4 address
425def cookie.domain_suffix(host: String, domain: String, ip: Bool) -> Bool:
426  match ip:
427    case True{}:
428      False{}
429    case False{}:
430      String.ends_with(host, "." ++ domain)
431
432# exact match, else the suffix rule
433def cookie.domain_eq(+host: String, +domain: String, same: Bool) -> Bool:
434  match same:
435    case True{}:
436      True{}
437    case False{}:
438      cookie.domain_suffix(host, domain, cookie.ipv4(host))
439
440# domain-match (RFC 6265 §5.1.3). Host and domain are compared lowercase.
441def cookie.domain_match(+host: String, +domain: String) -> Bool:
442  cookie.domain_eq(host, domain, String.eq(host, domain))
443
444# host-only is exact; a Domain attribute uses domain-match
445def cookie.domain_ok(host: String, domain: String, host_only: Bool) -> Bool:
446  match host_only:
447    case True{}:
448      String.eq(String.to_lower(host), String.to_lower(domain))
449    case False{}:
450      cookie.domain_match(String.to_lower(host), String.to_lower(domain))
451
452# the next request character is `/`, or the cookie-path already ended in `/`
453def cookie.path_slash(+req: String, +cpath: String, slash: Bool) -> Bool:
454  match slash:
455    case True{}:
456      True{}
457    case False{}:
458      String.starts_with(String.drop(req, String.length(cpath)), "/")
459
460# identical paths match; otherwise the prefix rules (RFC 6265 §5.1.4)
461def cookie.path_rest(+req: String, +cpath: String, same: Bool) -> Bool:
462  match same:
463    case True{}:
464      True{}
465    case False{}:
466      cookie.path_slash(req, cpath, String.ends_with(cpath, "/"))
467
468# path-match once the cookie-path is known to be a prefix
469def cookie.path_prefix(+req: String, +cpath: String, hit: Bool) -> Bool:
470  match hit:
471    case False{}:
472      False{}
473    case True{}:
474      cookie.path_rest(req, cpath, String.eq(req, cpath))
475
476# path-match (RFC 6265 §5.1.4)
477def cookie.path_ok(+req: String, +cpath: String) -> Bool:
478  cookie.path_prefix(req, cpath, String.starts_with(req, cpath))
479
480# Secure cookies ride only on TLS (RFC 6265 §5.4)
481def cookie.send.secure(tls: Bool, secure: Bool) -> Bool:
482  match secure:
483    case False{}:
484      True{}
485    case True{}:
486      tls
487
488# path-match, then the Secure bit
489def cookie.send.path(tls: Bool, secure: Bool, ok: Bool) -> Bool:
490  match ok:
491    case False{}:
492      False{}
493    case True{}:
494      cookie.send.secure(tls, secure)
495
496# domain-match, then path-match
497def cookie.send.domain(req: String, tls: Bool, secure: Bool, cpath: String,
498  ok: Bool) -> Bool:
499  match ok:
500    case False{}:
501      False{}
502    case True{}:
503      cookie.send.path(tls, secure, cookie.path_ok(req, cpath))
504
505# whether this cookie is sent on this request (RFC 6265 §5.4)
506def cookie.send(host: String, req_path: String, tls: Bool, c: Cookie) -> Bool:
507  match c:
508    case Cookie{name, value, expires, max_age, domain, path, secure, http_only,
509      same_site, host_only}:
510      cookie.send.domain(req_path, tls, secure, path,
511        cookie.domain_ok(host, domain, host_only))
512
513# no Max-Age means a session cookie (still sendable). 0 means already expired.
514def cookie.alive.of(age: Nat, m: Maybe<&2, Nat>) -> Bool:
515  match m:
516    case None{}:
517      True{}
518    case Some{n}:
519      Nat.is_lt(age, n)
520
521# Max-Age against an age in seconds. Expires is not compared to a clock.
522def cookie.alive(max_age: String, age: Nat) -> Bool:
523  cookie.alive.of(age, Http.digits.read(max_age))
524
525# cons when the cookie matches the request
526def cookie.select.cons(ok: Bool, c: Cookie, rest: Unit -> List<&2, Cookie>)
527  -> List<&2, Cookie>:
528  match ok:
529    case False{}:
530      rest(Unit{})
531    case True{}:
532      c <> rest(Unit{})
533
534# cookies that would be sent (domain, path, secure)
535def cookie.select(cs: List<&2, Cookie>, +host: String, +req_path: String,
536  +tls: Bool) -> List<&2, Cookie>:
537  match cs:
538    case []:
539      []
540    case +h <> t:
541      cookie.select.cons(cookie.send(host, req_path, tls, h), h,
542        _u => cookie.select(t, host, req_path, tls))