Skip to content

Commit 7098e2e

Browse files
committed
Add option to set cookie without escaping and change escaping algorithm
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 })` Also added escaping for cookie path, and changed escaping algorithm according to RFC 6265 "HTTP State Management Mechanism", see [1]. This change was added as a part of http v2 support in commit 'Added ability to set and get cookie without escaping' (42e3002) and later reverted in scope of ticket with discard v2. 1. https://tools.ietf.org/html/rfc6265 Follows up #126 Part of #134
1 parent a1b7405 commit 7098e2e

File tree

5 files changed

+319
-16
lines changed

5 files changed

+319
-16
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
2020
- Add editorconfig to configure indentation.
2121
- Add luacheck integration.
2222
- Add option to get cookie without escaping.
23+
- Add option to set cookie without escaping and change escaping algorithm.
2324

2425
### Fixed
2526

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,8 +240,9 @@ end
240240
* `resp.status` - HTTP response code.
241241
* `resp.headers` - a Lua table with normalized headers.
242242
* `resp.body` - response body (string|table|wrapped\_iterator).
243-
* `resp:setcookie({ name = 'name', value = 'value', path = '/', expires = '+1y', domain = 'example.com'))` -
244-
adds `Set-Cookie` headers to `resp.headers`.
243+
* `resp:setcookie({ name = 'name', value = 'value', path = '/', expires = '+1y', domain = 'example.com'}, {raw = true})` -
244+
adds `Set-Cookie` headers to `resp.headers`, if `raw` option was set then cookie will not be escaped,
245+
otherwise cookie's value and path will be escaped
245246

246247
### Examples
247248

http/server.lua

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,57 @@ local function sprintf(fmt, ...)
2323
return string.format(fmt, ...)
2424
end
2525

26+
local function valid_cookie_value_byte(byte)
27+
-- https://tools.ietf.org/html/rfc6265#section-4.1.1
28+
-- US-ASCII characters excluding CTLs, whitespace DQUOTE, comma, semicolon,
29+
-- and backslash.
30+
return 32 < byte and byte < 127 and byte ~= string.byte('"') and
31+
byte ~= string.byte(",") and byte ~= string.byte(";") and byte ~= string.byte("\\")
32+
end
33+
34+
local function valid_cookie_path_byte(byte)
35+
-- https://tools.ietf.org/html/rfc6265#section-4.1.1
36+
-- <any CHAR except CTLs or ";">
37+
return 32 <= byte and byte < 127 and byte ~= string.byte(";")
38+
end
39+
40+
local function escape_char(char)
41+
return string.format('%%%02X', string.byte(char))
42+
end
43+
44+
local function unescape_char(char)
45+
return string.char(tonumber(char, 16))
46+
end
47+
48+
local function escape_string(str, byte_filter)
49+
local result = {}
50+
for i = 1, str:len() do
51+
local char = str:sub(i,i)
52+
if byte_filter(string.byte(char)) then
53+
result[i] = char
54+
else
55+
result[i] = escape_char(char)
56+
end
57+
end
58+
return table.concat(result)
59+
end
60+
61+
local function escape_value(cookie_value)
62+
return escape_string(cookie_value, valid_cookie_value_byte)
63+
end
64+
65+
local function escape_path(cookie_path)
66+
return escape_string(cookie_path, valid_cookie_path_byte)
67+
end
68+
2669
local function uri_escape(str)
2770
local res = {}
2871
if type(str) == 'table' then
2972
for _, v in pairs(str) do
3073
table.insert(res, uri_escape(v))
3174
end
3275
else
33-
res = string.gsub(str, '[^a-zA-Z0-9_]',
34-
function(c)
35-
return string.format('%%%02X', string.byte(c))
36-
end
37-
)
76+
res = string.gsub(str, '[^a-zA-Z0-9_]', escape_char)
3877
end
3978
return res
4079
end
@@ -50,11 +89,7 @@ local function uri_unescape(str, unescape_plus_sign)
5089
str = string.gsub(str, '+', ' ')
5190
end
5291

53-
res = string.gsub(str, '%%([0-9a-fA-F][0-9a-fA-F])',
54-
function(c)
55-
return string.char(tonumber(c, 16))
56-
end
57-
)
92+
res = string.gsub(str, '%%([0-9a-fA-F][0-9a-fA-F])', unescape_char)
5893
end
5994
return res
6095
end
@@ -265,7 +300,8 @@ local function expires_str(str)
265300
return os.date(fmt, gmtnow + diff)
266301
end
267302

268-
local function setcookie(resp, cookie)
303+
local function setcookie(resp, cookie, options)
304+
options = options or {}
269305
local name = cookie.name
270306
local value = cookie.value
271307

@@ -276,9 +312,16 @@ local function setcookie(resp, cookie)
276312
error('cookie.value is undefined')
277313
end
278314

279-
local str = sprintf('%s=%s', name, uri_escape(value))
315+
if not options.raw then
316+
value = escape_value(value)
317+
end
318+
local str = sprintf('%s=%s', name, value)
280319
if cookie.path ~= nil then
281-
str = sprintf('%s;path=%s', str, cookie.path)
320+
local cookie_path = cookie.path
321+
if not options.raw then
322+
cookie_path = escape_path(cookie.path)
323+
end
324+
str = sprintf('%s;path=%s', str, cookie_path)
282325
end
283326
if cookie.domain ~= nil then
284327
str = sprintf('%s;domain=%s', str, cookie.domain)
@@ -1280,7 +1323,12 @@ local exports = {
12801323
}
12811324

12821325
return self
1283-
end
1326+
end,
1327+
1328+
internal = {
1329+
response_mt = response_mt,
1330+
request_mt = request_mt,
1331+
}
12841332
}
12851333

12861334
return exports

test/integration/http_server_requests_test.lua

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,54 @@ g.test_get_escaped_cookie = function()
252252
t.assert_equals(r.body, 'name=' .. str_non_escaped, 'body with escaped cookie')
253253
end
254254

255+
-- Set escaped cookie (Günter -> G%C3%BCnter).
256+
g.test_set_escaped_cookie = function(g)
257+
local str_escaped = 'G%C3%BCnter'
258+
local str_non_escaped = 'Günter'
259+
260+
local httpd = g.httpd
261+
httpd:route({
262+
path = '/cookie'
263+
}, function(req)
264+
local resp = req:render({
265+
text = ''
266+
})
267+
resp:setcookie({
268+
name = 'name',
269+
value = str_non_escaped
270+
})
271+
return resp
272+
end)
273+
274+
local r = http_client.get(helpers.base_uri .. '/cookie')
275+
t.assert_equals(r.status, 200, 'response status')
276+
t.assert_equals(r.headers['set-cookie'], 'name=' .. str_escaped, 'header with escaped cookie')
277+
end
278+
279+
-- Set raw cookie (Günter -> Günter).
280+
g.test_set_raw_cookie = function(g)
281+
local cookie = 'Günter'
282+
local httpd = g.httpd
283+
httpd:route({
284+
path = '/cookie'
285+
}, function(req)
286+
local resp = req:render({
287+
text = ''
288+
})
289+
resp:setcookie({
290+
name = 'name',
291+
value = cookie
292+
}, {
293+
raw = true
294+
})
295+
return resp
296+
end)
297+
298+
local r = http_client.get(helpers.base_uri .. '/cookie')
299+
t.assert_equals(r.status, 200, 'response status')
300+
t.assert_equals(r.headers['set-cookie'], 'name=' .. cookie, 'header with raw cookie')
301+
end
302+
255303
-- Request object methods.
256304
g.test_request_object_methods = function()
257305
local httpd = g.httpd

test/unit/http_setcookie_test.lua

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
local t = require('luatest')
2+
3+
local http_server = require('http.server')
4+
5+
local g = t.group()
6+
7+
local function get_object()
8+
return setmetatable({}, http_server.internal.response_mt)
9+
end
10+
11+
g.test_values_escaping = function()
12+
local test_table = {
13+
whitespace = {
14+
value = "f f",
15+
result = 'f%20f',
16+
},
17+
dquote = {
18+
value = 'f"f',
19+
result = 'f%22f',
20+
},
21+
comma = {
22+
value = "f,f",
23+
result = "f%2Cf",
24+
},
25+
semicolon = {
26+
value = "f;f",
27+
result = "f%3Bf",
28+
},
29+
backslash = {
30+
value = "f\\f",
31+
result = "f%5Cf",
32+
},
33+
unicode = {
34+
value = "fюf",
35+
result = "f%D1%8Ef"
36+
},
37+
unprintable_ascii = {
38+
value = string.char(15),
39+
result = "%0F"
40+
}
41+
}
42+
43+
for byte = 33, 126 do
44+
if byte ~= string.byte('"') and
45+
byte ~= string.byte(",") and
46+
byte ~= string.byte(";") and
47+
byte ~= string.byte("\\") then
48+
test_table[byte] = {
49+
value = "f" .. string.char(byte) .. "f",
50+
result = "f" .. string.char(byte) .. "f",
51+
}
52+
end
53+
end
54+
55+
for case_name, case in pairs(test_table) do
56+
local resp = get_object()
57+
resp:setcookie({
58+
name='name',
59+
value = case.value
60+
})
61+
t.assert_equals(resp.headers['set-cookie'], {
62+
"name=" .. case.result
63+
}, case_name)
64+
end
65+
end
66+
67+
g.test_values_raw = function()
68+
local test_table = {}
69+
for byte = 0, 127 do
70+
test_table[byte] = {
71+
value = "f" .. string.char(byte) .. "f",
72+
result = "f" .. string.char(byte) .. "f",
73+
}
74+
end
75+
76+
test_table.unicode = {
77+
value = "fюf",
78+
result = "fюf"
79+
}
80+
81+
for case_name, case in pairs(test_table) do
82+
local resp = get_object()
83+
resp:setcookie({
84+
name='name',
85+
value = case.value
86+
}, {
87+
raw = true
88+
})
89+
t.assert_equals(resp.headers['set-cookie'], {
90+
"name=" .. case.result
91+
}, case_name)
92+
end
93+
end
94+
95+
g.test_path_escaping = function()
96+
local test_table = {
97+
semicolon = {
98+
path = "f;f",
99+
result = "f%3Bf",
100+
},
101+
unicode = {
102+
path = "fюf",
103+
result = "f%D1%8Ef"
104+
},
105+
unprintable_ascii = {
106+
path = string.char(15),
107+
result = "%0F"
108+
}
109+
}
110+
111+
for byte = 32, 126 do
112+
if byte ~= string.byte(";") then
113+
test_table[byte] = {
114+
path = "f" .. string.char(byte) .. "f",
115+
result = "f" .. string.char(byte) .. "f",
116+
}
117+
end
118+
end
119+
120+
for case_name, case in pairs(test_table) do
121+
local resp = get_object()
122+
resp:setcookie({
123+
name='name',
124+
value = 'value',
125+
path = case.path
126+
})
127+
t.assert_equals(resp.headers['set-cookie'], {
128+
"name=value;" .. 'path=' .. case.result
129+
}, case_name)
130+
end
131+
end
132+
133+
g.test_path_raw = function()
134+
local test_table = {}
135+
for byte = 0, 127 do
136+
test_table[byte] = {
137+
path = "f" .. string.char(byte) .. "f",
138+
result = "f" .. string.char(byte) .. "f",
139+
}
140+
end
141+
142+
test_table.unicode = {
143+
path = "fюf",
144+
result = "fюf"
145+
}
146+
147+
for case_name, case in pairs(test_table) do
148+
local resp = get_object()
149+
resp:setcookie({
150+
name='name',
151+
value = 'value',
152+
path = case.path
153+
}, {
154+
raw = true
155+
})
156+
t.assert_equals(resp.headers['set-cookie'], {
157+
"name=value;" .. 'path=' .. case.result
158+
}, case_name)
159+
end
160+
end
161+
162+
g.test_set_header = function()
163+
local test_table = {
164+
name_value = {
165+
cookie = {
166+
name = 'name',
167+
value = 'value'
168+
},
169+
result = {"name=value"},
170+
},
171+
name_value_path = {
172+
cookie = {
173+
name = 'name',
174+
value = 'value',
175+
path = 'path'
176+
},
177+
result = {"name=value;path=path"},
178+
},
179+
name_value_path_domain = {
180+
cookie = {
181+
name = 'name',
182+
value = 'value',
183+
path = 'path',
184+
domain = 'domain',
185+
},
186+
result = {"name=value;path=path;domain=domain"},
187+
},
188+
name_value_path_domain_expires = {
189+
cookie = {
190+
name = 'name',
191+
value = 'value',
192+
path = 'path',
193+
domain = 'domain',
194+
expires = 'expires'
195+
},
196+
result = {"name=value;path=path;domain=domain;expires=expires"},
197+
},
198+
}
199+
200+
for case_name, case in pairs(test_table) do
201+
local resp = get_object()
202+
resp:setcookie(case.cookie)
203+
t.assert_equals(resp.headers["set-cookie"], case.result, case_name)
204+
end
205+
end

0 commit comments

Comments
 (0)