Sfoglia il codice sorgente

Merge branch 'goal/security' of Tilo15/astralis into master

Billy Barrow 1 giorno fa
parent
commit
7473515476

+ 23 - 21
MANIFEST.usm

@@ -1,19 +1,21 @@
 {
-  "name": "astralis",
-  "version": "0.1",
-  "summary": "Library for writing HTTP backends",
-  "licences": [],
-  "flags": ["ninjaStyleProgress"],
-  "provides": {
-    "lib:libastralis-0.1.so": "as-expected",
-    "inc:astralis-0.1.h": "as-expected",
-    "vapi:astralis-0.1.vapi": "as-expected",
-    "gir:astralis-0.1.gir": "as-expected",
-    "typelib:libastralis-0.1.typelib": "as-expected",
-    "pc:astralis-0.1.pc": "as-expected"
+  "name" : "astralis",
+  "version" : "0.2.0",
+  "summary" : "Library for writing HTTP backends",
+  "licences" : [],
+  "flags" : [
+    "ninjaStyleProgress"
+  ],
+  "provides" : {
+    "lib:libastralis-0.1.so" : "as-expected",
+    "inc:astralis-0.1.h" : "as-expected",
+    "vapi:astralis-0.1.vapi" : "as-expected",
+    "gir:astralis-0.1.gir" : "as-expected",
+    "typelib:libastralis-0.1.typelib" : "as-expected",
+    "pc:astralis-0.1.pc" : "as-expected"
   },
-  "depends": {
-    "runtime": [
+  "depends" : {
+    "runtime" : [
       "lib:libglib-2.0.so.0",
       "lib:libgobject-2.0.so.0",
       "lib:libgio-2.0.so.0",
@@ -26,7 +28,7 @@
       "lib:libbrotlienc.so.1",
       "lib:libzstd.so.1"
     ],
-    "build": [
+    "build" : [
       "bin:valac",
       "bin:meson",
       "bin:ninja",
@@ -46,13 +48,13 @@
       "pc:invercargill-json.pc",
       "pc:inversion-0.1.pc"
     ],
-    "manage": [
+    "manage" : [
       "bin:bash"
     ]
   },
-  "execs": {
-    "build": "usm-scripts/build.sh",
-    "install": "usm-scripts/install.sh",
-    "postInstall": "usm-scripts/post-install.sh"
+  "execs" : {
+    "build" : "usm-scripts/build.sh",
+    "install" : "usm-scripts/install.sh",
+    "postInstall" : "usm-scripts/post-install.sh"
   }
-}
+}

+ 2 - 1
meson.build

@@ -1,5 +1,5 @@
 project('astralis', ['c', 'vala'],
-  version: '0.1',
+  version: '0.2.0',
 )
 
 # Dependencies
@@ -22,3 +22,4 @@ add_project_arguments(['--vapidir', join_paths(meson.current_source_dir(), 'vapi
 
 subdir('src')
 subdir('examples')
+subdir('tests')

+ 13 - 8
src/Components/EndpointRouter.vala

@@ -113,7 +113,6 @@ namespace Astralis {
 
         public RouteContext(EndpointRoute matched_route, Enumerable<string> requested_path_segments) throws Error {
             this.matched_route = matched_route;
-            this.requested_path = requested_path;
             this.requested_path_segments = requested_path_segments.to_immutable_buffer();
             this.requested_path = "/" + this.requested_path_segments.to_string(null, "/");
             
@@ -128,23 +127,27 @@ namespace Astralis {
                 // Extract named components from the prefix
                 for(int i = 0; i < prefix_components.length; i++) {
                     if(prefix_components[i].has_prefix("{") && prefix_components[i].has_suffix("}")) {
-                        if(i < matched_route.route_segments.length) {
+                        if(i < this.requested_path_segments.length) {
                             parameter_map.add(prefix_components[i][1:-1], this.requested_path_segments[i]);
                         }
                     }
                 }
 
-                // Capture remaining path components as "**"
+                // Capture the remaining REQUEST path components as "**". The
+                // remainder must come from the request, not the route: for a
+                // "/assets/**" route serving "/assets/css/app.css" the mapped
+                // "**" parameter is "css/app.css" (regression 9a3bb18b built
+                // it from the route's own segments, yielding the literal "**"
+                // and 404ing every deep path).
                 int remaining_start = (int)prefix_components.length;
-                
-                // Build path manually to avoid string.join issues
+
                 var builder = new StringBuilder();
                 bool first = true;
-                for(int i = remaining_start; i < matched_route.route_segments.length; i++) {
+                for(int i = remaining_start; i < this.requested_path_segments.length; i++) {
                     if (!first) {
                         builder.append("/");
                     }
-                    builder.append(matched_route.route_segments[i]);
+                    builder.append(this.requested_path_segments[i]);
                     first = false;
                 }
                 parameter_map.add("**", builder.str);
@@ -161,7 +164,9 @@ namespace Astralis {
                 if(!endpoint_components[i].has_prefix("{") || !endpoint_components[i].has_suffix("}")) {
                     continue;
                 }
-                parameter_map.add(endpoint_components[i][1:-1], this.requested_path_segments[i]);
+                if(i < this.requested_path_segments.length) {
+                    parameter_map.add(endpoint_components[i][1:-1], this.requested_path_segments[i]);
+                }
             }
 
             this.mapped_parameters = parameter_map;

+ 141 - 0
src/Components/SecurityHeadersComponent.vala

@@ -0,0 +1,141 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Inversion;
+
+namespace Astralis {
+
+    /// <summary>
+    /// Pipeline component that appends default security response headers
+    /// (SA-13). It wraps the rest of the pipeline: inner results pass
+    /// through with the defaults applied, so a header set by an endpoint or
+    /// an inner component always wins.
+    ///
+    /// Defaults (each overridable and disableable via the "securityHeaders"
+    /// WebConfig section, or code):
+    /// <list type="bullet">
+    /// <item>X-Content-Type-Options: nosniff</item>
+    /// <item>X-Frame-Options: SAMEORIGIN</item>
+    /// <item>Referrer-Policy: strict-origin-when-cross-origin</item>
+    /// <item>Content-Security-Policy: from a template apps extend</item>
+    /// <item>Strict-Transport-Security: OFF unless explicitly enabled</item>
+    /// </list>
+    /// </summary>
+    public class SecurityHeadersComponent : Object, PipelineComponent {
+
+        private WebConfig? config;
+        private bool configured;
+
+        /// Builds the component with an explicit configuration, for
+        /// applications that construct it directly instead of through the
+        /// DI container.
+        public SecurityHeadersComponent.with_config(WebConfig? config) {
+            this.config = config;
+            apply_config();
+        }
+
+        public string x_content_type_options { get; set; default = "nosniff"; }
+        public string x_frame_options { get; set; default = "SAMEORIGIN"; }
+        public string referrer_policy { get; set; default = "strict-origin-when-cross-origin"; }
+
+        /// <summary>
+        /// CSP template. Applications extend it (or replace it) rather than
+        /// starting from scratch; the default keeps the client runtime
+        /// functional: inline styles/scripts are what the markup components
+        /// emit today, and 'self' covers the app's own static assets.
+        /// </summary>
+        public string content_security_policy { get; set; default =
+            "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'"; }
+
+        /// <summary>
+        /// HSTS is opt-in: enabling it on a plain-HTTP deployment (local dev,
+        /// LAN installs) bricks clients for the max-age period. Off by
+        /// default, set securityHeaders.hsts = "max-age=31536000" to enable.
+        /// </summary>
+        public string? strict_transport_security { get; set; default = null; }
+
+        public SecurityHeadersComponent() {
+            // Deliberately empty: the DI scope is not available while the
+            // constructor runs (inject<WebConfig>() here would silently
+            // yield null). Configuration is resolved lazily on first use,
+            // when the instance is being resolved inside its scope.
+        }
+
+        /// Resolves the WebConfig the first time the component is actually
+        /// used (from the pipeline, or from apply_headers), which happens
+        /// inside the DI scope where inject<> works.
+        private void ensure_configured() {
+            if (configured) {
+                return;
+            }
+            if (config == null) {
+                config = try_inject<WebConfig>() ?? new WebConfig.empty();
+            }
+            apply_config();
+        }
+
+        private void apply_config() {
+            if (configured) {
+                return;
+            }
+            configured = true;
+            var section = config.get_section("securityHeaders");
+            if (section == null) {
+                return;
+            }
+
+            // Empty string disables a header; absent key keeps the default.
+            if (section.has_key("xContentTypeOptions")) {
+                x_content_type_options = section.get_string("xContentTypeOptions", x_content_type_options);
+            }
+            if (section.has_key("xFrameOptions")) {
+                x_frame_options = section.get_string("xFrameOptions", x_frame_options);
+            }
+            if (section.has_key("referrerPolicy")) {
+                referrer_policy = section.get_string("referrerPolicy", referrer_policy);
+            }
+            if (section.has_key("contentSecurityPolicy")) {
+                content_security_policy = section.get_string("contentSecurityPolicy", content_security_policy);
+            }
+            if (section.has_key("hsts")) {
+                // Empty string disables; has_key distinguishes "set to
+                // empty" from "absent".
+                strict_transport_security = section.get_string("hsts", "");
+            }
+        }
+
+        public async HttpResult process_request(HttpContext http_context, PipelineContext pipeline_context) throws Error {
+            ensure_configured();
+            HttpResult result = yield pipeline_context.next();
+            apply_headers(result);
+            return result;
+        }
+
+        /// <summary>Appends the configured headers without clobbering ones
+        /// the response already carries.</summary>
+        public void apply_headers(HttpResult result) {
+            ensure_configured();
+            append_default(result, "X-Content-Type-Options", x_content_type_options);
+            append_default(result, "X-Frame-Options", x_frame_options);
+            append_default(result, "Referrer-Policy", referrer_policy);
+            append_default(result, "Content-Security-Policy", content_security_policy);
+            // Opt-in only (see strict_transport_security).
+            if (strict_transport_security != null && strict_transport_security.length > 0) {
+                append_default(result, "Strict-Transport-Security", strict_transport_security);
+            }
+        }
+
+        private static void append_default(HttpResult result, string header, string configured) {
+            // Empty configuration disables the header entirely.
+            if (configured.length == 0) {
+                return;
+            }
+            // Never override a value the application chose itself; headers
+            // are a Dictionary, so presence is a direct lookup.
+            string? existing = null;
+            if (result.headers.try_get(header, out existing)) {
+                return;
+            }
+            result.set_header(header, configured);
+        }
+    }
+}

+ 7 - 0
src/Core/HttpContext.vala

@@ -171,5 +171,12 @@ namespace Astralis {
         internal HttpContext(HttpRequest request) {
             this.request = request;
         }
+
+        /// Builds a context around an already-constructed request. Public so
+        /// middleware authors and tests can run pipeline components and
+        /// endpoints against crafted requests without a live server.
+        public HttpContext.from_request(HttpRequest request) {
+            this.request = request;
+        }
     }
 }

+ 1 - 0
src/Core/HttpValues.vala

@@ -49,6 +49,7 @@ namespace Astralis {
         FORBIDDEN = 403,
         NOT_FOUND = 404,
         METHOD_NOT_ALLOWED = 405,
+        PAYLOAD_TOO_LARGE = 413,
         INTERNAL_SERVER_ERROR = 500
     }
 }

+ 71 - 0
src/Core/ServerLimits.vala

@@ -0,0 +1,71 @@
+namespace Astralis {
+
+    /// <summary>
+    /// Resource limits for the HTTP daemon and request bodies (SA-3).
+    /// Populated from WebConfig (section "server", see from_config) and used
+    /// by Server when starting the daemon and when rejecting oversized
+    /// request bodies.
+    /// </summary>
+    public class ServerLimits : GLib.Object {
+
+        /// <summary>Maximum number of simultaneously open connections.</summary>
+        public uint connection_limit { get; set; default = 256; }
+
+        /// <summary>Maximum number of connections from a single IP address.</summary>
+        public uint per_ip_connection_limit { get; set; default = 32; }
+
+        /// <summary>
+        /// Per-connection buffer ceiling libmicrohttpd applies internally
+        /// (bytes).
+        /// </summary>
+        public uint connection_memory_limit { get; set; default = 131072; }
+
+        /// <summary>
+        /// Hard cap on the accepted request body size (bytes). Requests that
+        /// declare a larger Content-Length are rejected with 413 immediately;
+        /// streaming writes past the cap are rejected defensively. 0 disables
+        /// the check (not recommended).
+        /// </summary>
+        public uint64 max_request_body { get; set; default = 10 * 1024 * 1024; }
+
+        /// <summary>
+        /// Reads limits from the "server" section of a WebConfig. Recognised
+        /// keys: connection_limit, per_ip_connection_limit,
+        /// connection_memory_limit, max_request_body. Missing keys keep the
+        /// defaults.
+        /// </summary>
+        public static ServerLimits from_config(WebConfig config) {
+            var limits = new ServerLimits();
+            var section = config.get_section("server");
+            if (section == null) {
+                return limits;
+            }
+
+            if (section.has_key("connection_limit")) {
+                int value = section.get_int("connection_limit", -1);
+                if (value > 0) {
+                    limits.connection_limit = (uint) value;
+                }
+            }
+            if (section.has_key("per_ip_connection_limit")) {
+                int value = section.get_int("per_ip_connection_limit", -1);
+                if (value > 0) {
+                    limits.per_ip_connection_limit = (uint) value;
+                }
+            }
+            if (section.has_key("connection_memory_limit")) {
+                int value = section.get_int("connection_memory_limit", -1);
+                if (value > 0) {
+                    limits.connection_memory_limit = (uint) value;
+                }
+            }
+            if (section.has_key("max_request_body")) {
+                int64 value = section.get_int64("max_request_body", -1);
+                if (value > 0) {
+                    limits.max_request_body = (uint64) value;
+                }
+            }
+            return limits;
+        }
+    }
+}

+ 6 - 1
src/Core/WebApplication.vala

@@ -16,8 +16,13 @@ namespace Astralis {
             container = new Container();
             var config = WebConfigLoader.load();
             container.register_singleton<WebConfig>(() => config);
+            // SA-3: resource limits come from the "server" config section
+            // (connection_limit, per_ip_connection_limit,
+            // connection_memory_limit, max_request_body).
+            var limits = ServerLimits.from_config(config);
+            container.register_singleton<ServerLimits>(() => limits);
             pipeline = new Pipeline(container);
-            server = new Server(this.port, pipeline);
+            server = new Server(this.port, pipeline, limits);
         }
 
         public void run() throws Error {

+ 18 - 0
src/Core/WebConfigSection.vala

@@ -52,6 +52,24 @@ namespace Astralis {
             return (int) node.get_int();
         }
 
+        /// <summary>
+        /// Gets a 64-bit integer value from the section, without truncating
+        /// values outside the range of int (e.g. byte counts).
+        /// </summary>
+        /// <param name="key">The key to look up</param>
+        /// <param name="default">The default value if key is missing or not an integer</param>
+        /// <returns>The 64-bit integer value or the default</returns>
+        public int64 get_int64(string key, int64 default = 0) {
+            if (!section_object.has_member(key)) {
+                return default;
+            }
+            var node = section_object.get_member(key);
+            if (node.get_node_type() != NodeType.VALUE) {
+                return default;
+            }
+            return node.get_int();
+        }
+
         /// <summary>
         /// Gets a boolean value from the section.
         /// </summary>

+ 181 - 29
src/Endpoints/FilesystemResource.vala

@@ -46,53 +46,73 @@ namespace Astralis {
         public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error {
             // Get the remaining path components after the route prefix
             string relative_path = get_relative_path(route_context);
-            print(@"Getting $(relative_path)\n");
-            
+
+            // Security (SA-2): reject traversal payloads outright, before the
+            // filesystem is touched. Request path segments are URL-decoded
+            // (see HttpRequest), so "%2e%2e%2f" arrives here as a literal "..".
+            if (contains_parent_reference(relative_path)) {
+                return new HttpStringResult("Forbidden", StatusCode.FORBIDDEN);
+            }
+
             // Build the full filesystem path
             string full_path = Path.build_filename(server_path, relative_path);
-            
-            // Security: Ensure the resolved path is within server_path (prevent path traversal)
-            string resolved_path = canonicalize_path(full_path);
-            string resolved_server_path = canonicalize_path(server_path);
-            
-            if (!resolved_path.has_prefix(resolved_server_path)) {
+
+            // Security (SA-2): containment must hold on symlink-free paths.
+            // File.get_path() is purely lexical, so a symlink planted inside
+            // the served tree could point outside it; resolve both the root
+            // and the target, then require the target to be the root itself
+            // or live strictly beneath it ("/srv/www" must not admit
+            // "/srv/www2" or "/srv/www-evil").
+            string resolved_root = yield resolve_server_path(server_path);
+
+            if (resolved_root == null) {
+                return new HttpStringResult("Not Found", StatusCode.NOT_FOUND);
+            }
+
+            string? resolved_path = yield resolve_path(full_path);
+
+            if (resolved_path == null) {
+                return new HttpStringResult("Not Found", StatusCode.NOT_FOUND);
+            }
+
+            if (!path_is_within(resolved_path, resolved_root)) {
                 return new HttpStringResult("Forbidden", StatusCode.FORBIDDEN);
             }
-            
-            var file = File.new_for_path(resolved_path);
-            
-            // Check if file exists
-            if (!file.query_exists()) {
+
+            // A lexically-resolved tail means the target does not exist:
+            // 404 before touching query_info, which would throw instead.
+            if (!File.new_for_path(resolved_path).query_exists()) {
                 return new HttpStringResult("Not Found", StatusCode.NOT_FOUND);
             }
-            
+
+            var file = File.new_for_path(resolved_path);
+
             // Get file info
             var info = yield file.query_info_async(
                 "standard::type,standard::content-type,standard::size",
                 FileQueryInfoFlags.NONE
             );
-            
+
             var file_type = info.get_file_type();
-            
+
             // Handle directories
             if (file_type == FileType.DIRECTORY) {
                 // Try index file first
-                if (index_file != null) {
-                    var index_path = Path.build_filename(resolved_path, index_file);
-                    var index_file_obj = File.new_for_path(index_path);
-                    if (index_file_obj.query_exists()) {
-                        return yield serve_file(index_file_obj, http_context);
+                if (index_file != null && !contains_parent_reference(index_file)) {
+                    var index_path = yield resolve_path(Path.build_filename(resolved_path, index_file));
+                    if (index_path != null && path_is_within(index_path, resolved_root)) {
+                        return yield serve_file(File.new_for_path(index_path), http_context);
                     }
                 }
-                
+
                 // Directory listing if enabled
                 if (allow_directory_listing) {
                     return yield serve_directory_listing(file, relative_path, http_context);
                 }
-                
+
                 return new HttpStringResult("Forbidden", StatusCode.FORBIDDEN);
             }
-            
+
             // Serve regular file
             return yield serve_file(file, http_context);
         }
@@ -205,11 +225,143 @@ namespace Astralis {
             return ".";
         }
         
-        private string canonicalize_path(string path) {
-            // Resolve the path to get the real, absolute path
-            // This handles . and .. components as well as symlinks
-            var file = File.new_for_path(path);
-            return file.get_path() ?? path;
+        /// True when any component of the path is exactly ".." (SA-2). Checked
+        /// on the decoded relative path before the filesystem is touched; note
+        /// that ".." embedded inside a longer component ("a..b") is a legal
+        /// filename and not traversal.
+        private static bool contains_parent_reference(string path) {
+            int start = 0;
+            while (start <= path.length) {
+                int end = path.index_of_char('/', start);
+                string component = end < 0
+                    ? path.substring(start)
+                    : path.substring(start, end - start);
+                if (component == "..") {
+                    return true;
+                }
+                if (end < 0) {
+                    break;
+                }
+                start = end + 1;
+            }
+            return false;
+        }
+
+        /// Strict containment (SA-2): `path` must be exactly `root` or live
+        /// beneath it with a "/" boundary, so "/srv/www2" and "/srv/www-evil"
+        /// do not pass a "/srv/www" root.
+        private static bool path_is_within(string path, string root) {
+            return path == root || path.has_prefix(root + "/");
+        }
+
+        /// Resolves symlinks in `path` (SA-2). GLib 2.x has no
+        /// g_file_resolve_symbolic_link, so this walks the components with
+        /// query_info_async(NOFOLLOW_SYMLINKS), following
+        /// standard::symlink-target links (absolute targets restart the walk,
+        /// relative ones are joined and lexically normalised) up to a fixed
+        /// budget to defeat symlink loops.
+        ///
+        /// Missing tail components are appended lexically, so a non-existent
+        /// target still yields a path (callers then 404 on it); returns null
+        /// only when an early component is missing or a symlink loop is hit.
+        private const int MAX_SYMLINK_FOLLOW = 40;
+
+        private static async string? resolve_path(string path) {
+            string current = File.new_for_path(path).get_path() ?? path;
+            int budget = MAX_SYMLINK_FOLLOW;
+
+            while (true) {
+                string[] parts = current.split("/");
+                bool restarted = false;
+                string resolved = "";
+
+                for (int i = 0; i < parts.length; i++) {
+                    if (parts[i].length == 0 || parts[i] == ".") {
+                        continue;
+                    }
+                    string candidate = resolved.length == 0 ? "/" + parts[i] : resolved + "/" + parts[i];
+                    FileInfo? info = yield query_info_without_following(candidate);
+                    if (info == null) {
+                        // Missing component: finish the rest lexically.
+                        for (int j = i + 1; j < parts.length; j++) {
+                            if (parts[j].length != 0 && parts[j] != ".") {
+                                candidate += "/" + parts[j];
+                            }
+                        }
+                        return candidate;
+                    }
+                    if (info.get_is_symlink()) {
+                        if (--budget < 0) {
+                            return null; // symlink loop
+                        }
+                        string target = info.get_symlink_target() ?? "";
+                        string rest = "";
+                        for (int j = i + 1; j < parts.length; j++) {
+                            if (parts[j].length != 0 && parts[j] != ".") {
+                                rest = rest.length == 0 ? parts[j] : rest + "/" + parts[j];
+                            }
+                        }
+                        string base_dir = resolved.length == 0 ? "/" : resolved;
+                        string joined = target.has_prefix("/")
+                            ? (rest.length == 0 ? target : target + "/" + rest)
+                            : (rest.length == 0 ? base_dir + "/" + target : base_dir + "/" + target + "/" + rest);
+                        string? normalised = lexical_normalise(joined);
+                        if (normalised == null) {
+                            return null; // symlink target escapes the filesystem root
+                        }
+                        current = normalised;
+                        restarted = true;
+                        break;
+                    }
+                    resolved = candidate;
+                }
+
+                if (!restarted) {
+                    return resolved.length == 0 ? "/" : resolved;
+                }
+            }
+        }
+
+        /// FileInfo about the path entry itself (not the link target), or null
+        /// when it does not exist / is not accessible.
+        private static async FileInfo? query_info_without_following(string path) {
+            try {
+                return yield File.new_for_path(path).query_info_async(
+                    "standard::type,standard::is-symlink,standard::symlink-target",
+                    FileQueryInfoFlags.NOFOLLOW_SYMLINKS
+                );
+            }
+            catch (Error e) {
+                return null;
+            }
+        }
+
+        /// Removes "." components and collapses ".." against the prefix.
+        /// Returns null when ".." pops past "/" (path escapes the root).
+        private static string? lexical_normalise(string path) {
+            string[] stack = {};
+            foreach (string part in path.split("/")) {
+                if (part.length == 0 || part == ".") {
+                    continue;
+                }
+                if (part == "..") {
+                    if (stack.length == 0) {
+                        return null;
+                    }
+                    stack.resize(stack.length - 1); // pops the last element
+                    continue;
+                }
+                stack.resize(stack.length + 1);
+                stack[stack.length - 1] = part;
+            }
+            return "/" + string.joinv("/", stack);
+        }
+
+        /// Resolves the served root (SA-2). The root is validated to exist at
+        /// construction time, so this only fails if it disappeared meanwhile;
+        /// callers treat null as "not found".
+        private static async string? resolve_server_path(string server_path) {
+            return yield resolve_path(server_path);
         }
         
         private static string[] parse_route_components(string route) {

+ 8 - 2
src/Server/RequestContext.vala

@@ -10,11 +10,17 @@ namespace Astralis {
         public bool handler_started { get; set; }
         public ResponseContext? response_context { get; set; }
 
+        /// SA-3: set once the request has been refused for an oversized
+        /// body. The pipeline is never started (or its result is discarded)
+        /// and remaining upload chunks are consumed and dropped; the 413 is
+        /// queued at reception end.
+        public bool rejected { get; set; }
+
         private bool request_fully_received;
         private bool handler_finished_execution;
 
-        public RequestContext() {
-            request_body = new ServerInput();
+        public RequestContext(uint64 max_request_body = 0) {
+            request_body = new ServerInput(max_request_body);
         }
 
         public bool handler_finished() {

+ 87 - 6
src/Server/Server.vala

@@ -8,14 +8,16 @@ namespace Astralis {
         private Daemon daemon;
         private Pipeline pipeline;
         private int port;
+        private ServerLimits limits;
         private HashSet<RequestContext> request_contexts;
         private HashSet<ResponseContext> response_contexts;
         private SocketMonitor? socket_monitor;
         private MainLoop main_loop;
 
-        public Server(int port, Pipeline pipeline) {
+        public Server(int port, Pipeline pipeline, ServerLimits? limits = null) {
             this.port = port;
             this.pipeline = pipeline;
+            this.limits = limits ?? new ServerLimits();
             this.request_contexts = new HashSet<RequestContext>();
             this.response_contexts = new HashSet<ResponseContext>();
             this.socket_monitor = null;
@@ -26,17 +28,40 @@ namespace Astralis {
 
             // On initial call for request, simply set up the context
             if (con_cls[0] == null) {
-                var context = new RequestContext();
+                var context = new RequestContext(limits.max_request_body);
                 request_contexts.add(context);
                 con_cls[0] = (void*) context;
+
+                // SA-3: reject oversized bodies as soon as Content-Length is
+                // known, before any of the body is buffered. Queueing the
+                // response here (headers-only callback) makes MHD discard the
+                // rest of the request and send our 413 instead of running the
+                // pipeline.
+                string? declared_cl = MHD.lookup_connection_value(connection, MHD.ValueKind.HEADER_KIND, "Content-Length");
+                if (Server.body_exceeds_limit(limits.max_request_body, declared_cl)) {
+                    queue_payload_too_large(connection, context);
+                }
                 return Result.YES;
             }
 
             // Subsequent calls are provided the context
             RequestContext context = (RequestContext) ((Object*) con_cls[0]);
 
-            // On the second call we populate the `HttpRequest` object and begin the handler
-            if (context.handler_context == null) {
+            // SA-3: an oversized body is being discarded. MHD forbids
+            // queueing a response while processing upload data, so the
+            // remaining chunks are consumed and dropped; the 413 is queued
+            // when reception ends (the else-branch below). The pipeline was
+            // never started for a rejected request, so nothing else answers.
+            if (context.rejected && upload_data_size[0] != 0) {
+                upload_data_size[0] = 0;
+                return Result.YES;
+            }
+
+            // On the second call we populate the `HttpRequest` object and begin the
+            // handler.
+            if (context.handler_context == null && !context.rejected) {
+                // SA-3: an oversized body never starts the pipeline at all;
+                // the request is answered with 413 at reception end below.
                 // Extract all headers from the connection (case-insensitive keys)
                 var headers = new Catalogue<string, string>(case_insensitive_hash, case_insensitive_equal);
                 var headers_collector = new KeyValueCollector(headers);
@@ -77,7 +102,11 @@ namespace Astralis {
                     catch(Error e) {
                         context.handler_error = e;
                     }
-                    if(context.handler_finished()) {
+                    // SA-3: a rejected request is answered with 413 at
+                    // reception end. The pipeline's result came from a
+                    // truncated (already-dropped) body and must never reach
+                    // the client, so the connection is not resumed here.
+                    if(!context.rejected && context.handler_finished()) {
                         // Just resume - access_handler will be re-entered and handle the response
                         MHD.resume_connection(connection);
                         this.request_monitor_update();
@@ -89,12 +118,28 @@ namespace Astralis {
             if (upload_data_size[0] != 0) {
                 var data = new uint8[upload_data_size[0]];
                 Memory.copy(data, upload_data, upload_data_size[0]);
-                context.request_body.write(data);
                 upload_data_size[0] = 0;
+                // SA-3: defensive cap for chunked/undeclared bodies. The
+                // Content-Length check above cannot see these coming; this
+                // stops buffering the moment the cap is crossed. The 413 is
+                // queued at reception end (MHD forbids queueing from inside
+                // an upload-data callback).
+                if (!context.request_body.write(data)) {
+                    context.rejected = true;
+                }
                 return Result.YES;
             }
             // End of request body data
             else {
+                // SA-3: an oversized body (declared or streamed) is answered
+                // with 413 here. This is the reception-end callback — the
+                // point where MHD allows queueing a response — and the
+                // pipeline was never started (or its result discarded), so
+                // nothing races this response.
+                if (context.rejected) {
+                    context.request_body.complete();
+                    return queue_payload_too_large(connection, context);
+                }
                 context.request_body.complete();
                 if(context.request_reception_finished()) {
                     return respond(connection, context);
@@ -108,6 +153,8 @@ namespace Astralis {
             // External epoll mode: no internal polling flag, MHD exposes its
             // epoll fd and we drive it from the GLib main loop. USE_TURBO
             // enables faster processing by not explicitly closing connections.
+            // SA-3: resource limits bound concurrency and per-connection
+            // buffering so a single client cannot exhaust the process.
             daemon = Daemon.start(
                 MHD.ALLOW_SUSPEND_RESUME | MHD.USE_TURBO | MHD.USE_EPOLL,
                 (uint16) this.port,
@@ -115,6 +162,9 @@ namespace Astralis {
                 (connection, url, method, version, upload_data, upload_data_size, con_cls) => {
                     return this.access_handler(connection, url, method, version, upload_data, upload_data_size, con_cls);
                 },
+                MHD.OPTION_CONNECTION_LIMIT, (void*) (size_t) limits.connection_limit,
+                MHD.OPTION_PER_IP_CONNECTION_LIMIT, (void*) (size_t) limits.per_ip_connection_limit,
+                MHD.OPTION_CONNECTION_MEMORY_LIMIT, (void*) (size_t) limits.connection_memory_limit,
                 MHD.OPTION_NOTIFY_COMPLETED,
                 (void*) request_completed_callback,
                 (void*) this,
@@ -151,6 +201,37 @@ namespace Astralis {
             }
         }
 
+        /// SA-3: queues a bare 413 response on the connection. Used before
+        /// the pipeline exists for the request (Content-Length known up
+        /// front) and defensively while streaming, when the body turns out
+        /// larger than declared. Uses a plain buffer response so the
+        /// streaming/suspending machinery of normal results is not involved.
+        private Result queue_payload_too_large(Connection connection, RequestContext context) {
+            context.handler_result = new HttpStringResult("Payload Too Large", StatusCode.PAYLOAD_TOO_LARGE);
+            context.handler_started = true;
+            context.rejected = true;
+
+            string body = "Payload Too Large";
+            var response = new Response.from_buffer(body.length, body.data, ResponseMemoryMode.RESPMEM_PERSISTENT);
+            response.add_header("Content-Type", "text/plain");
+            return MHD.queue_response(connection, (uint) StatusCode.PAYLOAD_TOO_LARGE, response);
+        }
+
+        /// SA-3: the 413 decision as a pure predicate (internal for tests).
+        /// max == 0 disables the limit; a missing or unparsable
+        /// Content-Length means the size is not declared and the streaming
+        /// cap in ServerInput.write is the defence instead.
+        internal static bool body_exceeds_limit(uint64 max, string? declared) {
+            if (max == 0 || declared == null) {
+                return false;
+            }
+            int64 length = int64.parse(declared);
+            if (length < 0) {
+                return false;
+            }
+            return (uint64) length > max;
+        }
+
         private Result respond(Connection connection, RequestContext context) {
             var result = Result.NO;
             if(context.handler_error != null) {

+ 40 - 4
src/Server/ServerInput.vala

@@ -4,12 +4,20 @@ namespace Astralis {
 
     internal class ServerInput : Object, AsyncInput {
 
-        private bool write_occurred;
         private Series<ByteBuffer> chunks = new Series<ByteBuffer>();
         private bool writes_complete;
+        private uint64 received_length;
+        private uint64 max_length;
+        private bool limit_exceeded;
         private delegate void WaitHandler();
         private signal void on_data_or_complete();
 
+        public ServerInput(uint64 max_length = 0) {
+            // 0 means unlimited, for tests and internal callers that want
+            // the previous behaviour.
+            this.max_length = max_length;
+        }
+
         public BinaryData? peek() {
             return chunks.first_or_default();
         }
@@ -44,10 +52,38 @@ namespace Astralis {
             return data;
         }
 
-        internal void write(uint8[] data) {
+        /// Bytes accepted so far (SA-3, test hook).
+        internal uint64 received_bytes {
+            get { return received_length; }
+        }
+
+        /// True when a write pushed the input past max_length (SA-3). The
+        /// offending chunk is kept out of the buffer: callers that ignore
+        /// this and read anyway see a truncated body rather than the whole
+        /// oversized upload.
+        internal bool over_limit {
+            get { return limit_exceeded; }
+        }
+
+        internal bool write(uint8[] data) {
+            if (limit_exceeded) {
+                return false;
+            }
+            if (max_length != 0 && received_length + data.length > max_length) {
+                // SA-3: the cap is crossed. The buffer is dropped (the body
+                // will never be handed to the application) but the stream is
+                // deliberately NOT completed: a pending read_all() in the
+                // application must keep waiting until the server has decided
+                // how to answer, otherwise its completion path (which resumes
+                // the connection) races MHD's still-ongoing reception.
+                limit_exceeded = true;
+                chunks.clear();
+                return false;
+            }
+            received_length += data.length;
             chunks.add(new ByteBuffer.from_byte_array(data));
-            write_occurred = true;
             on_data_or_complete();
+            return true;
         }
 
         internal void complete() {
@@ -57,4 +93,4 @@ namespace Astralis {
 
     }
 
-}
+}

+ 15 - 8
src/meson.build

@@ -11,8 +11,10 @@ sources = files(
     'Core/WebConfigSection.vala',
     'Core/WebConfigError.vala',
     'Core/ConfigMerger.vala',
+    'Core/ServerLimits.vala',
     'Data/FormDataParser.vala',
     'Components/EndpointRouter.vala',
+    'Components/SecurityHeadersComponent.vala',
     'Components/Compressor.vala',
     'Components/GzipCompressor.vala',
     'Components/BrotliCompressor.vala',
@@ -36,24 +38,29 @@ sources = files(
 )
 
 
-library_version = meson.project_version()
-libastralis = shared_library('astralis-@0@'.format(library_version),
+# The API version rides every artifact name (libastralis-0.1.so,
+# astralis-0.1.vapi, ...); the project version is the release version and
+# only reaches the pkg-config Version: field (usm checks it against the
+# manifest version).
+api_version = '0.1'
+release_version = meson.project_version()
+libastralis = shared_library('astralis-@0@'.format(api_version),
     sources,
     dependencies: [glib_dep, gobject_dep, mhd_dep, gio_dep, gio_unix_dep, invercargill_dep, invercargill_json_dep, json_glib_dep, zlib_dep, brotli_dep, zstd_dep, inversion_dep, libxml_dep],
     install: true,
-    vala_gir: 'astralis-@0@.gir'.format(library_version),
+    vala_gir: 'astralis-@0@.gir'.format(api_version),
     install_dir: [true, true, true, true]
 )
 
 
 pkg = import('pkgconfig')
 pkg.generate(libastralis,
-    version : library_version,
-    name : 'astralis-@0@'.format(library_version))
-    
+    version : release_version,
+    name : 'astralis-@0@'.format(api_version))
+
 g_ir_compiler = find_program('g-ir-compiler')
-custom_target('astralis typelib', command: [g_ir_compiler, '--shared-library=libastralis-@0@.so'.format(library_version), '--output', '@OUTPUT@', meson.current_build_dir() / 'astralis-@0@.gir'.format(library_version)],
-              output: 'libastralis-@0@.typelib'.format(library_version),
+custom_target('astralis typelib', command: [g_ir_compiler, '--shared-library=libastralis-@0@.so'.format(api_version), '--output', '@OUTPUT@', meson.current_build_dir() / 'astralis-@0@.gir'.format(api_version)],
+              output: 'libastralis-@0@.typelib'.format(api_version),
               depends: libastralis,
               install: true,
               install_dir: get_option('libdir') / 'girepository-1.0')

+ 427 - 0
tests/TestMain.vala

@@ -0,0 +1,427 @@
+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);
+        }
+    }
+}

+ 13 - 0
tests/meson.build

@@ -0,0 +1,13 @@
+# Astralis test suite (SA-2/SA-2b/SA-3/SA-13 regression tests).
+# Links the built library like Statum's harness and tests through the
+# public API, plus one end-to-end run of the real Server/MHD daemon for the
+# SA-3 body-limit and SA-13 header paths. Prints PASS/FAIL lines and exits
+# non-zero on failure.
+
+astralis_tests = executable('astralis-tests',
+    ['TestMain.vala'],
+    dependencies: [astralis_dep, gio_unix_dep],
+    install: false
+)
+
+test('astralis', astralis_tests)

+ 136 - 0
vapi/inversion-0.1.vapi

@@ -0,0 +1,136 @@
+/* inversion-0.1.vapi generated by valac 0.56.19, do not modify. */
+
+[CCode (gir_namespace = "inversion", gir_version = "0.1")]
+namespace Inversion {
+	[CCode (cheader_filename = "inversion-0.1.h")]
+	public class Container : GLib.Object {
+		public Container ();
+		public T configure_with<T> () throws GLib.Error;
+		public Inversion.Scope create_scope ();
+		public Inversion.Scope create_transient_scope ();
+		public Invercargill.Enumerable<Inversion.Registration> get_all_registrations ();
+		public Inversion.Registration get_registration (GLib.Type service_type) throws Inversion.ContainerError;
+		public Invercargill.Enumerable<Inversion.Registration> get_registrations (GLib.Type service_type);
+		public void initialise () throws GLib.Error;
+		public bool is_registered (GLib.Type type);
+		public Inversion.Registration register<T> (owned Inversion.TypedFactoryDelegate<T>? factory_func, Inversion.Lifecycle lifecycle);
+		public Inversion.Registration register_factory<T> (Inversion.Factory factory, Inversion.Lifecycle lifecycle);
+		public Inversion.Registration register_factory_type (GLib.Type implementation_type, Inversion.Factory factory, Inversion.Lifecycle lifecycle);
+		public Inversion.Registration register_module<T> (owned Inversion.TypedFactoryDelegate<T>? factory_func = null) throws GLib.Error;
+		public Inversion.Registration register_module_factory_type (GLib.Type type, Inversion.Factory factory) throws GLib.Error;
+		public Inversion.Registration register_module_type (GLib.Type type, owned Inversion.FactoryDelegate? factory_func = null) throws GLib.Error;
+		public Inversion.Registration register_scoped<T> (owned Inversion.TypedFactoryDelegate<T>? factory_func = null);
+		public Inversion.Registration register_singleton<T> (owned Inversion.TypedFactoryDelegate<T>? factory_func = null);
+		public Inversion.Registration register_startup<T> (owned Inversion.TypedFactoryDelegate<T>? factory_func = null);
+		public Inversion.Registration register_transient<T> (owned Inversion.TypedFactoryDelegate<T>? factory_func = null);
+		public Inversion.Registration register_type (GLib.Type implementation_type, owned Inversion.FactoryDelegate? factory_func, Inversion.Lifecycle lifecycle);
+	}
+	[CCode (cheader_filename = "inversion-0.1.h")]
+	public class DelegateFactory : GLib.Object, Inversion.Factory {
+		public DelegateFactory (owned Inversion.FactoryDelegate @delegate);
+		public static Inversion.DelegateFactory typed<T> (owned Inversion.TypedFactoryDelegate<T> @delegate);
+	}
+	[CCode (cheader_filename = "inversion-0.1.h")]
+	public class InjectionContext {
+		public InjectionContext (Inversion.Scope scope, GLib.Type requested_type, Inversion.Registration registration, Inversion.InjectionContext? previous = null);
+		public GLib.Error? error { get; set; }
+		public Inversion.InjectionContext? previous_context { get; set; }
+		public Inversion.Registration registration { get; set; }
+		public GLib.Type requested_type { get; set; }
+		public Inversion.Scope scope { get; set; }
+		public Invercargill.DataStructures.HashSet<Inversion.Registration> stack_registrations { get; set; }
+	}
+	[CCode (cheader_filename = "inversion-0.1.h")]
+	public class InstanceFactory : GLib.Object, Inversion.Factory {
+		public InstanceFactory (Inversion.Registration registration, GLib.Object instance);
+		public Inversion.Registration registration { get; set construct; }
+	}
+	[CCode (cheader_filename = "inversion-0.1.h")]
+	public class ObjectFactory : GLib.Object, Inversion.Factory {
+		public ObjectFactory (GLib.Type instance_type);
+		public GLib.Type instance_type { get; construct; }
+	}
+	[CCode (cheader_filename = "inversion-0.1.h")]
+	public class Registration : GLib.Object {
+		public Registration (GLib.Type implementation_type, Inversion.Factory factory, Inversion.Lifecycle lifecycle);
+		public Inversion.Registration @as<T> ();
+		public Inversion.Registration as_type (GLib.Type type);
+		public Invercargill.Enumerable<T> get_metadata<T> ();
+		public Invercargill.Enumerable<GLib.Object> get_metadata_type (GLib.Type type);
+		public bool provides (GLib.Type type);
+		public Inversion.Registration with_metadata<T> (T metadata);
+		public Inversion.Registration with_metadata_type (GLib.Type type, GLib.Object metadata);
+		public Inversion.Factory factory { get; set construct; }
+		public GLib.Type implementation_type { get; set construct; }
+		public Inversion.Lifecycle lifecycle { get; set construct; }
+		public Invercargill.ImmutableLot<GLib.Type> type_aliases { get; private set; }
+		public signal void alias_added (GLib.Type alias);
+	}
+	[CCode (cheader_filename = "inversion-0.1.h")]
+	public class Scope : GLib.Object {
+		public Invercargill.Enumerable<Inversion.Registration> get_all_registrations ();
+		public Invercargill.Enumerable<Inversion.Registration> get_local_registrations (GLib.Type service_type);
+		public Inversion.Registration get_registration (GLib.Type service_type) throws Inversion.ContainerError;
+		public Invercargill.Enumerable<Inversion.Registration> get_registrations (GLib.Type service_type);
+		public bool is_locally_registered (GLib.Type type);
+		public bool is_registered (GLib.Type type);
+		public Inversion.Registration register_local<T> (owned Inversion.TypedFactoryDelegate<T>? factory_func = null, Inversion.Lifecycle lifecycle = Lifecycle.SCOPED);
+		public Inversion.Registration register_local_factory<T> (Inversion.Factory factory, Inversion.Lifecycle lifecycle);
+		public Inversion.Registration register_local_factory_type (GLib.Type implementation_type, Inversion.Factory factory, Inversion.Lifecycle lifecycle);
+		public Inversion.Registration register_local_scoped<T> (owned Inversion.TypedFactoryDelegate<T>? factory_func = null);
+		public Inversion.Registration register_local_transient<T> (owned Inversion.TypedFactoryDelegate<T>? factory_func = null);
+		public Inversion.Registration register_local_type (GLib.Type implementation_type, owned Inversion.FactoryDelegate? factory_func, Inversion.Lifecycle lifecycle);
+		public void release ();
+		public T resolve<T> () throws GLib.Error;
+		public Invercargill.Enumerable<T> resolve_all<T> () throws GLib.Error;
+		public Invercargill.Enumerable<GLib.Object> resolve_all_type (GLib.Type type) throws GLib.Error;
+		public GLib.Object resolve_registration (Inversion.Registration registration) throws GLib.Error;
+		public GLib.Object resolve_type (GLib.Type type) throws GLib.Error;
+		public Inversion.Container container { get; set construct; }
+		public Inversion.Lifecycle scope_lifecycle { get; set construct; }
+	}
+	[CCode (cheader_filename = "inversion-0.1.h")]
+	public interface Factory : GLib.Object {
+		public abstract GLib.Object create (Inversion.Scope scope) throws GLib.Error;
+	}
+	[CCode (cheader_filename = "inversion-0.1.h")]
+	public interface Module : GLib.Object {
+		public abstract void register_components (Inversion.Container container) throws GLib.Error;
+	}
+	[CCode (cheader_filename = "inversion-0.1.h")]
+	public enum Lifecycle {
+		TRANSIENT,
+		SCOPED,
+		SINGLETON,
+		STARTUP
+	}
+	[CCode (cheader_filename = "inversion-0.1.h")]
+	public errordomain ContainerError {
+		NOT_REGISTERED,
+		CYCLE_DETECTED,
+		ILLEGAL_LIFECYCLE_COMBINATION,
+		INCOMPATIBLE_TYPE
+	}
+	[CCode (cheader_filename = "inversion-0.1.h")]
+	public delegate GLib.Object FactoryDelegate (Inversion.Scope scope) throws GLib.Error;
+	[CCode (cheader_filename = "inversion-0.1.h")]
+	public delegate T TypedFactoryDelegate<T> (Inversion.Scope scope) throws GLib.Error;
+	[CCode (cheader_filename = "inversion-0.1.h")]
+	public static GLib.Object create_with_injection_context (Inversion.Scope scope, GLib.Type requested_type, Inversion.Registration registration) throws GLib.Error;
+	[CCode (cheader_filename = "inversion-0.1.h")]
+	public static Inversion.InjectionContext enter_new_injection_context (Inversion.Scope scope, GLib.Type requested_type, Inversion.Registration registration) throws GLib.Error;
+	[CCode (cheader_filename = "inversion-0.1.h")]
+	public static void exit_injection_context () throws GLib.Error;
+	[CCode (cheader_filename = "inversion-0.1.h")]
+	public static Inversion.InjectionContext get_injection_context ();
+	[CCode (cheader_filename = "inversion-0.1.h")]
+	public static T inject<T> ();
+	[CCode (cheader_filename = "inversion-0.1.h")]
+	public static Invercargill.Lot<T> inject_all<T> ();
+	[CCode (cheader_filename = "inversion-0.1.h")]
+	public static Inversion.InjectionContext? try_get_injection_context ();
+	[CCode (cheader_filename = "inversion-0.1.h")]
+	public static T try_inject<T> ();
+	[CCode (cheader_filename = "inversion-0.1.h")]
+	public static Invercargill.Lot<T> try_inject_all<T> ();
+}

+ 8 - 0
vapi/libmicrohttpd.vapi

@@ -53,6 +53,14 @@ namespace MHD {
     public const int OPTION_END;
     [CCode (cname = "MHD_OPTION_NOTIFY_COMPLETED")]
     public const int OPTION_NOTIFY_COMPLETED;
+    [CCode (cname = "MHD_OPTION_CONNECTION_MEMORY_LIMIT")]
+    public const int OPTION_CONNECTION_MEMORY_LIMIT;
+    [CCode (cname = "MHD_OPTION_CONNECTION_LIMIT")]
+    public const int OPTION_CONNECTION_LIMIT;
+    [CCode (cname = "MHD_OPTION_CONNECTION_TIMEOUT")]
+    public const int OPTION_CONNECTION_TIMEOUT;
+    [CCode (cname = "MHD_OPTION_PER_IP_CONNECTION_LIMIT")]
+    public const int OPTION_PER_IP_CONNECTION_LIMIT;
 
     [SimpleType]
     [CCode (cname = "struct MHD_Connection*")]