| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427 |
- 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<string> request_segments(string path) {
- return Wrap.array<string>(path.split("?")[0].split("/"))
- .select<string>(p => Uri.unescape_string(p) ?? "")
- .where(p => p.length != 0);
- }
- HttpRequest get_request(string path) {
- var headers = new Catalogue<string, string>();
- return new HttpRequest(path, "GET", "HTTP/1.1", headers, new TestInput(),
- new Catalogue<string, string>(), new Catalogue<string, string>(), "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 "<tmp>/www" and the sibling "<tmp>/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<WebConfig>(() => config);
- var limits = new ServerLimits() { max_request_body = TEST_BODY_LIMIT };
- container.register_singleton<ServerLimits>(() => limits);
- var pipeline = new Pipeline(container);
- pipeline.container.register_scoped<EchoEndpoint>().as<Endpoint>()
- .with_metadata<EndpointRoute>(new EndpointRoute("/echo", Method.POST));
- // Router so the route actually resolves.
- pipeline.container.register_scoped<EndpointRouter>().as<PipelineComponent>();
- test_server = new Server(server_port, pipeline, limits);
- Thread.create<void>(() => {
- 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"), "<html></html>");
- FileUtils.set_contents(Path.build_filename(root_dir, "index.html"), "<html></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);
- }
- }
- }
|