Authenticated Is Not Protected: Auditing an Authorisation Boundary
Authenticated Is Not Protected: Auditing an Authorisation Boundary
Search
Ask the AI

Authenticated Is Not Protected: Auditing an Authorisation Boundary

After putting a two-factor login gate in front of a self-hosted file manager, I ticked it off the list and then asked myself one more question: what does this gate actually protect?

The answer made that tick look bad. It protected the browsing interface. It did not protect the files.

This is a record of that self-audit: how the hole came to exist, how — while verifying it — I discovered the gate was not in the request path at all, the two traps I hit while patching it, and the one place that stayed exposed for roughly a year even after the patch landed.

One set of bytes, two paths

The file manager’s root directory pointed at the blog’s uploads directory. That was a convenience decision at setup time: most of what goes into the drive ends up published on the blog anyway, so sharing one copy of the storage avoided shuffling files back and forth.

The problem is that the blog’s nginx already serves that uploads directory publicly as static assets. That is its job — images, attachments and download bundles all leave through it. So a single file on disk, one copy, had two URLs that would return it:

# Via the drive hostname: stopped by the gate, 302 to the login page
curl -sI https://prairie.example.com/wp-content/uploads/private.pdf | head -1

# Via the blog hostname: same bytes, 200
curl -sI https://www.example.com/wp-content/uploads/private.pdf | head -1

I tested this with a 5.5 MB PDF and got exactly that: one 302, one 200 with a complete download. Not a single line of the gate’s configuration was wrong. The gate simply had no authority over the other path.

This is worth pausing on, because intuition points the wrong way here. When you add authentication, what you are thinking is “I have protected these files.” What authentication actually acts on is a request path, not the data on disk. However many paths reach the same bytes, that is how many times the data needs auditing — and no amount of careful configuration review reveals this, because the other path lives in a different vhost and is, on its own terms, entirely correct.

The gate was not in the request path

The more embarrassing discovery came during verification. I wanted to confirm the gate side really did block, so I opened its nginx access log to look at that 302.

The log contained nothing but a handful of loopback curls I had run on the server myself.

This machine’s public entry point is a managed tunnel whose ingress rules live in the provider’s dashboard rather than on the server. That rule pointed at 127.0.0.1:8091 — the file manager itself. The nginx vhost where I had carefully configured auth_request listened on a different port and had never seen real traffic.

Put another way: before I discovered the files were downloadable through the blog hostname, the drive hostname was undefended too. The two doors I believed I had closed — neither was shut.

What made this hide so well is that browsing to the drive hostname did produce a login page. It was the file manager’s own login page, not the gate’s. The two look different, but nobody thinks to compare them when the thing they expected to see is right there.

I turned this into a standing check: before assuming a vhost is live, confirm its access log contains at least one non-loopback client IP. A vhost that has never served an external request is a document, not a control, no matter how complete its configuration reads.

awk '{print $1}' /var/log/nginx/prairie.access.log \
  | sort -u | grep -v '^127\.' | head

No output means the vhost is not in the path. The fix was to swap ports: move the file manager to a new port and let nginx take over the one the tunnel already pointed at. Changing the server is more reliable than changing the dashboard — dashboard configuration is not in version control and does not travel with a deploy.

Proxy auth outsources trust to a request header

Once the ports were swapped, one thing remained: the user should not have to log in twice.

The file manager supports a proxy authentication mode where it stops checking passwords itself and instead trusts an X-Auth-User header supplied by nginx. It works well, but it hands the entire “who is this” decision to a header, which makes two constraints mandatory at the same time:

location / {
    auth_request /__auth;
    auth_request_set $authuser $upstream_http_x_auth_user;
    proxy_set_header X-Auth-User $authuser;   # only ever from the gate's response
    proxy_pass http://127.0.0.1:8090;
}

location ^~ /share/ {
    proxy_set_header X-Auth-User "";          # unauthenticated paths must blank it
    proxy_pass http://127.0.0.1:8090;
}

The first constraint is that the header may only come from auth_request_set, reading the gate’s response — never passed through from the client. The second is easier to miss: paths deliberately exempt from authentication, such as public share links, must explicitly blank that header. Without the blanking, anyone who sends X-Auth-User: admin alongside a request for a share link walks in as the administrator. The exemption becomes a back door into the protected path.

A related detail belongs to the same family: the gate must reject usernames containing newlines or non-printable bytes when it mints its cookie, because that value eventually flows into a response header.

Validated is not the same as logged in

The gate itself had three state problems, all sitting on the line between “validated” and “completed” — the same category of thinking error as the hole above, wearing different clothes.

The first was when a one-time code gets consumed. Replay protection requires each code to be usable once, implemented by recording the counter already spent and demanding that subsequent codes strictly advance it. My first version performed that advance inside the function that validates the code — the moment a code matched, it was marked spent.

That produced the following behaviour: the user types a correct code and a wrong password, the login fails, and the code is burned anyway. They fix the password and retry, and the code from that same 30-second window is now rejected as a replay. All they can do is wait for the next window.

The correct split separates validation from consumption. The validating function only decides and returns the counter; marking it spent belongs on the path taken after the whole login has succeeded:

counter = check_totp(code)        # decide only, no side effect
if counter is None or not verify_password(user, pw):
    return unauthorized()
commit_totp(counter)              # consume only once everything passed
return issue_cookie(user)

This generalises: state advances that carry side effects belong on the success path, never on the validation path. A validation function should be safe to call repeatedly without changing the system.

The second item is an unexpectedly useful diagnostic that this bug left behind. On a failed login the gate’s error text is deliberately vague — it does not say whether the password or the code was wrong, so as not to help an attacker narrow things down. Which also means it tells me nothing when I am the one debugging.

The state file closes that gap: if the counter advanced but the request returned 401, the code was right and the password was wrong. If the counter did not move, the failure was at the code. Vague externally and inspectable internally are not in conflict; you just have to decide deliberately where the inspectable copy lives.

The third is logging out. The application’s own logout button clears the session credential it issued — but under proxy authentication its next request re-identifies the same user from X-Auth-User, so nothing was logged out. Logout has to reach the gate’s logout endpoint and clear the cookie the gate issued. This became true the moment proxy authentication was switched on, and the “sign out” button in the interface looks perfectly normal throughout. You do not find it without testing it.

Two traps while patching

The actual fix was an allowlist in the blog’s nginx: under the uploads directory, only a few named subdirectories are served publicly and everything else returns 404. That location has to sit ahead of the static-extension regex, or requests get matched by the latter first.

Writing that configuration cost me two mistakes, neither of them in the logic.

The first was nginx syntax. The allowlist needs to match year directories like 2024/ and 2025/, so I wrote a regex containing \d{2} — and nginx truncated the location at the brace. Braces delimit blocks in nginx configuration, so a regex containing {} must have the whole pattern quoted. Leaving the quotes off does not raise a syntax error; it silently shortens the pattern into a different one.

location ~ "^/wp-content/uploads/(20\d{2}|published)/" { }

The second trap is the more useful one. My reload script looked like this:

if nginx -t | tail -1 | grep -q successful; then systemctl reload nginx; fi

It reads as “reload only if the test passes.” In reality a pipeline’s exit status is that of its last command. When nginx -t fails, its error output still flows down the pipe, and grep returns 0 as soon as it matches the word anywhere in that output. This combination genuinely did reload a broken configuration once — nginx kept the old one so there was no outage, but the guard was worth nothing.

The correct form tests nginx -t‘s own exit status:

if nginx -t; then systemctl reload nginx; fi

Blocked at the origin, still served at the edge

The last layer is one I only verified afterwards: blocking a file at the origin does not remove copies already sitting in the CDN’s edge cache.

Files that had been fetched publicly before the fix carried max-age=31536000, immutable at the edge and kept being served with cf-cache-status: HIT for close to a year. What the origin returns is irrelevant at that point, because the request never reaches the origin.

When verifying, note that a plain URL shows you the cached edge copy and produces the false impression that the fix did not work. A random query parameter is what gets you past the cache to the origin’s real behaviour. You want both readings: the random-query one tells you whether the fix took effect, the plain one tells you whether the edge is still serving.

There was a related complication. The API token I had on hand was scoped for DNS validation only and could not purge cache — the call returns an authentication error outright. Purging needs either the dashboard or a separately issued token carrying cache-purge rights. That is worth confirming before you need it rather than during an incident.

What generalises

The real return on this audit was not closing the hole. It was three reusable habits.

First, the object of the audit is every URL that reaches the same data, not any one configuration file. The method is crude but effective: take a file you are certain should be private and try it against every hostname you can think of. Familiarity with the configuration does not substitute for this step, precisely because the hole grows in the place where every individual configuration is correct.

Second, before believing a piece of configuration is in effect, prove it is in the request path. A non-loopback client IP in the access log is that proof.

Third, for every authentication exemption, ask the inverse question: does this exempt path pass through, unchanged, some input that the authenticated path depends on — a header, a cookie, a query parameter?

As a postscript, I ended up tiering the storage afterwards: genuinely private material no longer lives on the disk that a public web server points at. That is not distrust of the allowlist. It is that an allowlist is a whitelist, whitelists accumulate entries as the system grows, and the fact that a given disk has no web server in front of it does not.

Leave a Reply

Scroll down