OSIIX Library

Tracing the Boundary Between Code and Runtime Behavior in Network Check Security Hardening

From a Content-Length-dependent limit to enforcing actual received bytes at the ASGI layer. A record of tracing the gap between implementation intent and runtime behavior.

Type
Case study / technical record
Author
mars70

1. Introduction

Network Check is a read-only verification service that reads and displays publicly observable information such as DNS records and TLS settings for domains and IP addresses. It is not a penetration-testing or vulnerability-assessment service. In this Security Hardening effort — work to identify security weaknesses through review and incrementally apply mitigations — the main focus was Network Check's own web-input boundaries and the handling of error information included in public responses. It was not a comprehensive effort to make the whole of Network Check secure.

This work did not begin as a response to something being breached. It began as a read-only security review intended to confirm how far the existing defenses actually worked, not only in code and tests but also in the behavior of the running application.

During that process, one design decision that initially looked reasonable turned out not to behave as intended. This article records how that was found and corrected.

2. Existing defenses checked first

The review also covered XSS, SQL Injection, Command Injection, and error-information leakage. Within the scope reviewed, the existing defensive structure was observed to be functioning.

The Jinja2 template engine performed HTML escaping by default, and there was no explicit escaping bypass such as |safe. On the JavaScript side, DOM writes used textContent, and there were no locations that inserted user input directly through mechanisms such as innerHTML. SQL execution consistently used placeholder-based parameter binding, with no SQL statements assembled through string concatenation. The HTTP/2 check that invokes an external command also launched the process directly with an argument list rather than through a shell, and the domain name was validated by regular expression before being passed to it.

Regression tests were added for these areas using boundary and regression inputs containing shell metacharacters, SQL metacharacters, and similar values. These tests do not attempt to attack third-party systems; they are safe defensive checks intended to keep verifying, through future code changes, that the existing protections have not been broken. There were also several locations where error messages exposed internal exception details or file paths directly in public responses, and those were replaced with generic messages. These items are not the main subject of this article. The central story is the input-size limit described next.

3. Initial design: relying on Content-Length

Following the general design principle of placing a limit on request-body size to reduce resource exhaustion, the Hardening work initially started with an approach that read the HTTP request's Content-Length header and returned 413 — the HTTP status indicating that the request body is too large — if the declared size exceeded a fixed byte threshold of 8 KiB. This was not an implementation that had already existed in Network Check; it was the first approach adopted during this Hardening work. Content-Length is a header in which the client declares the exact number of bytes in the request body in advance, so looking at it can determine the size before the entire body is received. At first glance, the design looked efficient and reasonable.

4. Boundary gap: self-reported header and character-limit mismatch

During the review, however, an oversight in this design became apparent. Content-Length is ultimately a value reported by the client. Under HTTP, a client can send a request without that header, and it can also use Transfer-Encoding: chunked, where the body is sent incrementally in chunks. With chunked transfer, the total body length is not fixed when sending begins, so Content-Length is not used.

Within the source of the Starlette version range used at the time, the review did not find a built-in body-size limit in Request.body() or in the form parser itself. For the body-size limit implemented at the application layer, that meant the intended restriction did not work for requests without Content-Length or for requests using chunked transfer. "Checked the header" and "limited the size of the body actually received" were not the same thing.

At the same time, another mismatch was found. Inputs such as domain names and URLs had character-count limits — 2048 characters for a URL — while the body-size limit was byte-based at 8 KiB. When character and byte limits are designed separately, percent encoding can make the actual byte size diverge. A simple calculation shows that the 2048-character URL field alone could expand to 24,576 bytes after encoding, exceeding an 8 KiB body-size limit.

5. The first fix still did not return 413

To remove the dependency on Content-Length, the first fix tried raising a custom exception while reading the body and converting that exception into a 413 response. But when the tests were actually executed, the result was not the expected 413. A generic 400 (Bad Request) was returned instead.

Tracing the cause showed that, in the processing path used by FastAPI 0.115.6 in this application, an exception raised while reading the request body was caught during routing and converted into a generic 400. This was confirmed by direct observation. The application code intended to return 413, but that intent was overwritten as the request passed through the routing path.

This was a concrete example of how "intent" in source code does not necessarily match the "behavior" that emerges after a request passes through the framework. The behavior was observed in this application's implementation path; it has not been generalized to other versions (see "What this case shows — and what it does not" near the end).

6. Moving the boundary to the ASGI layer

In this application, an HTTP request passes through ASGI — the standard interface between Python web applications and servers — into Starlette/FastAPI, and from there reaches Network Check's application logic. Roughly, the path looks like this:

client request
    → ASGI server / interface
        → Starlette / FastAPI (routing, exception handling)
            → Network Check application

The earlier conversion to 400 occurred at the Starlette/FastAPI stage of this path. The design was therefore reworked. Instead of depending on the value in the Content-Length header, a new middleware was implemented at the outer ASGI interface, using receive() — the point where the actual request-body byte stream is received — to accumulate the number of bytes actually read.

This BodySizeLimitMiddleware runs before the request reaches Starlette/FastAPI routing or the application code. As soon as the received byte count exceeds the limit, it sends a 413 response directly and stops processing without going through routing. If the request stays within the limit, the received data is buffered and then "replayed" unchanged to the downstream application. With this design, the decision is based only on the bytes actually received, regardless of whether Content-Length is present or whether chunked transfer is used. It also avoids the earlier problem in which routing caught the exception, because the response is returned before routing is reached.

At the same time, several separate body-size-related constants were consolidated into a single source-of-truth constant: REQUEST_BODY_MAX_BYTES = 32768 (32 KiB). This value was set with headroom above the 24,576-byte worst-case expansion of the 2048-character URL limit under percent encoding. The 32 KiB value is specific to Network Check's input limits in this implementation; it is not a general recommendation. Consolidating the limits resolved the input-design mismatch identified in this review.

7. What happened after the tests passed

After these changes were applied, the regression suite against the source code was run and all 73 tests passed (73/73 PASS). The work did not stop there. The changes were then applied to a validation server running Network Check, and requests were sent directly to the running application to observe its behavior.

Specifically, POST requests larger than 32,768 bytes were observed to return 413; domain fields longer than 253 characters were observed to return 422, indicating an invalid input value; and the response from the /usage-metrics endpoint was checked to confirm that it did not contain internal file paths, database paths, or exception details.

Why was runtime validation still necessary after all source-code tests had passed? As described above, both the design that relied only on Content-Length and the collision with framework-level exception handling were difficult to notice from reading the source alone. They became visible as behavior only after real requests were sent. This runtime check did not mean that "security was confirmed." The actual result was narrower: for the boundary under review, implementation intent and runtime behavior were observed to match. In this case, a passing source regression suite and a functioning boundary in the running application were separate things worth checking.

Later, on , the same Security Hardening was applied to two Network Check runtimes on Jupiter. Normal POST requests larger than 32,768 bytes and chunked requests again returned 413, a 254-character domain input returned 422, and public responses were rechecked for the absence of error/path leakage. This was a later revalidation of the boundary examined here; it does not demonstrate the security of the application as a whole.

8. What I would consider now

Looking back from the current point in time, the implementation used FastAPI 0.115.6, whose Starlette dependency range was >=0.40.0,<0.42.0. The exact resolved Starlette version is UNKNOWN because no lock file existed.

A later review of the upstream Starlette project confirmed that Pull Request #3431, which added official request-body-size limiting mechanisms named RequestBodyLimitMiddleware and max_body_size, was merged and first included in the formal Starlette 1.6.0 release, as shown by Starlette's official GitHub release, tag, and commit history. That release is outside the dependency range used by this implementation. The later upstream feature therefore was not available in the dependency range used by Network Check at the time, so Network Check implemented its own application-side boundary.

If implementing a new application with Starlette today, there would be reason to consider an officially provided mechanism such as RequestBodyLimitMiddleware. This record is specifically about the version boundary that existed when the implementation was made; the time periods are kept separate so that the situation then is not conflated with the situation now. The detailed generalization boundary is consolidated in the next section.

9. What this case shows — and what it does not

The sequence described here is factual for this one Network Check implementation, supported by Git records, test results, and requests sent to a running server.

It has not been established that the same kind of mismatch occurs generally across other FastAPI or Starlette versions or other ASGI applications, and this article does not present it as a general law. It is not a claim that "Starlette has no way to limit request-body size," nor that "FastAPI requires custom middleware." Nor does this work mean that Network Check became "completely secure." As Network Check's own SECURITY_POLICY.md states, it is not a vulnerability-assessment service; what was checked here was only the behavior of specific boundaries that were identified and reviewed.

10. AI-assisted development and the Human Gate

This work was carried out as AI-assisted development, but this article is not intended to promote that approach. What seemed meaningful here was not that "AI made it secure," but that proposal, implementation, and validation on a running server were not treated as one continuous decision. A human approval step was placed at each boundary.

For example, the issue where the first implementation returned 400 instead of 413 was found during review before commit, allowing a design-correction decision to be made at that point. Even in AI-assisted development, not treating proposal, implementation, and verification as one and the same decision — and having a human stop and approve at each boundary — was one factor that contributed to finding and correcting the issue in this case.

11. Conclusion

I hope this serves as one concrete example when reviewing implementations with similar input-size boundaries.