| 1 | // Copyright (c) 2019 Alexander Medvednikov. All rights reserved. |
| 2 | // Use of this source code is governed by an MIT license |
| 3 | // that can be found in the LICENSE file. |
| 4 | module http |
| 5 | |
| 6 | import time |
| 7 | import strings |
| 8 | |
| 9 | pub struct Cookie { |
| 10 | pub mut: |
| 11 | name string |
| 12 | value string |
| 13 | path string // optional |
| 14 | domain string // optional |
| 15 | expires time.Time // optional |
| 16 | raw_expires string // for reading cookies only. optional. |
| 17 | // max_age=0 means no 'Max-Age' attribute specified. |
| 18 | // max_age<0 means delete cookie now, equivalently 'Max-Age: 0' |
| 19 | // max_age>0 means Max-Age attribute present and given in seconds |
| 20 | max_age int |
| 21 | secure bool |
| 22 | http_only bool |
| 23 | same_site SameSite |
| 24 | raw string |
| 25 | unparsed []string // Raw text of unparsed attribute-value pairs |
| 26 | } |
| 27 | |
| 28 | // SameSite allows a server to define a cookie attribute making it impossible for |
| 29 | // the browser to send this cookie along with cross-site requests. The main |
| 30 | // goal is to mitigate the risk of cross-origin information leakage, and provide |
| 31 | // some protection against cross-site request forgery attacks. |
| 32 | // |
| 33 | // See https://tools.ietf.org/html/draft-ietf-httpbis-cookie-same-site-00 for details. |
| 34 | pub enum SameSite { |
| 35 | same_site_not_set |
| 36 | same_site_default_mode = 1 |
| 37 | same_site_lax_mode |
| 38 | same_site_strict_mode |
| 39 | same_site_none_mode |
| 40 | } |
| 41 | |
| 42 | // Parses all "Cookie" values from the header `h` and |
| 43 | // returns the successfully parsed Cookies. |
| 44 | // |
| 45 | // if `filter` isn't empty, only cookies of that name are returned |
| 46 | pub fn read_cookies(h Header, filter string) []&Cookie { |
| 47 | // lines := h['Cookie'] |
| 48 | lines := h.values(.cookie) // or { |
| 49 | if lines.len == 0 { |
| 50 | return [] |
| 51 | } |
| 52 | mut cookies := []&Cookie{} |
| 53 | for _, line_ in lines { |
| 54 | mut line := line_.trim_space() |
| 55 | mut part := '' |
| 56 | for line.len > 0 { |
| 57 | mut semicolon_position := |
| 58 | line.index_any(';') // Store the position of the next semicolon |
| 59 | if semicolon_position > 0 { // So, there is a semicolon, let's parse until that position |
| 60 | line_parts := |
| 61 | line[..semicolon_position].split(';') // split the line only until that semicolon |
| 62 | line = line[(semicolon_position + 1)..] // and then skip everything before the semicolon |
| 63 | part = line_parts[0] |
| 64 | } else { |
| 65 | part = line |
| 66 | line = '' |
| 67 | } |
| 68 | part = part.trim_space() |
| 69 | if part.len == 0 { |
| 70 | continue |
| 71 | } |
| 72 | mut name, mut val := part.split_once('=') or { part, '' } |
| 73 | if !is_cookie_name_valid(name) { |
| 74 | continue |
| 75 | } |
| 76 | if filter != '' && filter != name { |
| 77 | continue |
| 78 | } |
| 79 | val = parse_cookie_value(val, true) or { continue } |
| 80 | cookies << &Cookie{ |
| 81 | name: name |
| 82 | value: val |
| 83 | } |
| 84 | } |
| 85 | } |
| 86 | return cookies |
| 87 | } |
| 88 | |
| 89 | // str returns the serialization of the cookie for use in a Cookie header |
| 90 | // (if only Name and Value are set) or a Set-Cookie response |
| 91 | // header (if other fields are set). |
| 92 | // |
| 93 | // If c.name is invalid, the empty string is returned. |
| 94 | pub fn (c &Cookie) str() string { |
| 95 | if !is_cookie_name_valid(c.name) { |
| 96 | return '' |
| 97 | } |
| 98 | // extra_cookie_length derived from typical length of cookie attributes |
| 99 | // see RFC 6265 Sec 4.1. |
| 100 | extra_cookie_length := 110 |
| 101 | mut b := strings.new_builder(c.name.len + c.value.len + c.domain.len + c.path.len + |
| 102 | extra_cookie_length) |
| 103 | b.write_string(c.name) |
| 104 | b.write_string('=') |
| 105 | b.write_string(sanitize_cookie_value(c.value)) |
| 106 | if c.path.len > 0 { |
| 107 | b.write_string('; path=') |
| 108 | b.write_string(sanitize_cookie_path(c.path)) |
| 109 | } |
| 110 | if c.domain.len > 0 { |
| 111 | if valid_cookie_domain(c.domain) { |
| 112 | // A `domain` containing illegal characters is not |
| 113 | // sanitized but simply dropped which turns the cookie |
| 114 | // into a host-only cookie. A leading dot is okay |
| 115 | // but won't be sent. |
| 116 | mut d := c.domain |
| 117 | if d[0] == `.` { |
| 118 | d = d.substr(1, d.len) |
| 119 | } |
| 120 | b.write_string('; domain=') |
| 121 | b.write_string(d) |
| 122 | } else { |
| 123 | // TODO: Log invalid cookie domain warning |
| 124 | } |
| 125 | } |
| 126 | if c.expires.year > 1600 { |
| 127 | time_str := c.expires.http_header_string() |
| 128 | b.write_string('; expires=') |
| 129 | b.write_string(time_str) |
| 130 | } |
| 131 | // TODO: Fix this. Technically a max age of 0 or less should be 0 |
| 132 | // We need a way to not have a max age. |
| 133 | if c.max_age > 0 { |
| 134 | b.write_string('; Max-Age=') |
| 135 | b.write_string(c.max_age.str()) |
| 136 | } else if c.max_age < 0 { |
| 137 | b.write_string('; Max-Age=0') |
| 138 | } |
| 139 | if c.http_only { |
| 140 | b.write_string('; HttpOnly') |
| 141 | } |
| 142 | if c.secure { |
| 143 | b.write_string('; Secure') |
| 144 | } |
| 145 | match c.same_site { |
| 146 | .same_site_not_set {} |
| 147 | .same_site_default_mode { |
| 148 | b.write_string('; SameSite') |
| 149 | } |
| 150 | .same_site_none_mode { |
| 151 | b.write_string('; SameSite=None') |
| 152 | } |
| 153 | .same_site_lax_mode { |
| 154 | b.write_string('; SameSite=Lax') |
| 155 | } |
| 156 | .same_site_strict_mode { |
| 157 | b.write_string('; SameSite=Strict') |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | return b.str() |
| 162 | } |
| 163 | |
| 164 | fn sanitize(valid fn (u8) bool, v string) string { |
| 165 | mut ok := true |
| 166 | for i in 0 .. v.len { |
| 167 | if valid(v[i]) { |
| 168 | continue |
| 169 | } |
| 170 | // TODO: Warn that we're dropping the invalid byte? |
| 171 | ok = false |
| 172 | break |
| 173 | } |
| 174 | if ok { |
| 175 | return v.clone() |
| 176 | } |
| 177 | return v.bytes().filter(valid(it)).bytestr() |
| 178 | } |
| 179 | |
| 180 | fn sanitize_cookie_name(name string) string { |
| 181 | return name.replace_each(['\n', '-', '\r', '-']) |
| 182 | } |
| 183 | |
| 184 | // https://tools.ietf.org/html/rfc6265#section-4.1.1 |
| 185 | // cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE ) |
| 186 | // cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E |
| 187 | // ; US-ASCII characters excluding CTLs, |
| 188 | // ; whitespace DQUOTE, comma, semicolon, |
| 189 | // ; and backslash |
| 190 | // We loosen this as spaces and commas are common in cookie values |
| 191 | // but we produce a quoted cookie-value in when value starts or ends |
| 192 | // with a comma or space. |
| 193 | pub fn sanitize_cookie_value(v string) string { |
| 194 | val := sanitize(valid_cookie_value_byte, v) |
| 195 | if v.len == 0 { |
| 196 | return v |
| 197 | } |
| 198 | // Check for the existence of a space, comma or semicolon |
| 199 | if val.starts_with(' ') || v.contains(';') || val.ends_with(' ') || val.starts_with(',') |
| 200 | || val.ends_with(',') { |
| 201 | return '"${v}"' |
| 202 | } |
| 203 | return v |
| 204 | } |
| 205 | |
| 206 | fn sanitize_cookie_path(v string) string { |
| 207 | return sanitize(valid_cookie_path_byte, v) |
| 208 | } |
| 209 | |
| 210 | fn valid_cookie_value_byte(b u8) bool { |
| 211 | return 0x20 <= b && b < 0x7f && b != `"` && b != `;` && b != `\\` |
| 212 | } |
| 213 | |
| 214 | fn valid_cookie_path_byte(b u8) bool { |
| 215 | return 0x20 <= b && b < 0x7f && b != `!` |
| 216 | } |
| 217 | |
| 218 | fn valid_cookie_domain(v string) bool { |
| 219 | if is_cookie_domain_name(v) { |
| 220 | return true |
| 221 | } |
| 222 | // TODO |
| 223 | // valid_ip := net.parse_ip(v) or { |
| 224 | // false |
| 225 | // } |
| 226 | // if valid_ip { |
| 227 | // return true |
| 228 | // } |
| 229 | return false |
| 230 | } |
| 231 | |
| 232 | pub fn is_cookie_domain_name(_s string) bool { |
| 233 | mut s := _s |
| 234 | if s.len == 0 { |
| 235 | return false |
| 236 | } |
| 237 | if s.len > 255 { |
| 238 | return false |
| 239 | } |
| 240 | if s[0] == `.` { |
| 241 | s = s.substr(1, s.len) |
| 242 | } |
| 243 | mut last := `.` |
| 244 | mut ok := false |
| 245 | mut part_len := 0 |
| 246 | for i, _ in s { |
| 247 | c := s[i] |
| 248 | if c.is_letter() { |
| 249 | // No '_' allowed here (in contrast to package net). |
| 250 | ok = true |
| 251 | part_len++ |
| 252 | } else if `0` <= c && c <= `9` { |
| 253 | // fine |
| 254 | part_len++ |
| 255 | } else if c == `-` { |
| 256 | // Byte before dash cannot be dot. |
| 257 | if last == `.` { |
| 258 | return false |
| 259 | } |
| 260 | part_len++ |
| 261 | } else if c == `.` { |
| 262 | // Byte before dot cannot be dot, dash. |
| 263 | if last == `.` || last == `-` { |
| 264 | return false |
| 265 | } |
| 266 | if part_len > 63 || part_len == 0 { |
| 267 | return false |
| 268 | } |
| 269 | part_len = 0 |
| 270 | } else { |
| 271 | return false |
| 272 | } |
| 273 | last = c |
| 274 | } |
| 275 | if last == `-` || part_len > 63 { |
| 276 | return false |
| 277 | } |
| 278 | return ok |
| 279 | } |
| 280 | |
| 281 | fn parse_cookie_value(_raw string, allow_double_quote bool) !string { |
| 282 | mut raw := _raw |
| 283 | // Strip the quotes, if present |
| 284 | if allow_double_quote && raw.len > 1 && raw[0] == `"` && raw[raw.len - 1] == `"` { |
| 285 | raw = raw.substr(1, raw.len - 1) |
| 286 | } |
| 287 | for i in 0 .. raw.len { |
| 288 | if !valid_cookie_value_byte(raw[i]) { |
| 289 | return error('http.cookie: invalid cookie value') |
| 290 | } |
| 291 | } |
| 292 | return raw |
| 293 | } |
| 294 | |
| 295 | fn is_cookie_name_valid(name string) bool { |
| 296 | if name == '' { |
| 297 | return false |
| 298 | } |
| 299 | for b in name { |
| 300 | if b < 33 || b > 126 { |
| 301 | return false |
| 302 | } |
| 303 | } |
| 304 | return true |
| 305 | } |
| 306 | |
| 307 | fn parse_cookie(line string) !Cookie { |
| 308 | mut parts := line.trim_space().split(';') |
| 309 | if parts.len == 1 && parts[0] == '' { |
| 310 | return error('malformed cookie') |
| 311 | } |
| 312 | parts[0] = parts[0].trim_space() |
| 313 | index := parts[0].index('=') or { return error('malformed cookie') } |
| 314 | name := parts[0][..index] |
| 315 | raw_value := parts[0][index + 1..] |
| 316 | if !is_cookie_name_valid(name) { |
| 317 | return error('malformed cookie') |
| 318 | } |
| 319 | value := parse_cookie_value(raw_value, true) or { return error('malformed cookie') } |
| 320 | mut c := Cookie{ |
| 321 | name: name |
| 322 | value: value |
| 323 | raw: line |
| 324 | } |
| 325 | for i, _ in parts { |
| 326 | parts[i] = parts[i].trim_space() |
| 327 | if parts[i].len == 0 { |
| 328 | continue |
| 329 | } |
| 330 | mut attr := parts[i] |
| 331 | mut raw_val := '' |
| 332 | if ind := parts[i].index('=') { |
| 333 | attr = parts[i][..ind] |
| 334 | raw_val = parts[i][ind + 1..] |
| 335 | } |
| 336 | lower_attr := attr.to_lower() |
| 337 | val := parse_cookie_value(raw_val, false) or { |
| 338 | c.unparsed << parts[i] |
| 339 | continue |
| 340 | } |
| 341 | match lower_attr { |
| 342 | 'samesite' { |
| 343 | lower_val := val.to_lower() |
| 344 | match lower_val { |
| 345 | 'lax' { c.same_site = .same_site_lax_mode } |
| 346 | 'strict' { c.same_site = .same_site_strict_mode } |
| 347 | 'none' { c.same_site = .same_site_none_mode } |
| 348 | else { c.same_site = .same_site_default_mode } |
| 349 | } |
| 350 | } |
| 351 | 'secure' { |
| 352 | c.secure = true |
| 353 | continue |
| 354 | } |
| 355 | 'httponly' { |
| 356 | c.http_only = true |
| 357 | continue |
| 358 | } |
| 359 | 'domain' { |
| 360 | c.domain = val |
| 361 | continue |
| 362 | } |
| 363 | 'max-age' { |
| 364 | mut secs := val.int() |
| 365 | if secs != 0 && val[0] != `0` { |
| 366 | break |
| 367 | } |
| 368 | if secs <= 0 { |
| 369 | secs = -1 |
| 370 | } |
| 371 | c.max_age = secs |
| 372 | continue |
| 373 | } |
| 374 | // TODO: Fix this once time works better |
| 375 | // 'expires' { |
| 376 | // c.raw_expires = val |
| 377 | // mut exptime := time.parse_iso(val) |
| 378 | // if exptime.year == 0 { |
| 379 | // exptime = time.parse_iso('Mon, 02-Jan-2006 15:04:05 MST') |
| 380 | // } |
| 381 | // c.expires = exptime |
| 382 | // continue |
| 383 | // } |
| 384 | 'path' { |
| 385 | c.path = val |
| 386 | continue |
| 387 | } |
| 388 | else { |
| 389 | c.unparsed << parts[i] |
| 390 | } |
| 391 | } |
| 392 | } |
| 393 | return c |
| 394 | } |
| 395 | |