Harden HTTP path headers sessions and archives

This commit is contained in:
Udo 2026-05-21 09:56:25 +00:00
parent d37517041d
commit 0d8b74930c
10 changed files with 260 additions and 22 deletions

View File

@ -47,6 +47,8 @@ TRANSPORT_MAX_WEBSOCKET_OUTPUT_BYTES=4194304
TRANSPORT_MAX_RESPONSE_BYTES=8388608
TRANSPORT_HTTP_REQUEST_TIMEOUT_SECONDS=15
TRANSPORT_CONNECTION_IDLE_TIMEOUT_SECONDS=120
# Empty means COMPILER_SYS_PATH for the built-in direct HTTP listener.
HTTP_DOCUMENT_ROOT=
# CUSTOM SERVER LIMITS
CUSTOM_SERVER_MAX_SERVERS=16

View File

@ -40,7 +40,7 @@ RENDER(Request& context)
check("uri_encode() / uri_decode()", decoded == uri_input, encoded + " => " + decoded);
check("parse_query()", query["alpha"] == "1" && query["beta"] == "two words", query_dump);
check("set_cookie()", set_cookie_dump.find("site-tests-cookie") != String::npos && set_cookie_dump.find("cookie-value") != String::npos, set_cookie_dump);
check("set_cookie()", set_cookie_dump.find("site-tests-cookie") != String::npos && set_cookie_dump.find("cookie-value") != String::npos && set_cookie_dump.find("HttpOnly") != String::npos && set_cookie_dump.find("SameSite=Lax") != String::npos, set_cookie_dump);
check("response header mutation", context.header["X-Site-Tests"] == "http-suite", header_dump);
check("session_start()", session_id != "", "session_id=" + session_id);
check("session_id_create()", generated_session.length() >= 16, generated_session);

View File

@ -0,0 +1,6 @@
RENDER(Request& context)
{
context.header["X-UCE-Security-Test"] = "safe\r\nX-UCE-Injected: nope";
redirect("/tests/index.uce\r\nX-UCE-Redirect-Injected: nope", 299);
print("security header sanitizer test");
}

View File

@ -36,6 +36,7 @@
#include <cstring>
#include <stdexcept>
#include <cctype>
#include <errno.h> // E*
#include <fcntl.h>
@ -123,6 +124,86 @@ make_http_text_response(String status_line, String body, String extra_headers =
);
}
static bool
header_token_safe(String name)
{
if(name == "")
return(false);
for(char c : name)
{
if(!(std::isalnum((unsigned char)c) || c == '-' || c == '_'))
return(false);
}
return(true);
}
static String
header_value_sanitize(String value)
{
for(char& c : value)
{
if(c == '\r' || c == '\n')
c = ' ';
}
return(value);
}
static String
render_header_map(StringMap headers)
{
String result;
for(auto& item : headers)
{
if(!header_token_safe(item.first))
continue;
result += item.first + ": " + header_value_sanitize(item.second) + "\r\n";
}
return(result);
}
static String
render_set_cookie_headers(StringList headers)
{
String result;
for(String header : headers)
{
if(header.find('\r') != String::npos || header.find('\n') != String::npos)
continue;
if(!str_starts_with(to_lower(header), "set-cookie: "))
continue;
result += header + "\r\n";
}
return(result);
}
static String
safe_status_line(String status_line)
{
if(status_line.find('\r') != String::npos || status_line.find('\n') != String::npos)
return("Status: 500 Internal Server Error");
return(status_line);
}
static String
http_script_root()
{
if(context && context->server)
return(first(
context->server->config["HTTP_DOCUMENT_ROOT"],
context->server->config["COMPILER_SYS_PATH"],
cwd_get()
));
return(cwd_get());
}
static String
strip_leading_slashes(String s)
{
while(s.length() > 0 && s[0] == '/')
s.erase(0, 1);
return(s);
}
static bool
is_valid_close_code(u16 status_code)
{
@ -784,15 +865,20 @@ FastCGIServer::process_http_request(FastCGIRequest& request, String& data)
if(!parse_http_message(request, data))
return;
if(request.params["HTTP_SCRIPT_FILENAME"] != "")
request.params["SCRIPT_FILENAME"] = request.params["HTTP_SCRIPT_FILENAME"];
else if(request.params["SCRIPT_FILENAME"] == "" && request.params["DOCUMENT_URI"] != "")
if(request.params["SCRIPT_FILENAME"] == "" && request.params["DOCUMENT_URI"] != "")
{
String document_root = first(request.params["DOCUMENT_ROOT"], cwd_get());
if(document_root.length() > 1 && document_root[document_root.length()-1] == '/')
document_root.resize(document_root.length()-1);
request.params["DOCUMENT_ROOT"] = document_root;
request.params["SCRIPT_FILENAME"] = document_root + request.params["DOCUMENT_URI"];
String document_root = http_script_root();
String document_uri = strip_leading_slashes(request.params["DOCUMENT_URI"]);
String candidate = path_join(document_root, document_uri);
String real_root = path_real(document_root);
String real_candidate = path_real(candidate);
if(real_root == "" || real_candidate == "" || !path_is_within(real_candidate, real_root))
{
reject_http_connection(*client_sockets[request.resources.client_socket], "HTTP/1.1 404 Not Found", "script not found\n");
return;
}
request.params["DOCUMENT_ROOT"] = real_root;
request.params["SCRIPT_FILENAME"] = real_candidate;
}
if(to_lower(request.params["HTTP_UPGRADE"]) == "websocket")
@ -1253,9 +1339,9 @@ void
FastCGIServer::assemble_output_buffer(FastCGIRequest& request, Connection* connection)
{
request.out =
request.response_code+"\r\n"+
var_dump(request.header, "", "\r\n") +
var_dump(request.set_cookies, "", "\r\n") +
safe_status_line(request.response_code)+"\r\n"+
render_header_map(request.header) +
render_set_cookie_headers(request.set_cookies) +
"\r\n";
for(auto obs : request.ob_stack)
@ -1268,7 +1354,7 @@ FastCGIServer::assemble_output_buffer(FastCGIRequest& request, Connection* conne
request.set_status(500, "Response Too Large");
request.header.clear();
request.header["Content-Type"] = "text/plain; charset=utf-8";
request.out = request.response_code + "\r\n" + var_dump(request.header, "", "\r\n") + "\r\nresponse exceeded configured output limit\n";
request.out = safe_status_line(request.response_code) + "\r\n" + render_header_map(request.header) + "\r\nresponse exceeded configured output limit\n";
}
request.ob_stack.clear();
request.flags.output_closed = true;

View File

@ -6,6 +6,7 @@
#include <netdb.h>
#include <execinfo.h>
#include <fcntl.h>
#include <limits.h>
#include <sys/file.h>
#include "sys.h"
#include "hash.h"
@ -182,6 +183,27 @@ String path_join(String base, String child)
return(base + "/" + child);
}
String path_real(String path)
{
char resolved[PATH_MAX];
if(realpath(path.c_str(), resolved))
return(String(resolved));
return("");
}
bool path_is_within(String path, String root)
{
String real_path = path_real(path);
String real_root = path_real(root);
if(real_path == "" || real_root == "")
return(false);
if(real_path == real_root)
return(true);
if(real_root[real_root.length() - 1] != '/')
real_root += "/";
return(str_starts_with(real_path, real_root));
}
bool mkdir(String path)
{
shell_exec(String("mkdir -p ")+" "+shell_escape(path));
@ -877,6 +899,7 @@ StringMap make_server_settings()
cfg["TRANSPORT_MAX_RESPONSE_BYTES"] = std::to_string(8 * 1024 * 1024);
cfg["TRANSPORT_HTTP_REQUEST_TIMEOUT_SECONDS"] = "15";
cfg["TRANSPORT_CONNECTION_IDLE_TIMEOUT_SECONDS"] = "120";
cfg["HTTP_DOCUMENT_ROOT"] = "";
cfg["CUSTOM_SERVER_MAX_SERVERS"] = "16";
cfg["CUSTOM_SERVER_MIN_PORT"] = "1024";
cfg["CUSTOM_SERVER_MAX_PORT"] = "65535";

View File

@ -9,6 +9,8 @@ String shell_escape(String raw);
String basename(String fn);
String dirname(String fn);
String path_join(String base, String child);
String path_real(String path);
bool path_is_within(String path, String root);
bool mkdir(String path);
bool file_exists(String path);
int file_open_locked(String file_name, int open_flags, int lock_type = LOCK_SH, int create_mode = 0644, f64 wait_timeout_seconds = 3.0, String purpose = "");

View File

@ -1,5 +1,8 @@
#include "uri.h"
#include <fcntl.h>
#include <unistd.h>
static String base64_encode(String raw)
{
static const char* chars =
@ -256,9 +259,33 @@ String encode_query(StringMap map)
return(result);
}
namespace {
String http_header_value_clean(String value)
{
for(char& c : value)
{
if(c == '\r' || c == '\n')
c = ' ';
}
return(value);
}
String cookie_attribute_value_clean(String value)
{
for(char& c : value)
{
if(c == '\r' || c == '\n' || c == ';')
c = ' ';
}
return(trim(value));
}
}
void redirect(String url, s32 code)
{
context->header["Location"] = url;
context->header["Location"] = http_header_value_clean(url);
context->set_status(code);
}
@ -499,9 +526,18 @@ void set_cookie(
bool secure, bool http_only)
{
String cookie = "Set-Cookie: ";
cookie.append(uri_encode(name) + "=" + uri_encode(value));
cookie.append(uri_encode(cookie_attribute_value_clean(name)) + "=" + uri_encode(value));
if(expires > 0)
cookie.append(String("; Expires=") + time_format_utc("RFC1123", expires));
if(path != "")
cookie.append("; Path=" + cookie_attribute_value_clean(path));
if(domain != "")
cookie.append("; Domain=" + cookie_attribute_value_clean(domain));
if(secure)
cookie.append("; Secure");
if(http_only)
cookie.append("; HttpOnly");
cookie.append("; SameSite=Lax");
context->set_cookies.push_back(cookie);
context->cookies[name] = value;
}
@ -520,7 +556,30 @@ StringMap parse_cookies(String cookie_String)
String session_id_create()
{
return(to_hex(rand())+to_hex(rand())+to_hex(rand())+to_hex(rand()));
unsigned char bytes[32];
int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC);
if(fd == -1)
throw std::runtime_error("session_id_create(): could not open /dev/urandom");
size_t offset = 0;
while(offset < sizeof(bytes))
{
ssize_t count = read(fd, bytes + offset, sizeof(bytes) - offset);
if(count <= 0)
{
close(fd);
throw std::runtime_error("session_id_create(): could not read random bytes");
}
offset += (size_t)count;
}
close(fd);
String result;
static const char* hex = "0123456789abcdef";
for(unsigned char b : bytes)
{
result.push_back(hex[b >> 4]);
result.push_back(hex[b & 0x0f]);
}
return(result);
}
bool is_valid_session_id(String session_id)
@ -602,7 +661,7 @@ String session_start(String session_name)
context->session_name = "";
String session_id = context->cookies[session_name];
if(!is_valid_session_id(session_id))
if(!is_valid_session_id(session_id) || !file_exists(session_file_path(session_id)))
session_id = "";
if(session_id.length() == 0)

View File

@ -122,6 +122,21 @@ String zip_read(String zip_file_name, String entry_name)
if(!mz_zip_reader_init_file(&archive, zip_file_name.c_str(), 0))
throw std::runtime_error(zip_error("zip_read", "could not open " + zip_file_name));
int file_index = mz_zip_reader_locate_file(&archive, entry_name.c_str(), NULL, 0);
if(file_index < 0)
{
mz_zip_reader_end(&archive);
throw std::runtime_error(zip_error("zip_read", "entry not found or not readable: " + entry_name));
}
mz_zip_archive_file_stat stat;
std::memset(&stat, 0, sizeof(stat));
if(!mz_zip_reader_file_stat(&archive, (mz_uint)file_index, &stat))
{
mz_zip_reader_end(&archive);
throw std::runtime_error(zip_error("zip_read", "entry not found or not readable: " + entry_name));
}
archive_check_size("zip_read", "uncompressed entry", stat.m_uncomp_size, "ARCHIVE_MAX_OUTPUT_BYTES", 64 * 1024 * 1024);
size_t size = 0;
void* data = mz_zip_reader_extract_file_to_heap(&archive, entry_name.c_str(), &size, 0);
if(!data)
@ -129,7 +144,6 @@ String zip_read(String zip_file_name, String entry_name)
mz_zip_reader_end(&archive);
throw std::runtime_error(zip_error("zip_read", "entry not found or not readable: " + entry_name));
}
archive_check_size("zip_read", "uncompressed entry", size, "ARCHIVE_MAX_OUTPUT_BYTES", 64 * 1024 * 1024);
String result((char*)data, size);
mz_free(data);
mz_zip_reader_end(&archive);
@ -305,9 +319,12 @@ String gz_compress(String src)
String gz_uncompress(String compressed)
{
archive_check_size("gz_uncompress", "input", compressed.size(), "ARCHIVE_MAX_INPUT_BYTES", 64 * 1024 * 1024);
size_t deflate_offset = gz_deflate_offset(compressed);
size_t footer_offset = compressed.size() - 8;
size_t deflate_size = footer_offset - deflate_offset;
u32 expected_size = gz_read_u32_le(compressed, footer_offset + 4);
archive_check_size("gz_uncompress", "declared output", expected_size, "ARCHIVE_MAX_OUTPUT_BYTES", 64 * 1024 * 1024);
size_t out_len = 0;
void* out = tinfl_decompress_mem_to_heap(compressed.data() + deflate_offset, deflate_size, &out_len, 0);
if(!out)
@ -318,7 +335,6 @@ String gz_uncompress(String compressed)
mz_free(out);
u32 expected_crc = gz_read_u32_le(compressed, footer_offset);
u32 expected_size = gz_read_u32_le(compressed, footer_offset + 4);
u32 actual_crc = (u32)mz_crc32(MZ_CRC32_INIT, (const unsigned char*)result.data(), result.size());
if(actual_crc != expected_crc)
throw std::runtime_error("gz_uncompress(): crc check failed");

View File

@ -1329,10 +1329,10 @@ pid_t server_start_http(String key, String socket_fn_or_port, String call_uce_fi
if(call_uce_filename[0] != '/')
call_uce_filename = expand_path(call_uce_filename, cwd_get());
String allowed_root = first(server_state.config["CUSTOM_SERVER_UCE_ROOT"], path_join(server_state.config["COMPILER_SYS_PATH"], server_state.config["SITE_DIRECTORY"]));
if(allowed_root[allowed_root.length() - 1] != '/')
allowed_root += "/";
if(!str_starts_with(call_uce_filename, allowed_root))
String real_call_uce_filename = path_real(call_uce_filename);
if(real_call_uce_filename == "" || !path_is_within(real_call_uce_filename, allowed_root))
throw std::runtime_error("server_start_http(): call_uce_filename is outside configured custom server UCE root");
call_uce_filename = real_call_uce_filename;
String config_file = custom_server_config_file(key);
StringMap previous_config;

View File

@ -0,0 +1,44 @@
import http.client
from run_network_tests import TestFailure
def _direct_http_request(path, headers=None):
connection = http.client.HTTPConnection("127.0.0.1", 8080, timeout=5.0)
try:
connection.request("GET", path, headers=headers or {})
response = connection.getresponse()
body = response.read().decode("utf-8", errors="replace")
return response.status, dict(response.getheaders()), body
finally:
connection.close()
def register(registry):
def direct_http_rejects_dotdot(context):
status, headers, body = _direct_http_request("/../site/demo/hello.uce")
if status == 200 or "hello world" in body:
raise TestFailure("direct HTTP accepted dot-dot script traversal")
return "direct HTTP dot-dot traversal rejected with HTTP %s" % status
def direct_http_ignores_script_filename_header(context):
status, headers, body = _direct_http_request(
"/no-such-script.uce",
headers={"Script-Filename": "/Code/uce.openfu.com/uce/site/demo/hello.uce"},
)
if status == 200 or "hello world" in body:
raise TestFailure("direct HTTP trusted client Script-Filename header")
return "direct HTTP Script-Filename override rejected with HTTP %s" % status
def response_headers_are_sanitized(context):
response = context.request("/tests/security_headers.uce")
if "X-UCE-Injected" in response.headers or "X-UCE-Redirect-Injected" in response.headers:
raise TestFailure("CRLF header injection produced an extra response header")
location = response.headers.get("Location", "")
if "\r" in location or "\n" in location:
raise TestFailure("Location header contains raw CR/LF")
return "CRLF response header injection was sanitized"
registry.case("direct HTTP rejects dot-dot script traversal", direct_http_rejects_dotdot, tags=["security", "http", "internal"])
registry.case("direct HTTP ignores Script-Filename header", direct_http_ignores_script_filename_header, tags=["security", "http", "internal"])
registry.case("response headers sanitize CRLF", response_headers_are_sanitized, tags=["security", "http", "internal"])