afterguard

How AfterGuard Was Tested to Let Nothing Ambiguous Through

A security gateway is only as good as its worst gap, so AfterGuard got a written threat model before any code, and every promise in it got a test. Six promises: nothing ambiguous gets through, a denied request never reaches the app, allowed traffic passes byte for byte, limits hold and memory stays bounded, a client can’t choose its identity or address, and no secret reaches a log.

Nothing ambiguous

This is the one that matters most. The test sends a collection of known tricks over a raw socket, byte by byte as written, to AfterGuard in front of a test app that records exactly what it receives:

  • encoded slashes, dots, backslashes, NULs and percent signs, in upper and lower case
  • .. and . segments, //, semicolons, control characters, invalid UTF-8
  • absolute URLs as the request target, CONNECT, OPTIONS *
  • both Content-Length and Transfer-Encoding, two Transfer-Encoding headers, headers folded over two lines, bare line feeds

Each must be refused, or reach the app in exactly the form the rules judged. Then smuggling attempts are pipelined on one connection behind clean requests, chunked bodies, upgrade headers, OPTIONS * and broken chunk lines, and every one must still be caught.

Building it turned up something about Go itself. Go’s HTTP server accepts a request with both Content-Length and Transfer-Encoding without a word. It drops the length and uses the chunks. It also joins folded headers into one. By the time a handler sees the request, the evidence is gone. So AfterGuard reads the raw bytes of every request head as Go reads them, and follows each body chunk by chunk to find where the next request starts.

Denied means denied

Random rule sets meet random requests. The test works out its own verdict for each request, independent of AfterGuard, and the app must see exactly the requests that verdict allows, no more. Another test sends 300 random requests, binary bodies and chunked ones included, directly and through AfterGuard. The app must get the same request both ways, and the client the same answer.

Limits are tested with 200 requests from 20 clients at once against a limit of 10; exactly 10 get through. A client rotating through thousands of addresses must leave the table at its size.

What the review found

Before release, an independent review set out to break AfterGuard, and it did, four ways. All four came from one weakness in that byte reader: when it had no verdict for a request, the request counted as clean.

  • An Upgrade header switched the checks off for the rest of the connection, even when no upgrade happened.
  • Go answers OPTIONS * itself, so its verdict was never used, and every later verdict on the connection went to the wrong request.
  • A very long pipelined head got no verdict at all.
  • A chunk size written loosely, such as 0, was read one way by AfterGuard and another by Go, so a whole request could hide inside what AfterGuard took for trailers.

In each case a request with both Content-Length and Transfer-Encoding reached the app. Now a request without a verdict is rejected outright, chunk lines are read exactly as Go 1.24 reads them, and a connection AfterGuard can’t follow is closed.

The review found seven medium issues too. An h2c upgrade could tunnel past every rule. Headers such as X-Original-Url, which some frameworks use to replace the path, went through. A flood of new addresses could push other clients’ limits and lockouts out of the table. Lower-case methods and underscore spellings like X_Role slipped past rules written for DELETE and X-Role. And under ignore_case, a long s (ſ) could stand in for an s. Each is closed, with the reviewer’s own reproduction kept as a regression test.

The recorded run has 404 tests across Aftermarket, all passing with Go’s race detector on. The release smoke test starts AfterGuard on every system it ships for and checks real answers: 404 for the hidden page, 401 without a login, 400 for a path trick. The raw results are in test/results in the code repository.