From 8a48967b68010be532eb344f66e8e596bed94fe6 Mon Sep 17 00:00:00 2001 From: Sergey Bronnikov Date: Fri, 22 Oct 2021 18:46:17 +0300 Subject: [PATCH] Added ability to set and get cookie without escaping TODO: add unit tests Method resp:setcookie() implicitly escapes cookie values. Commit adds ability to set cookie without any escaping with option 'raw': resp:setcookie('name', 'value', { raw = true })` Method req:cookie() implicitly unescapes cookie values. Commit adds ability to get cookie without unescaping: req:cookie('name', { raw = true }) Also added escaping of cookie path, and changed escaping algorithm according to [1]. These changes were added as a part of http v2 support in commit 'Added ability to set and get cookie without escaping' (42e3002228a2e7c10ce45d34da39922d39cb5967) and later reverted in scope of ticket with discard v2. 1. https://tools.ietf.org/html/rfc6265 Follows up #126 Part of #134 --- README.md | 9 +- http/server.lua | 78 ++++++++++++---- .../integration/http_server_requests_test.lua | 90 +++++++++++++++---- 3 files changed, 140 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 0083dad..cdb9414 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,9 @@ end * `req:query_param(name)` - returns a single GET request parameter value. If `name` is `nil`, returns a Lua table with all arguments. * `req:param(name)` - any request parameter, either GET or POST. -* `req:cookie(name)` - to get a cookie in the request. +* `req:cookie(name, {raw = true})` | to get a cookie in the request. if `raw` + option was set then cookie will not be unescaped, otherwise cookie's value + will be unescaped. * `req:stash(name[, value])` - get or set a variable "stashed" when dispatching a route. * `req:url_for(name, args, query)` - returns the route's exact URL. @@ -238,8 +240,9 @@ end * `resp.status` - HTTP response code. * `resp.headers` - a Lua table with normalized headers. * `resp.body` - response body (string|table|wrapped\_iterator). -* `resp:setcookie({ name = 'name', value = 'value', path = '/', expires = '+1y', domain = 'example.com'))` - - adds `Set-Cookie` headers to `resp.headers`. +* `resp:setcookie({ name = 'name', value = 'value', path = '/', expires = '+1y', domain = 'example.com'}, {raw = true})` - + adds `Set-Cookie` headers to `resp.headers`, if `raw` option was set then cookie will not be escaped, + otherwise cookie's value and path will be escaped ### Examples diff --git a/http/server.lua b/http/server.lua index c5e7322..4235d46 100644 --- a/http/server.lua +++ b/http/server.lua @@ -23,6 +23,50 @@ local function sprintf(fmt, ...) return string.format(fmt, ...) end +local function valid_cookie_value_byte(byte) + -- https://tools.ietf.org/html/rfc6265#section-4.1.1 + -- US-ASCII characters excluding CTLs, whitespace DQUOTE, comma, semicolon, + -- and backslash. + return 32 < byte and byte < 127 and byte ~= string.byte('"') and + byte ~= string.byte(",") and byte ~= string.byte(";") and byte ~= string.byte("\\") +end + +local function valid_cookie_path_byte(byte) + -- https://tools.ietf.org/html/rfc6265#section-4.1.1 + -- + return 32 <= byte and byte < 127 and byte ~= string.byte(";") +end + +local function escape_char(char) + return string.format('%%%02X', string.byte(char)) +end + +local function unescape_char(char) + return string.char(tonumber(char, 16)) +end + + +local function escape_string(str, byte_filter) + local result = {} + for i = 1, str:len() do + local char = str:sub(i,i) + if byte_filter(string.byte(char)) then + result[i] = char + else + result[i] = escape_char(char) + end + end + return table.concat(result) +end + +local function escape_value(cookie_value) + return escape_string(cookie_value, valid_cookie_value_byte) +end + +local function escape_path(cookie_path) + return escape_string(cookie_path, valid_cookie_path_byte) +end + local function uri_escape(str) local res = {} if type(str) == 'table' then @@ -30,11 +74,7 @@ local function uri_escape(str) table.insert(res, uri_escape(v)) end else - res = string.gsub(str, '[^a-zA-Z0-9_]', - function(c) - return string.format('%%%02X', string.byte(c)) - end - ) + res = string.gsub(str, '[^a-zA-Z0-9_]', escape_char) end return res end @@ -50,11 +90,7 @@ local function uri_unescape(str, unescape_plus_sign) str = string.gsub(str, '+', ' ') end - res = string.gsub(str, '%%([0-9a-fA-F][0-9a-fA-F])', - function(c) - return string.char(tonumber(c, 16)) - end - ) + res = string.gsub(str, '%%([0-9a-fA-F][0-9a-fA-F])', unescape_char) end return res end @@ -265,7 +301,8 @@ local function expires_str(str) return os.date(fmt, gmtnow + diff) end -local function setcookie(resp, cookie) +local function setcookie(resp, cookie, options) + options = options or {} local name = cookie.name local value = cookie.value @@ -276,9 +313,16 @@ local function setcookie(resp, cookie) error('cookie.value is undefined') end - local str = sprintf('%s=%s', name, uri_escape(value)) + if not options.raw then + value = escape_value(value) + end + local str = sprintf('%s=%s', name, value) if cookie.path ~= nil then - str = sprintf('%s;path=%s', str, cookie.path) + local cookie_path = cookie.path + if not options.raw then + cookie_path = escape_path(cookie.path) + end + str = sprintf('%s;path=%s', str, cookie_path) end if cookie.domain ~= nil then str = sprintf('%s;domain=%s', str, cookie.domain) @@ -304,14 +348,18 @@ local function setcookie(resp, cookie) return resp end -local function cookie(tx, cookie) +local function cookie(tx, cookie, options) + options = options or {} if tx.headers.cookie == nil then return nil end for k, v in string.gmatch( tx.headers.cookie, "([^=,; \t]+)=([^,; \t]+)") do if k == cookie then - return uri_unescape(v) + if not options.raw then + v = uri_unescape(v) + end + return v end end return nil diff --git a/test/integration/http_server_requests_test.lua b/test/integration/http_server_requests_test.lua index 83cdb6e..53be810 100644 --- a/test/integration/http_server_requests_test.lua +++ b/test/integration/http_server_requests_test.lua @@ -211,49 +211,101 @@ pgroup.test_chunked_encoding = function(g) t.assert_equals(r.body, 'chunkedencodingt\r\nest', 'chunked body') end --- Get cookie. -pgroup.test_get_cookie = function(g) +-- Get raw cookie value (Günter -> Günter). +pgroup.test_get_raw_cookie = function(g) + local cookie = 'Günter' local httpd = g.httpd httpd:route({ path = '/receive_cookie' }, function(req) - local foo = req:cookie('foo') - local baz = req:cookie('baz') + local name = req:cookie('name', { + raw = true + }) return req:render({ - text = ('foo=%s; baz=%s'):format(foo, baz) + text = ('name=%s'):format(name) }) end) + local r = http_client.get(helpers.base_uri .. '/receive_cookie', { headers = { - cookie = 'foo=bar; baz=feez', + cookie = 'name=' .. cookie, } }) - t.assert_equals(r.status, 200, 'status') - t.assert_equals(r.body, 'foo=bar; baz=feez', 'body') + + t.assert_equals(r.status, 200, 'response status') + t.assert_equals(r.body, 'name=' .. cookie, 'body with raw cookie') +end + +-- Get escaped cookie (G%C3%BCnter -> Günter). +pgroup.test_get_escaped_cookie = function(g) + local str_escaped = 'G%C3%BCnter' + local str_non_escaped = 'Günter' + local httpd = g.httpd + httpd:route({ + path = '/receive_cookie' + }, function(req) + local name = req:cookie('name') + return req:render({ + text = ('name=%s'):format(name) + }) + end) + + local r = http_client.get(helpers.base_uri .. '/receive_cookie', { + headers = { + cookie = 'name=' .. str_escaped, + } + }) + + t.assert_equals(r.status, 200, 'response status') + t.assert_equals(r.body, 'name=' .. str_non_escaped, 'body with escaped cookie') end --- Cookie. -pgroup.test_set_cookie = function(g) +-- Set escaped cookie (Günter -> G%C3%BCnter). +pgroup.test_set_escaped_cookie = function(g) + local str_escaped = 'G%C3%BCnter' + local str_non_escaped = 'Günter' + local httpd = g.httpd httpd:route({ path = '/cookie' }, function(req) - local resp = req:render({text = ''}) + local resp = req:render({ + text = '' + }) resp:setcookie({ - name = 'test', - value = 'tost', - expires = '+1y', - path = '/abc' + name = 'name', + value = str_non_escaped + }) + return resp + end) + + local r = http_client.get(helpers.base_uri .. '/cookie') + t.assert_equals(r.status, 200, 'response status') + t.assert_equals(r.headers['set-cookie'], 'name=' .. str_escaped, 'header with escaped cookie') +end + +-- Set raw cookie (Günter -> Günter). +pgroup.test_set_raw_cookie = function(g) + local cookie = 'Günter' + local httpd = g.httpd + httpd:route({ + path = '/cookie' + }, function(req) + local resp = req:render({ + text = '' }) resp:setcookie({ - name = 'xxx', - value = 'yyy' + name = 'name', + value = cookie + }, { + raw = true }) return resp end) + local r = http_client.get(helpers.base_uri .. '/cookie') - t.assert_equals(r.status, 200, 'status') - t.assert(r.headers['set-cookie'] ~= nil, 'header') + t.assert_equals(r.status, 200, 'response status') + t.assert_equals(r.headers['set-cookie'], 'name=' .. cookie, 'header with raw cookie') end -- Request object methods.