Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 192 additions & 4 deletions fun/lib/http-server.fun
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@
// * Windows: the winsock backend has no RunOnce/Run and its Start takes
// (hwnd, message), so listen()/runOnce()/run() here run on Linux and
// macOS today; wiring the Windows message-pump driver is a follow-up.
//
// Request/response API:
// * req.query(k) and req.form() are URL-decoded; req.form() parses
// application/x-www-form-urlencoded bodies on demand.
// * req.cookies / req.cookie(k) read the Cookie header.
// * res.cookie(name, value, opts) / res.clearCookie(name, opts) write
// Set-Cookie; res.addHeader(k, v) appends multi-value headers.

use 'lib-winsock.fun';
use 'lib-regex.fun';
Expand Down Expand Up @@ -89,20 +96,159 @@ fun _segments(p)
if cur <> '' then result.@add(cur); end if;
end fun;

fun _hexVal(c)
result = '0123456789abcdef'.subpos(c.lower());
end fun;

// %XX and '+' (form/query encoding); invalid escapes stay literal
fun _urlDecode(s)
var out = '';
var i = 0;
while i < s.length() do
var c = s.substr(i, 1);
if c = '+' then
out &= ' ';
i += 1;
elsif c = '%' and i + 2 < s.length() then
var hi = _hexVal(s.substr(i + 1, 1));
var lo = _hexVal(s.substr(i + 2, 1));
if hi >= 0 and lo >= 0 then
out &= (hi * 16 + lo).toChar();
i += 3;
else
out &= c;
i += 1;
end if;
else
out &= c;
i += 1;
end if;
end do;
result = out;
end fun;

fun _querySet(q)
result = new [];
for pair in split(/&/, q) do
if pair <> '' then
var i = pair.subpos('=');
if i < 0 then
result[pair] = '';
result[_urlDecode(pair)] = '';
else
result[pair.substr(0, i)] = pair.substr(i + 1);
result[_urlDecode(pair.substr(0, i))] = _urlDecode(pair.substr(i + 1));
end if;
end if;
end do;
end fun;

// Cookie: a=1; b=2 (values may be quoted)
fun _cookieSet(h)
result = new [];
if h = nil or h = '' then return; end if;
for part in split(/;/, h) do
var p = _trim(part);
if p <> '' then
var i = p.subpos('=');
if i > 0 then
var k = _trim(p.substr(0, i));
var v = _trim(p.substr(i + 1));
if v.length() >= 2 and v.substr(0, 1) = '"' and v.substr(v.length() - 1, 1) = '"' then
v = v.substr(1, v.length() - 2);
end if;
result[k] = v;
end if;
end if;
end do;
end fun;

fun _hasCtl(s)
result = s.subpos(13.toChar()) >= 0 or s.subpos(10.toChar()) >= 0;
end fun;

fun _checkHeader(k, v)
var ks = '' & k;
var vs = '' & v;
if ks = '' or _hasCtl(ks) or ks.subpos(':') >= 0 then
raise 'http-server: invalid header name or value';
end if;
if _hasCtl(vs) then
raise 'http-server: invalid header name or value';
end if;
end fun;

// names: RFC token-ish; values: no control/separator characters
fun _cookieOk(s, isName)
result = false;
if _hasCtl(s) then return; end if;
if isName and s = '' then return; end if;
if isName then
if s.subpos('=') >= 0 or s.subpos(';') >= 0 or s.subpos(',') >= 0 or
s.subpos(' ') >= 0 or s.subpos(9.toChar()) >= 0 then return; end if;
else
if s.subpos(';') >= 0 or s.subpos(',') >= 0 or s.subpos(' ') >= 0 or
s.subpos(9.toChar()) >= 0 or s.subpos('"') >= 0 then return; end if;
end if;
result = true;
end fun;

fun _checkAttr(v)
var s = '' & v;
if _hasCtl(s) or s.subpos(';') >= 0 then
raise 'http-server: invalid cookie attribute';
end if;
end fun;

fun _cookieLine(name, value, opts, clear)
var n = '' & name;
var v = '' & value;
if not _cookieOk(n, true) or not _cookieOk(v, false) then
raise 'http-server: invalid cookie name or value';
end if;
var o = opts;
if o = nil then o = new []; end if;
var line = n & '=' & v;
var path = o['path'];
if path <> nil and path <> '' then
_checkAttr(path);
line &= '; Path=' & path;
end if;
var domain = o['domain'];
if domain <> nil and domain <> '' then
_checkAttr(domain);
line &= '; Domain=' & domain;
end if;
if clear then
line &= '; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT';
else
var ma = o['maxAge'];
if ma <> nil then
_checkAttr(ma);
line &= '; Max-Age=' & ma;
end if;
var ex = o['expires'];
if ex <> nil and ex <> '' then
_checkAttr(ex);
line &= '; Expires=' & ex;
end if;
if o['httpOnly'] then line &= '; HttpOnly'; end if;
if o['secure'] then line &= '; Secure'; end if;
var ss = o['sameSite'];
if ss <> nil and ss <> '' then
var sl = ss.lower();
if sl = 'lax' then
line &= '; SameSite=Lax';
elsif sl = 'strict' then
line &= '; SameSite=Strict';
elsif sl = 'none' then
line &= '; SameSite=None';
else
raise 'http-server: invalid SameSite value';
end if;
end if;
end if;
result = line;
end fun;

fun _joinList(a, b)
var all = new [];
if a <> nil then
Expand Down Expand Up @@ -195,6 +341,8 @@ class HttpReq()
var params = nil; // :name matches (filled by the router)
var queryS = nil; // set: query parameters
var headS = nil; // set: lowercased header names
var cookies = nil; // set: request cookies
var formS = nil; // set: urlencoded form, parsed on demand

fun header(k)
result = headS[k.lower()];
Expand All @@ -204,6 +352,21 @@ class HttpReq()
result = queryS[k];
end fun;

fun cookie(k)
result = cookies[k];
end fun;

fun form()
if formS = nil then
formS = new [];
var ct = header('content-type') or '';
if ct.lower().subpos('application/x-www-form-urlencoded') = 0 then
formS = _querySet(body);
end if;
end if;
result = formS;
end fun;

fun param(k)
result = params[k];
end fun;
Expand All @@ -226,10 +389,33 @@ class HttpRes()
end fun;

fun header(k, v)
_checkHeader(k, v);
hdrs[k] = v;
result = this;
end fun;

// Append another line for the same header name (Set-Cookie, Vary, ...).
// Values are validated, so the embedded CRLF cannot come from input.
fun addHeader(k, v)
_checkHeader(k, v);
if hdrs[k] = nil then
hdrs[k] = v;
else
hdrs[k] = hdrs[k] & 13.toChar() & 10.toChar() & k & ': ' & v;
end if;
result = this;
end fun;

fun cookie(name, value, opts)
addHeader('Set-Cookie', _cookieLine(name, value, opts, false));
result = this;
end fun;

fun clearCookie(name, opts)
addHeader('Set-Cookie', _cookieLine(name, '', opts, true));
result = this;
end fun;

fun send(s)
if sent then
raise 'http-server: response already sent';
Expand Down Expand Up @@ -258,8 +444,8 @@ class HttpRes()
end fun;

fun redirect(url, c)
code = c or 302;
hdrs['Location'] = url;
code = c or 302;
header('Location', url);
result = this.send('');
end fun;
end class;
Expand Down Expand Up @@ -453,6 +639,7 @@ class HttpServer(addr)
r.headS = new [];
r.queryS = new [];
r.params = new [];
r.cookies = new [];

var lines = split(/\r?\n/, header);
if lines.@count() > 0 then
Expand All @@ -470,6 +657,7 @@ class HttpServer(addr)
end if;
end if;
end do;
r.cookies = _cookieSet(r.headS['cookie']);

var qi = req.subpos('?');
if qi >= 0 then
Expand Down
11 changes: 10 additions & 1 deletion fun/test/expected/lib-http-test.fun.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ static miss 404: True
traversal 404: True
boom 500: True
boom message: True
query decoded: True
form decoded: True
cookies parsed: True
cookie set sid: True
cookie set theme: True
cookie cleared: True
multi header: True
bad header 500: True
bad header message: True
addr ip:port: True
addr :port: True
ok
ok
90 changes: 89 additions & 1 deletion fun/test/lib-http-test.fun
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,39 @@ fun echoH(req, res)
res.text('echo:' & req.body);
end fun;

fun queryH(req, res)
res.text('q=' & req.query('q') & ';z=' & req.query('z'));
end fun;

fun formH(req, res)
var f = req.form();
res.text('a=' & f['a'] & ';b=' & f['b']);
end fun;

fun cookiesH(req, res)
res.text(req.cookie('a') & ':' & req.cookie('b'));
end fun;

fun setCookiesH(req, res)
res.cookie('sid', 'abc', [path: '/', httpOnly: true, sameSite: 'Lax']);
res.cookie('theme', 'dark', [path: '/']);
res.text('cookies');
end fun;

fun clearCookieH(req, res)
res.clearCookie('sid', [path: '/']);
res.text('cleared');
end fun;

fun multiH(req, res)
res.addHeader('X-Multi', 'one').addHeader('X-Multi', 'two').text('multi');
end fun;

fun badHeaderH(req, res)
res.header('X-Bad', 'a' & 13.toChar() & 10.toChar() & 'X-Injected: b');
res.text('bad');
end fun;

fun boom(req, res)
raise 'kaboom';
end fun;
Expand All @@ -74,6 +107,13 @@ s.Use(mwA);
s.get('/', hello);
s.get('/users/:id', userH);
s.post('/echo', echoH);
s.get('/query', queryH);
s.post('/form', formH);
s.get('/cookies', cookiesH);
s.get('/setcookies', setCookiesH);
s.get('/clearcookie', clearCookieH);
s.get('/multi', multiH);
s.get('/badheader', badHeaderH);
s.get('/boom', boom);
var api = s.group('/api');
api.Use(authMw);
Expand Down Expand Up @@ -197,7 +237,55 @@ var r13 = RecvSome(c13, 4096);
CloseSocket(c13);

//--------------------------------------------------------------
// 9. address strings: 'ip:port' and ':port' (all interfaces)
// 9. URL/form decoding, cookies and multi-value headers
//--------------------------------------------------------------
var c14 = getReq(p, '/query?q=hello%20world&z=a+b');
pump(s, 5);
var r14 = RecvSome(c14, 4096);
?. 'query decoded: ' & hasRe(r14, %\r\n\r\nq=hello\x20world;z=a\x20b$%);
CloseSocket(c14);

var fbody = 'a=1&b=hello%20world';
var c15 = rq(p, 'POST /form HTTP/1.1\r\nHost: t\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: ' & fbody.length() & '\r\n\r\n' & fbody);
pump(s, 5);
var r15 = RecvSome(c15, 4096);
?. 'form decoded: ' & hasRe(r15, %\r\n\r\na=1;b=hello\x20world$%);
CloseSocket(c15);

var c16 = rq(p, 'GET /cookies HTTP/1.1\r\nHost: t\r\nCookie: a=1; b=two\r\n\r\n');
pump(s, 5);
var r16 = RecvSome(c16, 4096);
?. 'cookies parsed: ' & hasRe(r16, %\r\n\r\n1:two$%);
CloseSocket(c16);

var c17 = getReq(p, '/setcookies');
pump(s, 5);
var r17 = RecvSome(c17, 4096);
?. 'cookie set sid: ' & hasRe(r17, %Set-Cookie:\x20sid=abc;\x20Path=/;\x20HttpOnly;\x20SameSite=Lax%);
?. 'cookie set theme: ' & hasRe(r17, %Set-Cookie:\x20theme=dark;\x20Path=/%);
CloseSocket(c17);

var c18 = getReq(p, '/clearcookie');
pump(s, 5);
var r18 = RecvSome(c18, 4096);
?. 'cookie cleared: ' & hasRe(r18, %Set-Cookie:\x20sid=;\x20Path=/;\x20Max-Age=0%);
CloseSocket(c18);

var c19 = getReq(p, '/multi');
pump(s, 5);
var r19 = RecvSome(c19, 4096);
?. 'multi header: ' & hasRe(r19, %X-Multi:\x20one\r\nX-Multi:\x20two%);
CloseSocket(c19);

var c20 = getReq(p, '/badheader');
pump(s, 5);
var r20 = RecvSome(c20, 4096);
?. 'bad header 500: ' & hasRe(r20, %^HTTP/1\.1\x20500%);
?. 'bad header message: ' & hasRe(r20, %invalid\x20header%);
CloseSocket(c20);

//--------------------------------------------------------------
// 10. address strings: 'ip:port' and ':port' (all interfaces)
//--------------------------------------------------------------
var s2 = HttpServer('127.0.0.1:0');
?. 'addr ip:port: ' & (s2.listen() > 0);
Expand Down
Loading