From c6c2fccb23f644a3c7a9d3fab7ac99d3f50e690f Mon Sep 17 00:00:00 2001 From: Chris Watson Date: Mon, 13 Apr 2020 19:34:05 -0600 Subject: [PATCH] net.http: add cookie support --- vlib/net/http/cookie.v | 428 ++++++++++++++++++++++++++++++++++++ vlib/net/http/cookie_test.v | 294 +++++++++++++++++++++++++ 2 files changed, 722 insertions(+) create mode 100644 vlib/net/http/cookie.v create mode 100644 vlib/net/http/cookie_test.v diff --git a/vlib/net/http/cookie.v b/vlib/net/http/cookie.v new file mode 100644 index 0000000000..57fc24bf3a --- /dev/null +++ b/vlib/net/http/cookie.v @@ -0,0 +1,428 @@ +// 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 + arrays + 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 { + mut parts := line.trim_space().split(';') + if parts.len == 1 && parts[0] == '' { + continue + } + parts[0] = parts[0].trim_space() + keyval := parts[0].split('=') + if keyval.len != 2 { + continue + } + name := keyval[0] + _value := keyval[1] + if !is_cookie_name_valid(name) { + continue + } + value := parse_cookie_value(_value, true) or { + continue + } + 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 _val := '' + if attr.contains('=') { + pieces := attr.split('=') + attr = pieces[0] + _val = pieces[1] + } + lower_attr := attr.to_lower() + val := parse_cookie_value(_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 + } + '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] + } + } + } + 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 { + _parts := line.split(';') + part = _parts[0] + line = _parts[1] + } else { + part = line + line = '' + } + part = part.trim_space() + if part.len == 0 { + continue + } + mut name := part + mut val := '' + if part.contains('=') { + _parts := part.split('=') + name = _parts[0] + val = _parts[1] + } + if !is_cookie_name_valid(name) { + continue + } + if filter != '' && filter != name { + continue + } + // Circumvent the issue with assigning an `or` expression to an existing value + // TODO: Fix when fixed in compiler + _val := parse_cookie_value(val, true) or { + continue + } + val = _val + 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(c.name) + b.write('=') + b.write(sanitize_cookie_value(c.value)) + if c.path.len > 0 { + b.write('; path=') + b.write(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('; domain=') + b.write(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('; expires=') + b.write(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('; Max-Age=') + b.write(c.max_age.str()) + } else if c.max_age < 0 { + b.write('; Max-Age=0') + } + if c.http_only { + b.write('; HttpOnly') + } + if c.secure { + b.write('; Secure') + } + match c.same_site { + .same_site_default_mode { + b.write('; SameSite') + } + .same_site_none_mode { + b.write('; SameSite=None') + } + .same_site_lax_mode { + b.write('; SameSite=Lax') + } + .same_site_strict_mode { + b.write('; SameSite=Strict') + } + else { + // Do nothing + } + } + return b.str() +} + +fn sanitize(valid fn(byte) 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 + } + // TODO: Use `filter` instead of this nonesense + buf := v.bytes() + mut bytes := v.bytes() + for i, _ in buf { + if (!valid(buf[i])) { + bytes.delete(i) + } + } + return string(bytes) +} + +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 byte) bool { + return 0x20 <= b && b < 0x7f && b != `"` && b != `;` && b != `\\` +} + +fn valid_cookie_path_byte(b byte) 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.bytes() { + if !(b in arrays.range(33, 126)) { + return false + } + } + return true +} diff --git a/vlib/net/http/cookie_test.v b/vlib/net/http/cookie_test.v new file mode 100644 index 0000000000..59b94b0439 --- /dev/null +++ b/vlib/net/http/cookie_test.v @@ -0,0 +1,294 @@ +import ( + net.http + time +) + +struct SetCookieTestCase { + cookie &http.Cookie + raw string +} + +struct ReadSetCookiesTestCase { + header map[string][]string + cookies []&http.Cookie +} + +struct AddCookieTestCase { + cookie []&http.Cookie + raw string +} + +const ( + write_set_cookie_tests = [ + SetCookieTestCase{ + cookie: &http.Cookie{name: 'cookie-1', value: 'v1'}, + raw: 'cookie-1=v1' + }, + SetCookieTestCase{ + cookie: &http.Cookie{name: 'cookie-2', value: 'two', max_age: 3600}, + raw: 'cookie-2=two; Max-Age=3600' + }, + SetCookieTestCase{ + cookie: &http.Cookie{name: 'cookie-3', value: 'three', domain: '.example.com'}, + raw: 'cookie-3=three; domain=example.com' + }, + SetCookieTestCase{ + cookie: &http.Cookie{name: 'cookie-4', value: 'four', path: '/restricted/'}, + raw: 'cookie-4=four; path=/restricted/' + }, + SetCookieTestCase{ + cookie: &http.Cookie{name: 'cookie-5', value: 'five', domain: 'wrong;bad.abc'}, + raw: 'cookie-5=five' + }, + SetCookieTestCase{ + cookie: &http.Cookie{name: 'cookie-6', value: 'six', domain: 'bad-.abc'}, + raw: 'cookie-6=six' + }, + // SetCookieTestCase{ + // cookie: &http.Cookie{name: 'cookie-7', value: 'seven', domain: '127.0.0.1'}, + // raw: 'cookie-7=seven; domain=127.0.0.1' + // }, + SetCookieTestCase{ + cookie: &http.Cookie{name: 'cookie-8', value: 'eight', domain: '::1'}, + raw: 'cookie-8=eight' + }, + // { + // cookie: &http.Cookie{name: 'cookie-9', value: 'expiring', expires: time.unix(1257894000, 0)}, + // 'cookie-9=expiring; Expires=Tue, 10 Nov 2009 23:00:00 GMT', + // }, + // According to IETF 6265 Section 5.1.1.5, the year cannot be less than 1601 + // SetCookieTestCase{ + // cookie: &http.Cookie{name: 'cookie-10', value: 'expiring-1601', expires: time.parse('Mon, 01 Jan 1601 01:01:01 GMT')}, + // raw: 'cookie-10=expiring-1601; Expires=Mon, 01 Jan 1601 01:01:01 GMT' + // }, + // SetCookieTestCase{ + // cookie: &http.Cookie{name: 'cookie-11', value: 'invalid-expiry', expires: time.parse('Mon, 01 Jan 1600 01:01:01 GMT')}, + // raw: 'cookie-11=invalid-expiry' + // }, + SetCookieTestCase{ + cookie: &http.Cookie{name: 'cookie-12', value: 'samesite-default', same_site: .same_site_default_mode}, + raw: 'cookie-12=samesite-default; SameSite' + }, + SetCookieTestCase{ + cookie: &http.Cookie{name: 'cookie-13', value: 'samesite-lax', same_site: .same_site_lax_mode}, + raw: 'cookie-13=samesite-lax; SameSite=Lax' + }, + SetCookieTestCase{ + cookie: &http.Cookie{name: 'cookie-14', value: 'samesite-strict', same_site: .same_site_strict_mode}, + raw: 'cookie-14=samesite-strict; SameSite=Strict' + }, + SetCookieTestCase{ + cookie: &http.Cookie{name: 'cookie-15', value: 'samesite-none', same_site: .same_site_none_mode}, + raw: 'cookie-15=samesite-none; SameSite=None' + }, + // The 'special' cookies have values containing commas or spaces which + // are disallowed by RFC 6265 but are common in the wild. + SetCookieTestCase{ + cookie: &http.Cookie{name: 'special-1', value: 'a z'}, + raw: 'special-1=a z' + }, + SetCookieTestCase{ + cookie: &http.Cookie{name: 'special-2', value: ' z'}, + raw: 'special-2=" z"' + }, + SetCookieTestCase{ + cookie: &http.Cookie{name: 'special-3', value: 'a '}, + raw: 'special-3="a "' + }, + SetCookieTestCase{ + cookie: &http.Cookie{name: 'special-4', value: ' '}, + raw: 'special-4=" "' + }, + SetCookieTestCase{ + cookie: &http.Cookie{name: 'special-5', value: 'a,z'}, + raw: 'special-5=a,z' + }, + SetCookieTestCase{ + cookie: &http.Cookie{name: 'special-6', value: ',z'}, + raw: 'special-6=",z"' + }, + SetCookieTestCase{ + cookie: &http.Cookie{name: 'special-7', value: 'a,'}, + raw: 'special-7="a,"' + }, + SetCookieTestCase{ + cookie: &http.Cookie{name: 'special-8', value: ','}, + raw: 'special-8=","' + }, + SetCookieTestCase{ + cookie: &http.Cookie{name: 'empty-value', value: ''}, + raw: 'empty-value=' + }, + SetCookieTestCase{ + cookie: &http.Cookie{name: ''}, + raw: '' + }, + SetCookieTestCase{ + cookie: &http.Cookie{name: '\t'}, + raw: '' + }, + SetCookieTestCase{ + cookie: &http.Cookie{name: '\r'}, + raw: '' + }, + SetCookieTestCase{ + cookie: &http.Cookie{name: 'a\nb', value: 'v'}, + raw: '' + }, + SetCookieTestCase{ + cookie: &http.Cookie{name: 'a\nb', value: 'v'}, + raw: '' + }, + SetCookieTestCase{ + cookie: &http.Cookie{name: 'a\rb', value: 'v'}, + raw: '' + }, + ] + add_cookies_tests = [ + AddCookieTestCase{ + cookie: [], + raw: "" + }, + AddCookieTestCase{ + cookie: [&http.Cookie{name: "cookie-1", value: "v1"}], + raw: "cookie-1=v1" + }, + AddCookieTestCase{ + cookie: [ + &http.Cookie{name: "cookie-1", value: "v1"}, + &http.Cookie{name: "cookie-2", value: "v2"}, + &http.Cookie{name: "cookie-3", value: "v3"} + ], + raw: "cookie-1=v1; cookie-2=v2; cookie-3=v3" + } + ] + read_set_cookies_tests = [ + ReadSetCookiesTestCase{ + header: {"Set-Cookie": ["Cookie-1=v1"]}, + cookies: [&http.Cookie{name: "Cookie-1", value: "v1", raw: "Cookie-1=v1"}] + }, + // ReadSetCookiesTestCase{ + // header: {"Set-Cookie": ["NID=99=YsDT5i3E-CXax-; expires=Wed, 23-Nov-2011 01:05:03 GMT; path=/; domain=.google.ch; HttpOnly"]}, + // cookies: [&http.Cookie{ + // name: "NID", + // value: "99=YsDT5i3E-CXax-", + // path: "/", + // domain: ".google.ch", + // http_only: true, + // expires: time.parse_iso('Wed, 23-Nov-2011 01:05:03 GMT'), + // raw_expires: "Wed, 23-Nov-2011 01:05:03 GMT", + // raw: "NID=99=YsDT5i3E-CXax-; expires=Wed, 23-Nov-2011 01:05:03 GMT; path=/; domain=.google.ch; HttpOnly" + // }] + // }, + // ReadSetCookiesTestCase{ + // header: {"Set-Cookie": [".ASPXAUTH=7E3AA; expires=Wed, 07-Mar-2012 14:25:06 GMT; path=/; HttpOnly"]}, + // cookies: [&http.Cookie{ + // name: ".ASPXAUTH", + // value: "7E3AA", + // path: "/", + // expires: time.parse_iso('Wed, 07-Mar-2012 14:25:06 GMT'), + // raw_expires: "Wed, 07-Mar-2012 14:25:06 GMT", + // http_only: true, + // raw: ".ASPXAUTH=7E3AA; expires=Wed, 07-Mar-2012 14:25:06 GMT; path=/; HttpOnly" + // }] + // }, + ReadSetCookiesTestCase{ + header: {"Set-Cookie": ["ASP.NET_SessionId=foo; path=/; HttpOnly"]}, + cookies: [&http.Cookie{ + name: "ASP.NET_SessionId", + value: "foo", + path: "/", + http_only: true, + raw: "ASP.NET_SessionId=foo; path=/; HttpOnly" + }] + }, + ReadSetCookiesTestCase{ + header: {"Set-Cookie": ["samesitedefault=foo; SameSite"]}, + cookies: [&http.Cookie{ + name: "samesitedefault", + value: "foo", + same_site: .same_site_default_mode, + raw: "samesitedefault=foo; SameSite" + }] + }, + ReadSetCookiesTestCase{ + header: {"Set-Cookie": ["samesitelax=foo; SameSite=Lax"]}, + cookies: [&http.Cookie{ + name: "samesitelax", + value: "foo", + same_site: .same_site_lax_mode, + raw: "samesitelax=foo; SameSite=Lax" + }] + }, + ReadSetCookiesTestCase{ + header: {"Set-Cookie": ["samesitestrict=foo; SameSite=Strict"]}, + cookies: [&http.Cookie{ + name: "samesitestrict", + value: "foo", + same_site: .same_site_strict_mode, + raw: "samesitestrict=foo; SameSite=Strict" + }] + }, + ReadSetCookiesTestCase{ + header: {"Set-Cookie": ["samesitenone=foo; SameSite=None"]}, + cookies: [&http.Cookie{ + name: "samesitenone", + value: "foo", + same_site: .same_site_none_mode, + raw: "samesitenone=foo; SameSite=None" + }] + }, + // Make sure we can properly read back the Set-Cookie headers we create + // for values containing spaces or commas: + ReadSetCookiesTestCase{ + header: {"Set-Cookie": ['special-1=a z']}, + cookies: [&http.Cookie{name: "special-1", value: "a z", raw: 'special-1=a z'}] + }, + ReadSetCookiesTestCase{ + header: {"Set-Cookie": ['special-2=" z"']}, + cookies: [&http.Cookie{name: "special-2", value: " z", raw: 'special-2=" z"'}] + }, + + ReadSetCookiesTestCase{ + header: {"Set-Cookie": ['special-3="a "']}, + cookies: [&http.Cookie{name: "special-3", value: "a ", raw: 'special-3="a "'}] + }, + ReadSetCookiesTestCase{ + header: {"Set-Cookie": ['special-4=" "']}, + cookies: [&http.Cookie{name: "special-4", value: " ", raw: 'special-4=" "'}] + }, + ReadSetCookiesTestCase{ + header: {"Set-Cookie": ['special-5=a,z']}, + cookies: [&http.Cookie{name: "special-5", value: "a,z", raw: 'special-5=a,z'}] + }, + ReadSetCookiesTestCase{ + header: {"Set-Cookie": ['special-6=",z"']}, + cookies: [&http.Cookie{name: "special-6", value: ",z", raw: 'special-6=",z"'}] + }, + ReadSetCookiesTestCase{ + header: {"Set-Cookie": ['special-7=","']}, + cookies: [&http.Cookie{name: "special-7", value: ",", raw: 'special-8=","'}] + } + // TODO(bradfitz): users have reported seeing this in the + // wild, but do browsers handle it? RFC 6265 just says "don't + // do that" (section 3) and then never mentions header folding + // again. + // Header{"Set-Cookie": ["ASP.NET_SessionId=foo; path=/; HttpOnly, .ASPXAUTH=7E3AA; expires=Wed, 07-Mar-2012 14:25:06 GMT; path=/; HttpOnly"]}, + ] +) + +fn test_write_set_cookies() { + for _, tt in write_set_cookie_tests { + assert tt.cookie.str() == tt.raw + } +} + +fn test_read_set_cookies() { + for _, tt in read_set_cookies_tests { + h := tt.header['Set-Cookie'][0] + c := http.read_set_cookies(tt.header) + println(h) + println(c[0].str()) + assert c[0].str() == h + } +}