using Invercargill; using Invercargill.DataStructures; using Inversion; using Astralis; // Regression tests for the security-audit fixes SA-2, SA-2b, SA-3 and SA-13 // (see Spry/docs/security-audit.md). Mirrors Statum's harness: PASS/FAIL // lines and a non-zero exit on failure. The SA-3 checks run a real Server // (MHD daemon + SocketMonitor) on a background thread and speak plain HTTP // over a socket. namespace Astralis.Tests { int failures = 0; int passes = 0; void check(bool condition, string label) { if (condition) { passes++; print("PASS %s\n", label); } else { failures++; print("FAIL %s\n", label); } } string root_dir; string sibling_dir; // ------------------------------------------------------------------ // SA-2b: the "**" remainder must come from the REQUEST's segments // ------------------------------------------------------------------ void test_wildcard_remainder_from_request() throws Error { var route = new EndpointRoute("/assets/**"); var context = new RouteContext(route, request_segments("/assets/css/app.css")); check(param(context, "**") == "css/app.css", "SA-2b: ** remainder is built from request segments (css/app.css)"); var context2 = new RouteContext(route, request_segments("/assets/a/b/c/d.js")); check(param(context2, "**") == "a/b/c/d.js", "SA-2b: deep ** remainder (a/b/c/d.js)"); var context3 = new RouteContext(route, request_segments("/assets")); check(param(context3, "**") == "", "SA-2b: prefix-only request yields empty remainder"); var user_route = new EndpointRoute("/user/{id}/files/**"); var context4 = new RouteContext(user_route, request_segments("/user/42/files/report/q3.csv")); check(param(context4, "id") == "42", "SA-2b: named parameter in ** prefix resolves from request"); check(param(context4, "**") == "report/q3.csv", "SA-2b: ** after named prefix captures the tail"); var plain = new RouteContext(new EndpointRoute("/user/{id}"), request_segments("/user/7")); check(param(plain, "id") == "7", "SA-2b: plain named route resolves from request"); } string? param(RouteContext context, string key) { string? value = null; context.mapped_parameters.try_get(key, out value); return value; } // Request segments as HttpRequest would produce them: split, URL-decode // per segment, drop empties. Enumerable request_segments(string path) { return Wrap.array(path.split("?")[0].split("/")) .select(p => Uri.unescape_string(p) ?? "") .where(p => p.length != 0); } HttpRequest get_request(string path) { var headers = new Catalogue(); return new HttpRequest(path, "GET", "HTTP/1.1", headers, new TestInput(), new Catalogue(), new Catalogue(), "127.0.0.1"); } /// Small public wrapper so tests (outside the library's compilation /// unit) can build an HttpContext around a crafted HttpRequest. HttpContext context_for(string path) { return new HttpContext.from_request(get_request(path)); } // ------------------------------------------------------------------ // SA-2: traversal rejection, symlink escape, containment boundary // ------------------------------------------------------------------ async HttpResult serve(string request_path) throws Error { var resource = new FilesystemResource("/assets/**", root_dir); var route_context = new RouteContext(new EndpointRoute("/assets/**"), request_segments(request_path)); return yield resource.handle_request(context_for(request_path), route_context); } async void run_traversal_tests() throws Error { var r1 = yield serve("/assets/../../../etc/passwd"); check(r1.status == StatusCode.FORBIDDEN, "SA-2: /assets/../../../etc/passwd is 403"); var r2 = yield serve("/assets/%2e%2e%2f%2e%2e%2fetc%2fpasswd"); check(r2.status == StatusCode.FORBIDDEN, "SA-2: encoded %2e%2e%2f traversal is 403"); var r3 = yield serve("/assets/link/passwd"); check(r3.status == StatusCode.FORBIDDEN, "SA-2: symlink escape (root/link -> /etc) is 403"); var r4 = yield serve("/assets/../www2/secret.txt"); check(r4.status == StatusCode.FORBIDDEN, "SA-2: sibling directory via .. is 403"); var r6 = yield serve("/assets/css/app.css"); check(r6.status == StatusCode.OK, "SA-2: legitimate deep file serves 200"); var r7 = yield serve("/assets/a..b.txt"); check(r7.status == StatusCode.OK, "SA-2: filename with embedded dots (a..b.txt) serves 200"); var r8 = yield serve("/assets/nope.txt"); check(r8.status == StatusCode.NOT_FOUND, "SA-2: missing file is 404"); var r9 = yield serve("/assets/sub"); check(r9.status == StatusCode.OK, "SA-2: directory with index.html serves the index"); var r10 = yield serve("/assets/inner-link/app.css"); check(r10.status == StatusCode.OK, "SA-2: internal symlink (stays under root) serves 200"); // Containment boundary: a file in a sibling directory whose name // shares the root's prefix must never be reachable. The fixture's // root is "/www" and the sibling "/www2". var r11 = yield serve("/assets/../../www2/secret.txt"); check(r11.status == StatusCode.FORBIDDEN, "SA-2: prefix-sharing sibling (/www vs /www2) is 403"); var r12 = yield serve("/assets"); check(r12.status == StatusCode.OK, "SA-2: root directory index serves 200"); } // ------------------------------------------------------------------ // SA-13: security response headers // ------------------------------------------------------------------ SecurityHeadersComponent headers_component(WebConfig config) { return new SecurityHeadersComponent.with_config(config); } string? header_of(HttpResult result, string name) { string? value = null; result.headers.try_get(name, out value); return value; } async void run_header_tests() throws Error { var component = headers_component(new WebConfig.empty()); var result = new HttpStringResult("hello"); component.apply_headers(result); check(header_of(result, "X-Content-Type-Options") == "nosniff", "SA-13: nosniff header present"); check(header_of(result, "X-Frame-Options") == "SAMEORIGIN", "SA-13: X-Frame-Options SAMEORIGIN"); check(header_of(result, "Referrer-Policy") == "strict-origin-when-cross-origin", "SA-13: Referrer-Policy set"); check((header_of(result, "Content-Security-Policy") ?? "").contains("default-src 'self'"), "SA-13: CSP template applied"); check(header_of(result, "Strict-Transport-Security") == null, "SA-13: HSTS off by default"); // Application-set header must win over the default. var result2 = new HttpStringResult("hello"); result2.set_header("X-Frame-Options", "DENY"); component.apply_headers(result2); check(header_of(result2, "X-Frame-Options") == "DENY", "SA-13: endpoint-set header is not clobbered"); // Config overrides: HSTS opt-in, CSP extension, header disable. var config = WebConfigLoader.load_and_merge_files({ Path.build_filename(root_dir, "headers-config.json") }); var configured = headers_component(config); var result3 = new HttpStringResult("hello"); configured.apply_headers(result3); check(header_of(result3, "Strict-Transport-Security") == "max-age=31536000", "SA-13: HSTS enabled via config"); check((header_of(result3, "Content-Security-Policy") ?? "").contains("frame-ancestors 'self'"), "SA-13: CSP extended via config"); check(header_of(result3, "X-Frame-Options") == null, "SA-13: header disabled via empty config value"); } // ------------------------------------------------------------------ // SA-3: request body limits, end to end through the real server // ------------------------------------------------------------------ // A POST endpoint that echoes the body size; the server is configured // with max_request_body = 64. class EchoEndpoint : Object, Endpoint { public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error { var body = yield http_context.request.request_body.read_all(); return new HttpStringResult(@"$(body.to_byte_buffer().length) bytes"); } } Server? test_server; int server_port; const uint64 TEST_BODY_LIMIT = 64; int find_free_port() { // Bind port 0, read the assigned ephemeral port, close. Racy in // theory, fine in a test. try { var socket = new Socket(SocketFamily.IPV4, SocketType.STREAM, SocketProtocol.DEFAULT); socket.bind(new InetSocketAddress(new InetAddress.from_string("127.0.0.1"), 0), false); var addr = (InetSocketAddress) socket.get_local_address(); uint16 port = addr.get_port(); socket.close(); return port; } catch (Error e) { return 18099; } } void start_server() throws Error { var container = new Container(); var config = new WebConfig.empty(); container.register_singleton(() => config); var limits = new ServerLimits() { max_request_body = TEST_BODY_LIMIT }; container.register_singleton(() => limits); var pipeline = new Pipeline(container); pipeline.container.register_scoped().as() .with_metadata(new EndpointRoute("/echo", Method.POST)); // Router so the route actually resolves. pipeline.container.register_scoped().as(); test_server = new Server(server_port, pipeline, limits); Thread.create(() => { test_server.run(); }, false); } string? http_post(string path, size_t content_length, string? body) { try { var client = new SocketClient(); var connection = client.connect(new InetSocketAddress( new InetAddress.from_string("127.0.0.1"), (uint16) server_port)); var output = connection.get_output_stream(); string request = "POST %s HTTP/1.1\r\nHost: localhost\r\nContent-Length: %s\r\nConnection: close\r\n\r\n".printf(path, content_length.to_string()); output.write_all(request.data, null); if (body != null) { output.write_all(body.data, null); } output.flush(); var input = new DataInputStream(connection.get_input_stream()); size_t read; // Read until the full response (headers + body) has arrived; // do not rely on the server closing the connection. var response = new StringBuilder(); uint8 buffer[4096]; while (!response.str.contains("\r\n\r\n")) { read = input.read(buffer); if (read == 0) { break; } response.append_len((string) buffer, (ssize_t) read); } connection.close(); return response.str; } catch (Error e) { return null; } } string? http_post_chunked(string path, size_t total_size) { try { var client = new SocketClient(); var connection = client.connect(new InetSocketAddress( new InetAddress.from_string("127.0.0.1"), (uint16) server_port)); var output = connection.get_output_stream(); string request = "POST %s HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n".printf(path); output.write_all(request.data, null); // Send in 16-byte chunks until past the cap. size_t sent = 0; while (sent < total_size) { size_t chunk = size_t.min(16, total_size - sent); string header = @"%x\r\n".printf((uint) chunk); output.write_all(header.data, null); output.write_all(string.nfill((int) chunk, 'x').data, null); output.write_all("\r\n".data, null); sent += chunk; } output.write_all("0\r\n\r\n".data, null); output.flush(); var input = new DataInputStream(connection.get_input_stream()); var response = new StringBuilder(); uint8 buffer[4096]; while (!response.str.contains("\r\n\r\n")) { size_t read = input.read(buffer); if (read == 0) { break; } response.append_len((string) buffer, (ssize_t) read); } connection.close(); return response.str; } catch (Error e) { return null; } } string? status_of(string? response) { if (response == null || !response.has_prefix("HTTP/")) { return null; } return response.split(" ")[1]; } void test_body_limit_end_to_end() throws Error { // Wait for the server to accept connections. bool up = false; for (int i = 0; i < 100; i++) { try { var client = new SocketClient(); client.connect(new InetSocketAddress(new InetAddress.from_string("127.0.0.1"), (uint16) server_port)).close(); up = true; break; } catch (Error e) { Thread.usleep(50000); } } check(up, "SA-3: test server came up"); // Oversized declared Content-Length: rejected with 413 up front, // without the body ever being sent or buffered. var over = http_post("/echo", 1024, null); check(status_of(over) == "413", "SA-3: oversized Content-Length answered 413 (got %s)".printf(status_of(over) ?? "none")); // Within the limit: served normally. // Within the limit: served normally (4 x 16 = 64 bytes exactly). var small_body = new StringBuilder(); for (int i = 0; i < 4; i++) { small_body.append("0123456789abcdef"); } var ok = http_post("/echo", small_body.len, small_body.str); check(status_of(ok) == "200", "SA-3: body exactly at the limit answered 200 (got %s)".printf(status_of(ok) ?? "none")); // Lying/chunked-style client: no Content-Length, sends a chunked // body larger than the cap. The streaming guard in ServerInput must // cut it off with 413. var lying = http_post_chunked("/echo", 256); check(status_of(lying) == "413", "SA-3: chunked stream past the cap answered 413 (got %s)".printf(status_of(lying) ?? "none")); if (test_server != null) { test_server.stop(); } } void test_limits_config() { var defaults = ServerLimits.from_config(new WebConfig.empty()); check(defaults.max_request_body == 10 * 1024 * 1024, "SA-3: default max_request_body is 10 MiB"); check(defaults.connection_limit == 256, "SA-3: default connection limit 256"); check(defaults.per_ip_connection_limit == 32, "SA-3: default per-IP connection limit 32"); check(defaults.connection_memory_limit == 131072, "SA-3: default connection memory limit 128 KiB"); var config = WebConfigLoader.load_and_merge_files({ Path.build_filename(root_dir, "limits-config.json") }); var limits = ServerLimits.from_config(config); check(limits.connection_limit == 64, "SA-3: connection_limit from config"); check(limits.per_ip_connection_limit == 8, "SA-3: per_ip_connection_limit from config"); check(limits.connection_memory_limit == 65536, "SA-3: connection_memory_limit from config"); check(limits.max_request_body == 2048, "SA-3: max_request_body from config"); } class TestInput : Object, AsyncInput { public BinaryData? peek() { return null; } public async BinaryData? read() throws Error { return null; } public async BinaryData read_all() { return new ByteBuffer(0); } } void main() { string base_dir = DirUtils.make_tmp("astralis-tests-XXXXXX"); root_dir = Path.build_filename(base_dir, "www"); sibling_dir = Path.build_filename(base_dir, "www2"); DirUtils.create_with_parents(Path.build_filename(root_dir, "css"), 0755); DirUtils.create_with_parents(Path.build_filename(root_dir, "sub"), 0755); DirUtils.create_with_parents(sibling_dir, 0755); FileUtils.set_contents(Path.build_filename(root_dir, "css", "app.css"), "body{}"); FileUtils.set_contents(Path.build_filename(root_dir, "a..b.txt"), "dots"); FileUtils.set_contents(Path.build_filename(root_dir, "sub", "index.html"), ""); FileUtils.set_contents(Path.build_filename(root_dir, "index.html"), ""); FileUtils.set_contents(Path.build_filename(sibling_dir, "secret.txt"), "sibling secret"); FileUtils.set_contents(Path.build_filename(root_dir, "headers-config.json"), "{\"securityHeaders\": {\"hsts\": \"max-age=31536000\", \"xFrameOptions\": \"\", \"contentSecurityPolicy\": \"default-src 'self'; frame-ancestors 'self'\"}}"); FileUtils.set_contents(Path.build_filename(root_dir, "limits-config.json"), "{\"server\": {\"connection_limit\": 64, \"per_ip_connection_limit\": 8, \"connection_memory_limit\": 65536, \"max_request_body\": 2048}}"); if (!FileUtils.test(Path.build_filename(root_dir, "link"), FileTest.EXISTS)) { File.new_for_path(Path.build_filename(root_dir, "link")).make_symbolic_link("/etc"); } if (!FileUtils.test(Path.build_filename(root_dir, "inner-link"), FileTest.EXISTS)) { File.new_for_path(Path.build_filename(root_dir, "inner-link")).make_symbolic_link("css"); } try { test_wildcard_remainder_from_request(); } catch (Error e) { failures++; print("FAIL wildcard remainder threw: %s\n", e.message); } var loop = new MainLoop(); run_traversal_tests.begin((obj, res) => { try { run_traversal_tests.end(res); } catch (Error e) { failures++; print("FAIL traversal tests threw: %s\n", e.message); } loop.quit(); }); loop.run(); var loop2 = new MainLoop(); run_header_tests.begin((obj, res) => { try { run_header_tests.end(res); } catch (Error e) { failures++; print("FAIL header tests threw: %s\n", e.message); } loop2.quit(); }); loop2.run(); test_limits_config(); // End-to-end server tests on a found-free port. server_port = find_free_port(); try { start_server(); test_body_limit_end_to_end(); } catch (Error e) { failures++; print("FAIL end-to-end server test threw: %s\n", e.message); } print("\n%d passed, %d failed\n", passes, failures); if (failures > 0) { Process.exit(1); } } }