case studies

Case Study: Putting an Old Admin Panel Behind AfterGuard

This case uses the examples/legacy-admin folder from the code repository: a small stand-in for a real old admin panel, and the guard.yaml and fixture that ship in every download archive. The commands and output below come from a real run of the downloaded aftermarket 0.6.0 in front of that panel.

The situation

The panel was written years ago for one office. It has no login. It lists and adds users under /admin/, serves a daily CSV under /reports/ that the billing system pulls, and has a debug page nobody remembers adding. It answers with its version in two headers. The team can’t change it, and now it needs to be reachable from outside the office.

Straight to the panel, on its own port:

$ curl -s http://127.0.0.1:9100/debug/env
DB_PASSWORD=hunter2

$ curl -s http://127.0.0.1:9100/admin/users
["ada","grace"]

The plan

The panel moves to 127.0.0.1:9100, so nothing but AfterGuard on the same machine can reach it, and AfterGuard listens on the public side. The example’s guard.yaml says what’s allowed, and everything else gets 403:

  • /status for anyone, within a rate limit
  • /debug/** for nobody, answered as 404
  • /reports/** for the billing system’s API key, 120 a minute
  • /admin/** for the admin login, from the office network only, with JSON bodies up to 64 KiB
aftermarket guard run --config examples/legacy-admin/guard.yaml \
  --upstream http://127.0.0.1:9100 --listen 127.0.0.1:8080

The same requests, through AfterGuard

$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/debug/env
404

$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/admin/users
401

$ curl -s -u admin:admin-demo-password-change-me http://127.0.0.1:8080/admin/users
["ada","grace"]

$ curl -s -o /dev/null -w '%{http_code}\n' -H 'X-Api-Key: billing-demo-key-change-me' http://127.0.0.1:8080/admin/users
403

$ curl -s -H 'X-Api-Key: billing-demo-key-change-me' http://127.0.0.1:8080/reports/daily
day,orders
2026-10-21,42

The debug page is gone. The admin pages want the admin login, and billing’s key, valid as it is, opens reports and nothing else. The admin’s answer has lost its version headers and gained three security headers:

HTTP/1.1 200 OK
Content-Length: 16
Content-Type: application/json
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff
X-Frame-Options: DENY

Straight from the panel, the same answer carries Server: LegacyAdmin/2.3 and X-Powered-By: PHP/5.6.

Someone tries harder

A path that climbs out of /status into the admin pages, and a request built to be read two ways:

$ curl -s -o /dev/null -w '%{http_code}\n' --path-as-is 'http://127.0.0.1:8080/status/..%2fadmin/users'
400

(raw) POST /status with both Content-Length and Transfer-Encoding
HTTP/1.1 400 Bad Request

A header some frameworks use to swap in another path, X-Original-Url: /admin/users, gets /status a 200 and nothing more: AfterGuard never passes that header on.

Then password guessing:

$ for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w '%{http_code} ' -u admin:guess$i http://127.0.0.1:8080/admin/users; done
401 401 401 401 401 429

$ curl -s -o /dev/null -w '%{http_code}\n' -u admin:admin-demo-password-change-me http://127.0.0.1:8080/admin/users
429

After five wrong passwords, that address waits, even with the right one. A minute later the right password works again.

What the log says

Every decision is one line, and none holds a password or key:

{"event":"denied","request":2,"rule":"admin-office","status":401,"method":"GET","path":"/admin/users","address":"127.0.0.1","reason":"no credential"}
{"event":"denied","request":4,"rule":"admin-office","status":403,"method":"GET","path":"/admin/users","address":"127.0.0.1","client":"billing","reason":"this client may not use this rule"}
{"event":"rejected","request":6,"status":400,"method":"GET","path":"/status/..%2fadmin/users","address":"127.0.0.1","reason":"the path has an encoded slash, backslash, dot, NUL or percent sign"}
{"event":"limited","request":14,"rule":"admin-office","status":429,"method":"GET","path":"/admin/users","address":"127.0.0.1","reason":"too many failed logins from this address"}
{"event":"rejected","request":16,"status":400,"method":"POST","path":"/status","address":"127.0.0.1","reason":"the request has both Content-Length and Transfer-Encoding"}

(Times left out to fit.) Turned-away requests never reach the panel; the example’s own test checks that. guard test runs the example’s nine fixture cases in CI, so a config change that opens the debug page again fails the build first. The AfterGuard reference has the full format.