// Copyright (c) 2019 Alexander Medvednikov. All rights reserved. // Use of this source code is governed by an MIT license // that can be found in the LICENSE file. module http import time import strings pub struct Cookie { pub mut: name string value string path string // optional domain string // optional expires time.Time // optional raw_expires string // for reading cookies only. optional. // max_age=0 means no 'Max-Age' attribute specified. // max_age<0 means delete cookie now, equivalently 'Max-Age: 0' // max_age>0 means Max-Age attribute present and given in seconds max_age int secure bool http_only bool same_site SameSite raw string unparsed []string // Raw text of unparsed attribute-value pairs } // SameSite allows a server to define a cookie attribute making it impossible for // the browser to send this cookie along with cross-site requests. The main // goal is to mitigate the risk of cross-origin information leakage, and provide // some protection against cross-site request forgery attacks. // // See https://tools.ietf.org/html/draft-ietf-httpbis-cookie-same-site-00 for details. pub enum SameSite { same_site_default_mode = 1 same_site_lax_mode same_site_strict_mode same_site_none_mode } // Parses all "Set-Cookie" values from the header `h` and // returns the successfully parsed Cookies. pub fn read_set_cookies(h map[string][]string) []&Cookie { cookies_s := h['Set-Cookie'] cookie_count := cookies_s.len if cookie_count == 0 { return [] } mut cookies := []&Cookie{} for _, line in cookies_s { c := parse_cookie(line) or { continue } cookies << &c } return cookies } // Parses all "Cookie" values from the header `h` and // returns the successfully parsed Cookies. // // if `filter` isn't empty, only cookies of that name are returned pub fn read_cookies(h map[string][]string, filter string) []&Cookie { lines := h['Cookie'] if lines.len == 0 { return [] } mut cookies := []&Cookie{} for _, line_ in lines { mut line := line_.trim_space() mut part := '' for line.len > 0 { if line.index_any(';') > 0 { line_parts := line.split(';') part = line_parts[0] line = line_parts[1] } else { part = line line = '' } part = part.trim_space() if part.len == 0 { continue } mut name := part mut val := '' if part.contains('=') { val_parts := part.split('=') name = val_parts[0] val = val_parts[1] } if !is_cookie_name_valid(name) { continue } if filter != '' && filter != name { continue } val = parse_cookie_value(val, true) or { continue } cookies << &Cookie{ name: name value: val } } } return cookies } // Returns the serialization of the cookie for use in a Cookie header // (if only Name and Value are set) or a Set-Cookie response // header (if other fields are set). // // If c.name is invalid, the empty string is returned. pub fn (c &Cookie) str() string { if !is_cookie_name_valid(c.name) { return '' } // extra_cookie_length derived from typical length of cookie attributes // see RFC 6265 Sec 4.1. extra_cookie_length := 110 mut b := strings.new_builder(c.name.len + c.value.len + c.domain.len + c.path.len + extra_cookie_length) b.write_string(c.name) b.write_string('=') b.write_string(sanitize_cookie_value(c.value)) if c.path.len > 0 { b.write_string('; path=') b.write_string(sanitize_cookie_path(c.path)) } if c.domain.len > 0 { if valid_cookie_domain(c.domain) { // A `domain` containing illegal characters is not // sanitized but simply dropped which turns the cookie // into a host-only cookie. A leading dot is okay // but won't be sent. mut d := c.domain if d[0] == `.` { d = d.substr(1, d.len) } b.write_string('; domain=') b.write_string(d) } else { // TODO: Log invalid cookie domain warning } } if c.expires.year > 1600 { e := c.expires time_str := '$e.weekday_str(), $e.day.str() $e.smonth() $e.year $e.hhmmss() GMT' b.write_string('; expires=') b.write_string(time_str) } // TODO: Fix this. Techically a max age of 0 or less should be 0 // We need a way to not have a max age. if c.max_age > 0 { b.write_string('; Max-Age=') b.write_string(c.max_age.str()) } else if c.max_age < 0 { b.write_string('; Max-Age=0') } if c.http_only { b.write_string('; HttpOnly') } if c.secure { b.write_string('; Secure') } match c.same_site { .same_site_default_mode { b.write_string('; SameSite') } .same_site_none_mode { b.write_string('; SameSite=None') } .same_site_lax_mode { b.write_string('; SameSite=Lax') } .same_site_strict_mode { b.write_string('; SameSite=Strict') } } return b.str() } fn sanitize(valid fn (u8) bool, v string) string { mut ok := true for i in 0 .. v.len { if valid(v[i]) { continue } // TODO: Warn that we're dropping the invalid byte? ok = false break } if ok { return v.clone() } return v.bytes().filter(valid(it)).bytestr() } fn sanitize_cookie_name(name string) string { return name.replace_each(['\n', '-', '\r', '-']) } // https://tools.ietf.org/html/rfc6265#section-4.1.1 // cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE ) // cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E // ; US-ASCII characters excluding CTLs, // ; whitespace DQUOTE, comma, semicolon, // ; and backslash // We loosen this as spaces and commas are common in cookie values // but we produce a quoted cookie-value in when value starts or ends // with a comma or space. pub fn sanitize_cookie_value(v string) string { val := sanitize(valid_cookie_value_byte, v) if v.len == 0 { return v } // Check for the existence of a space or comma if val.starts_with(' ') || val.ends_with(' ') || val.starts_with(',') || val.ends_with(',') { return '"$v"' } return v } fn sanitize_cookie_path(v string) string { return sanitize(valid_cookie_path_byte, v) } fn valid_cookie_value_byte(b u8) bool { return 0x20 <= b && b < 0x7f && b != `"` && b != `;` && b != `\\` } fn valid_cookie_path_byte(b u8) bool { return 0x20 <= b && b < 0x7f && b != `!` } fn valid_cookie_domain(v string) bool { if is_cookie_domain_name(v) { return true } // TODO // valid_ip := net.parse_ip(v) or { // false // } // if valid_ip { // return true // } return false } pub fn is_cookie_domain_name(_s string) bool { mut s := _s if s.len == 0 { return false } if s.len > 255 { return false } if s[0] == `.` { s = s.substr(1, s.len) } mut last := `.` mut ok := false mut part_len := 0 for i, _ in s { c := s[i] if (`a` <= c && c <= `z`) || (`A` <= c && c <= `Z`) { // No '_' allowed here (in contrast to package net). ok = true part_len++ } else if `0` <= c && c <= `9` { // fine part_len++ } else if c == `-` { // Byte before dash cannot be dot. if last == `.` { return false } part_len++ } else if c == `.` { // Byte before dot cannot be dot, dash. if last == `.` || last == `-` { return false } if part_len > 63 || part_len == 0 { return false } part_len = 0 } else { return false } last = c } if last == `-` || part_len > 63 { return false } return ok } fn parse_cookie_value(_raw string, allow_double_quote bool) ?string { mut raw := _raw // Strip the quotes, if present if allow_double_quote && raw.len > 1 && raw[0] == `"` && raw[raw.len - 1] == `"` { raw = raw.substr(1, raw.len - 1) } for i in 0 .. raw.len { if !valid_cookie_value_byte(raw[i]) { return error('http.cookie: invalid cookie value') } } return raw } fn is_cookie_name_valid(name string) bool { if name == '' { return false } for b in name { if b < 33 || b > 126 { return false } } return true } fn parse_cookie(line string) ?Cookie { mut parts := line.trim_space().split(';') if parts.len == 1 && parts[0] == '' { return error('malformed cookie') } parts[0] = parts[0].trim_space() keyval := parts[0].split('=') if keyval.len != 2 { return error('malformed cookie') } name := keyval[0] raw_value := keyval[1] if !is_cookie_name_valid(name) { return error('malformed cookie') } value := parse_cookie_value(raw_value, true) or { return error('malformed cookie') } mut c := Cookie{ name: name value: value raw: line } for i, _ in parts { parts[i] = parts[i].trim_space() if parts[i].len == 0 { continue } mut attr := parts[i] mut raw_val := '' if attr.contains('=') { pieces := attr.split('=') attr = pieces[0] raw_val = pieces[1] } lower_attr := attr.to_lower() val := parse_cookie_value(raw_val, false) or { c.unparsed << parts[i] continue } match lower_attr { 'samesite' { lower_val := val.to_lower() match lower_val { 'lax' { c.same_site = .same_site_lax_mode } 'strict' { c.same_site = .same_site_strict_mode } 'none' { c.same_site = .same_site_none_mode } else { c.same_site = .same_site_default_mode } } } 'secure' { c.secure = true continue } 'httponly' { c.http_only = true continue } 'domain' { c.domain = val continue } 'max-age' { mut secs := val.int() if secs != 0 && val[0] != `0` { break } if secs <= 0 { secs = -1 } c.max_age = secs continue } // TODO: Fix this once time works better // 'expires' { // c.raw_expires = val // mut exptime := time.parse_iso(val) // if exptime.year == 0 { // exptime = time.parse_iso('Mon, 02-Jan-2006 15:04:05 MST') // } // c.expires = exptime // continue // } 'path' { c.path = val continue } else { c.unparsed << parts[i] } } } return c }