November 2, 2026
How two forward slashes let anyone use a government website as a free file host
The most dangerous line of code I have ever found was commented out.

By Priyansh
9 min read
Not missing. Not forgotten. Someone wrote it โ and then somebody typed two forward slashes in front of it and moved on with their day.
Because of those two slashes, a government website accepted a file from me โ a stranger with no account, no credentials, and no permission of any kind โ gave it a UUID for a name, and promised to serve it to the internet, essentially, forever.
I still keep the tab open in my browser. Like a splinter I'm not allowed to pull out.
The story of how that file got there starts three weeks earlier, with 2,135 subdomains, a rule I was starting to hate, and nothing whatsoever to show for any of it.
The rule I hated
Every hunt has house rules. Mine ran through the city's coordinated vulnerability disclosure programme โ the deal where researchers probe a government's public infrastructure, and the government handles what they find like adults instead of like an incident. Identify yourself on every request. No brute forcing, no denial of service, no touching personal data. Human pace: one request, then a 2.5-second breath, every single time, because the traffic should look like a person โ it is a person.
And one rule that quietly ran the whole campaign: only submit findings rated medium or higher.
That bar is expensive. It means walking past bugs other hunters would happily cash. It means watching someone else submit the finding you just declared not good enough โ and collect. It means the submission queue sits empty for weeks while you keep telling yourself the bar is a compass, not an obstacle.
Hold that thought. It becomes the whole story.
Weeks of elegant nothing
The campaign opened the quiet way: passive recon, zero packets sent to the target. Certificate transparency logs, passive DNS, the urlscan archive. I harvested 2,135 subdomain names under the city's top-level domain, resolved them down to 578 live addresses, and watched them cluster into families: a legacy estate of 144 hosts on one old subnet, a corporate block left over from an outsourcing era, and a fast-growing Azure fleet โ the unmistakable fingerprint of a government mid-migration to the cloud. Nine third-party services hiding behind the city's DNS. Internal hostnames peeking out of a public uptime-monitoring repository on GitHub, quietly announcing where the soft organs of the network were.
Seventy-five hosts made the shortlist for active testing. Forty-three were alive. And nearly every single one of them was a wall.
Beautiful walls, to be fair. Admin environments behind Azure's zero-trust front door. Document systems behind Keycloak realms. This is what two decades of professionalization looks like from the outside: a lot of very locked doors.
So I picked the locks that were legal to pick, one polite request at a time.
The school-finder portal with its Laravel CMS? Strict validation; every mass-assignment attempt slapped down with a 422. Dead end. The IoT sensor register behind Entra? Every Application Proxy pre-auth trick I knew, from method tampering to path normalization โ airtight. Dead end. The traffic portal, where I traced all eighteen dangerouslySetInnerHTML sinks through 1.7 MB of beautified JavaScript? Vendor-internal, every write 403-gated at the API. Dead end. The artist-studio platform on Mendix? Every entity read denied โ a textbook anonymous role. Dead end.
The parking-permits portal actually looked promising, right up until I reconstructed 65 frontend routes and 107 endpoints from its bundle and discovered the vendor's API geo-fences my entire region. Verified in code, unverifiable in reality. Dead end, with a signature.
Then the cruelest stretch: the document system's OAuth layer. Days spent dissecting its flows like a frog in biology class. A public connect-check endpoint that exchanged authorization codes before validating state โ a textbook login-CSRF precondition. Redirect URIs accepted across whole hosts. A CORS policy that reflected arbitrary origins. Each one looked juicy at write-up length. Each one, tested honestly, came up thin: no demonstrable victim, no working exploit path, no credentials in the reflection. Impact below the bar.
I dropped them all. Every single one.
And then CivicHub happened โ round one. The city's open-source citizen-participation platform. I found a token leaking into a URL. A real finding. A genuine security smell. I wrote it up, rated it honestly โ low โ and put it in the below-bar drawer with everything else.
That decision felt like masochism at the time. It turned out to be the single best decision of the campaign. Dropping the low finding meant I had to come back.
And coming back meant reading.
The bar only pays off if you hold it when it hurts. So I held it.
The blueprints were public
Here is the thing I had been too distracted to use: CivicHub is open source.
Not a sample. Not a partial dump. The actual codebase of the system running in production, published by the city on GitHub โ a 30.7 MB monorepo covering the admin server, the API server, the OAuth server, and the image server. Four vhosts on a single Azure IP, Host-header routing, an identical acceptance stack. The blueprints of the house were sitting in public, and I had spent round one on the doorstep, knocking on windows.
So I stopped hunting CVEs and started reading. Route by route, middleware by middleware, the way a detective reads a case file, asking one question at every file boundary:
"Who is allowed to be here โ and who actually checks?"
That question is the entire discipline of application security in eleven words. Most code answers it well. The interesting files answer it with a shrug.
I built the matrix endpoint by endpoint โ twenty-four API route families plus the auth, notification, stats, and plugin routes, every gate noted. And somewhere in the middle of that matrix, in apps/api-server/src/routes/api/upload.js, two forward slashes caught my eye.
Two slashes
The file did exactly what its name promised: uploads, under /api/project/:projectId/upload/. In Express, a route like that should begin with a checkpoint โ a small block of middleware that inspects the request for a valid session and returns a 401 if it doesn't find one.
The checkpoint existed. It was right there in the file. It just wasn't doing anything:
router
.route('/images|/image|/document|/documents')
.post(rateLimiter(), (req, res, next) => {
// check if req.user is set
// if (!req.user || !req.user?.id) {
// console.log ('upload path: no user found', req.user);
// return res.status(401).send('Unauthorized');
// }
next();
})
.post(imageProxyMw);router
.route('/images|/image|/document|/documents')
.post(rateLimiter(), (req, res, next) => {
// check if req.user is set
// if (!req.user || !req.user?.id) {
// console.log ('upload path: no user found', req.user);
// return res.status(401).send('Unauthorized');
// }
next();
})
.post(imageProxyMw);Read it slowly, because I certainly did. The authorization check for the upload route has been commented out. Not removed. Not refactored. Commented out โ the programming equivalent of taping a note that says DOOR OUT OF ORDER over a perfectly functional deadbolt. The lock still exists. The note is the only thing stopping nobody.
Hunters see vulnerable code all the time. But there is a special category for code where the fix already existed and someone switched it off. That is not a missing control. That is a disabled one. And disabled controls stay disabled for years, because the code looks like it has auth in it. Reviewers read what they expect to read.
Now, a commented-out check is only fatal if anonymous requests can survive the surrounding machinery. I kept reading upstream, and the picture got worse. Three pieces of code, each defensible in a code review:
The greeter. In the user middleware, requests arriving without an Authorization header are not rejected. They are given an identity: req.user = { role: 'anonymous', id: null }. The system doesn't slam the door on the stranger at the counter. It hands him a name badge and lets him walk the halls.
The laundry. For upload paths specifically, any incoming Authorization header is replaced with a freshly minted internal JWT โ userId: '9999999', signed with the server's own secret, valid five minutes, issued regardless of who is asking. The stranger doesn't just get into the building. The building issues him a company ID card under a number no auditor will ever miss.
The smuggler. The upload proxy forwards accepted files to the internal image server and injects the internal image-token into every request it forwards โ including the anonymous ones it just waved through. The image server's own defense was correct and working. The trust boundary dissolved from the inside.
The greeter, the laundry, the smuggler. Three accomplices, zero bad intentions, one confused deputy โ held together by two forward slashes where a 401 used to be.
Reading code tells you what should be true. Only a request tells you what is.
One POST
The proof was aggressively boring. That is what good proofs are.
One tiny transparent PNG. One POST to the upload endpoint with no cookies, no tokens, nothing clever. If the door is open, you just open it:
printf 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' \
| base64 -d > poc.png # a 1x1 transparent PNG
curl -s -X POST 'https://api.v2.example.com/api/project/1/upload/image' \
-F 'image=@poc.png;type=image/png'
# (the real request also carried the programme's researcher-identification header)printf 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' \
| base64 -d > poc.png # a 1x1 transparent PNG
curl -s -X POST 'https://api.v2.example.com/api/project/1/upload/image' \
-F 'image=@poc.png;type=image/png'
# (the real request also carried the programme's researcher-identification header)Two hundred.
{"name":"cvd-poc_png",
"url":"https://image.v2.example.com/image/cvd-poc-802deca4-e2ed-43b3-82b1-fb1185812de9_png"}{"name":"cvd-poc_png",
"url":"https://image.v2.example.com/image/cvd-poc-802deca4-e2ed-43b3-82b1-fb1185812de9_png"}Production. The server running the city's real citizen-participation projects just accepted a file from a completely anonymous request. Acceptance behaved identically. I repeated it with images and documents, across every route variant โ same result each time: 200, a JSON body, a fresh UUID.
Then I fetched the file back, and the finding grew edges:
HTTP/2 200
content-type: image/png
access-control-allow-origin: *
cache-control: public,max-age=31536000
x-width: 1
x-height: 1HTTP/2 200
content-type: image/png
access-control-allow-origin: *
cache-control: public,max-age=31536000
x-width: 1
x-height: 1Read those headers the way an attacker would. Any website on the internet can fetch this file cross-origin from JavaScript. Every cache on earth is instructed to store it as immutable โ for a full year. The filename is unguessable, so only the sender and the intended target ever know the URL. And the image service has no delete endpoint โ I enumerated every route it exposes. Upload: yes. Read: yes. Delete: absent.
The math is the part I could not stop recalculating. The rate limiter allows 100 uploads per minute per IP. That is ~144,000 files per day, per IP, with no quota and no cleanup. The guardrails limit what each file is โ SVGs rejected with a 415 by a proper extension allowlist, documents forced to download as attachments โ but nothing limits how many there are, or how long they live.
I left exactly three marker files, each clearly named as mine, each benign โ two 1ร1 PNGs and a tiny text-only PDF โ and disclosed all three URLs in the report so they could be cleaned the moment it landed. A proof of concept is a demonstration. Demonstrations should be tidy.
The twist that almost got away
After a find like that, you go back over the whole codebase hunting for siblings. If one route's gate was commented out, what else had been silenced?
That is when I found it, in the user routes: PUT /api/project/:id/user/:uid/reset-two-factor. In the public source, the handler has zero authorization checks. Any anonymous caller could wipe an arbitrary user's two-factor authentication. If that were live, it would not be a medium. It would be a critical with a bow on it.
I tested it the only responsible way โ read-only, nonexistent user IDs first:
curl -s 'https://acc.api.v2.example.com/api/project/1/user/999999999/two-factor-status'
# โ 404 {"message": "User not found"} โ the handler RAN unauthenticated
curl -s 'https://acc.api.v2.example.com/api/project/1/user/1/two-factor-status'
# โ 403 "You cannot manage two factor for this User"curl -s 'https://acc.api.v2.example.com/api/project/1/user/999999999/two-factor-status'
# โ 404 {"message": "User not found"} โ the handler RAN unauthenticated
curl -s 'https://acc.api.v2.example.com/api/project/1/user/1/two-factor-status'
# โ 403 "You cannot manage two factor for this User"A 403. An authorization check โ present, working, and completely absent from the public repository. Same result on production.
The GitHub repo, it turns out, lags the deployed code. The version running in production is newer, carrying fixes that never made it to the open-source mirror. My critical was a fossil: a fixed vulnerability, perfectly preserved on GitHub long after it died in production.
And there is the symmetry that matters. The upload finding survived the exact same test that killed the 2FA candidate. Same platform, same verification discipline, opposite verdicts.
The repository is a map of the past. The server is the territory.
Why a "file upload" is bigger than it sounds
"Unauthenticated file upload" sounds like plumbing. Say it slower.
It is a free, permanent, highly available hosting service on a trusted government domain โ no account, no quota, no content moderation, no delete button. A phishing-kit author's dream stack, assembled by accident.
Think about what a genuine government hostname buys an attacker. Email filters that score sender and linked domains give government infrastructure the benefit of the doubt. Security-awareness training teaches users to hover over links and check the domain โ and this one is the domain, as legitimate as the city's own press releases, wearing the city's actual TLS certificate. The domain you would tell a victim to trust is the domain hosting the attack.
Now add the cache headers. A file cached as immutable for a year is not just available โ it is distributed. Once a corporate proxy or ISP cache has fetched it, the copy outlives the original; deleting the file after a campaign does not recall what is already circulating. Unguessable UUID paths make one-to-one targeting discreet. The CORS wildcard makes scripted embedding trivial.
You cannot buy this infrastructure from any legitimate host. The city was giving it away.
I rated it medium โ honestly bounded: no XSS, no RCE, file types restricted, rate limited. Severity ratings are a language for consequences, not mechanics. The mechanic is two comment slashes. The consequence is a municipal government's domain being conscripted โ permanently, at zero cost โ into infrastructure for impersonating that same government.
The fix, meanwhile, is almost embarrassing: the 401 branch is already written. It is sitting in the source, behind the slashes. Uncomment it โ and delete the ghost-user branch while you are in there. The harder half is the debt the finding exposes: no deletion, no retention, no quota, on a service that was accidentally public.