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. 5 import Base 6 import ./http.bend as Http 7 8 # one cookie. host_only means the Domain attribute was absent (exact host). 9 type 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 24 def cookie.new(name: String, value: String) -> Cookie: 25 Cookie{name, value, "", "", "", "", False{}, False{}, "", True{}} 26 27 # replace Expires 28 def 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 36 def 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 44 def 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 52 def 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 60 def 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 68 def 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 76 def 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 84 def 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 92 def 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) 100 def 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 108 def 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 113 def 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 121 def 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 129 def 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) 137 def cookie.flag(c: Cookie, +name: String) -> Cookie: 138 cookie.flag.secure(c, name, String.eq(name, "secure")) 139 140 # SameSite, or an unrecognized attribute 141 def 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 150 def 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 159 def 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 168 def 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 177 def 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 186 def 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 190 def 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 198 def 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. 202 def 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) 210 def 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 219 def 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 `;` 229 def 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) 238 def cookie.parse(line: String) -> Maybe<&2, Cookie>: 239 cookie.parse.parts(String.split(line, ';')) 240 241 # `; Name=value` when value is present 242 def 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 250 def 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 254 def 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 262 def 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) 270 def 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 284 def 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 288 def 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 297 def 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 305 def 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) 313 def cookie.join(cs: List<&2, Cookie>) -> String: 314 String.join(cookie.pairs(cs), "; ") 315 316 # a Cookie request header 317 def cookie.request(cs: List<&2, Cookie>) -> Http.Header: 318 Http.H{"Cookie", cookie.join(cs)} 319 320 # count `/` in a path 321 def 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 329 def 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 337 def 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 345 def 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 353 def 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 357 def 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) 365 def 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 369 def 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 377 def 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 385 def 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 389 def 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 399 def 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 410 def 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) 421 def cookie.ipv4(host: String) -> Bool: 422 cookie.ipv4.go(host, False{}) 423 424 # suffix match when the host is not an IPv4 address 425 def 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 433 def 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. 441 def 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 445 def 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 `/` 453 def 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) 461 def 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 469 def 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) 477 def 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) 481 def 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 489 def 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 497 def 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) 506 def 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. 514 def 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. 522 def 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 526 def 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) 535 def 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))