How one letter opened HuggingChat’s admin API

On November 5, the security company V12 posted a two-panel image that should not work as well as it does. On the left, POST /admin/export gets a 401. On the right, POST /%61dmin/export gets a 202, where %61 is the letter a.

The trick is one percent-encoded character in the path. Memory management and concurrency have nothing to do with it. The admin API in Hugging Face’s open source chat app stops asking for the admin secret.

The graphic is funny until you read the guard.

V12 sells an agentic bug hunter, and the thread doubles as a product demo. The finding is still real. Hugging Face’s open source chat-ui repository powers HuggingChat at huggingface.co/chat, and anyone can self-host it.

The check that never ran

The handler for /admin/export does not check authorization. It reads a model name from a JSON body, walks through conversations, writes a Parquet file to /tmp, and uploads the result to a dataset configured by the operator.

The admin API’s protection sits in one branch of src/lib/server/hooks/handle.ts. In the last published release, v0.10.0, it looks like this:

if (event.url.pathname.startsWith(`${base}/admin/`) || event.url.pathname === `${base}/admin`) {
	const ADMIN_SECRET = config.ADMIN_API_SECRET || config.PARQUET_EXPORT_SECRET;

	if (!ADMIN_SECRET) {
		return errorResponse(500, 'Admin API is not configured');
	}

	if (event.request.headers.get('Authorization') !== `Bearer ${ADMIN_SECRET}`) {
		return errorResponse(401, 'Unauthorized');
	}
}

The guard covers every route under the admin prefix. It refuses to run without a secret and compares the header. The string it reads, though, is not the string that decides where the request goes.

The comment above the export handler shows the intended call: curl with Authorization: Bearer <ADMIN_API_SECRET>. The handler itself never looks at that header. The hook was the only place that checked the admin secret.

Two readings of the same URL

SvelteKit percent-decodes the path and picks a route before the handle hook runs. /%61dmin/export is already the admin export route by the time the hook gets control. The hook, though, reads event.url.pathname, which still carries the encoded string. It compares /%61dmin/export to /admin/, gets false, and moves on.

Then resolve(event) renders the route SvelteKit already chose. The hook made its decision on a string the router had already decoded and moved past.

The fix is one line of new logic. Commit 1996ae90, from August 27, replaces the pathname check with the route the framework resolved:

if (event.route.id === '/admin' || event.route.id?.startsWith('/admin/')) {

The commit message names the mechanism directly: gate on event.route.id, the canonical, already-decoded id of the route that will handle the request.

What the admin routes do

Two admin routes inherited the bug.

RouteWhat one request startsAuth inside the handler
/admin/exportA Parquet export of conversations for one model, uploaded to a datasetNone
/admin/stats/computeThe conversation and message stats aggregationNone

The export is the one that got the headline. It queries conversations where shareConversationsWithModelAuthors is true, handles anonymous sessions and logged-in users in two passes, and writes the rows to the dataset named in PARQUET_EXPORT_DATASET. If the operator never configured that dataset or the upload token, the route returns 500 before doing anything.

The stats route is simpler. One POST with no body starts computeAllStats() and returns 202. The route has no configuration check to trip over. Call it in a loop and you can make someone else’s server run aggregation jobs on demand.

Where the thread oversells it

V12’s first post says a single request could trigger “bulk internal exports of every user’s private chats.” The code is narrower than that in two places.

The export only selects conversations from users and sessions that left shareConversationsWithModelAuthors on. That setting defaults to true, so the pool is large, but anyone who turned it off is not in it. The handler also requires a model name and filters conversations to that model, so covering every model takes one request per model.

The attacker also does not receive the file. It lands in the operator’s dataset. If that dataset is private, the stranger gets a heavy job on your infrastructure and one more file in your storage. If the dataset is public, the conversations that match the query become readable, and then the headline starts to look reasonable.

None of this proves the hosted service was exposed. The thread demonstrates an unauthorized admin route, not a production data leak, and no public document says Hugging Face had the export configured. That is a separate question from whether the code was vulnerable.

One detail does not match. In the current source, the export handler returns an empty 200. The only admin endpoint I found that returns 202 is the stats route, yet the graphic shows 202 for the export. The hosted build may differ from main, so the status line is not a reliable part of the finding. None of that changes the bypass.

None of this makes the bug harmless. An unauthenticated stranger can still make your server run the most sensitive bulk operation in the app. The attacker is using your admin API as a remote control.

The fix was 70 days old

The commit is dated August 27. The thread went up on November 5. That is 70 days, which looks like coordinated disclosure, though no public document confirms one.

The release state is easier to pin down. The latest tag, v0.10.0, was published on May 11, 2026 and still contains the old pathname check. The compare view on GitHub puts the gap at 188 commits. Anyone who builds from that tag gets the vulnerable check, no matter how recently they cloned.

I have not found a CVE, a published security advisory, a release note for the fix, or any public account from Hugging Face. The primary record is thin: a commit date and a thread.

The release gap bothers me more than the bypass itself. A fix on main protects the people who build from main. Anyone who builds from the release without patching is still on the old code, and the version number they trust tells them nothing.

I do not know when the change reached huggingface.co/chat. I have not found a public statement about it. A commit date is not a deployment date, and production could have been patched before or after the commit. The hosted service is the least documented part of this story.

Checking your own instance

If you self-host chat-ui, check whether the fix is in your history:

git merge-base --is-ancestor 1996ae90 HEAD && echo fixed

If it prints nothing, the fix is not in your history, or your clone is too shallow to know. Apply the change to handle.ts or build from main after August 27. No release includes it.

You can also test the behavior directly. Run this only against systems you own:

HOST=https://your-site.example
for p in /admin /%61dmin /Admin //admin /./admin /admin/. /%2561dmin; do
  printf '%-14s' "$p"
  curl -s -o /dev/null -w '%{http_code}\n' "$HOST$p"
done

If your deployment sets a base path, add it to every variant. A 200 or 202 on a variant while /admin demands the admin secret is the signature. A difference between 403 and 404 deserves more attention, since some servers treat case and dot segments as different paths and answer with their own error.

Check the logs as well. Requests with encoded letters in the path are rare in normal traffic:

grep -E '"(GET|POST) [^ ]*%(4[1-9A-Fa-f]|5[0-9Aa]|6[1-9A-Fa-f]|7[0-9Aa])' access.log

Turning off the dataset and token for the export removes the worst route. It does not fix the stats route, and it does not fix the next admin endpoint someone adds.

The rule I would take from this

Decide authorization on the matched route, or inside the handler that will run. A security filter that reads the raw URL can disagree with the code behind it. That disagreement is the bug. Percent-encoding is one way to create it. Any layer that normalizes a path differently from the filter can create it too.

Start from deny. Every route needs the secret unless it appears on a short public list. A new admin endpoint should not become public because someone forgot to update a string comparison.

Test the variants in CI. Ten lines of shell would have caught this, and they will keep catching it after the next refactor.

Keep admin routes off the public internet where you can. A VPN or an IP allowlist does not care how the path is encoded.

If you use nginx rules written against $request_uri, check them. location blocks match the normalized URI, but a rule on the raw request variable does not, and the same split shows up there.

The comment above the export handler explains how to authenticate, in a curl command with a Bearer token. The bypassed request skipped the only branch that checked the token.

Sources

V12’s thread
Fix commit 1996ae90
The vulnerable check in v0.10.0
Admin export handler
Admin stats handler
Settings default for sharing with model authors
v0.10.0 release
Pasquale Pillitteri’s analysis