Skip to the content.

theroyallab/tabbyAPI — security scan

Repository: theroyallab/tabbyAPI Commit scanned: c50f0d2b Scan date: 2026-08-08 Disclosure status: disclosed — issue #448

Summary

Severity Count
Critical 0
High 2
Medium 16
Low
Info

Total findings: 18 raw / 18 at --min-severity medium (2 real after curation; one of the 18 pointed at a real problem, and it understated it)

Scanned with --min-severity medium, so low and info rows are filtered rather than empty.

The project

TabbyAPI (1.3k★, AGPL-3.0) is the official API server for ExLlamaV3 — a FastAPI application that loads a quantized model onto your GPU and serves it over an OpenAI-compatible API, with a KoboldAI-compatible surface as an option. It is the thing a very large number of people run locally when they want their own inference endpoint behind SillyTavern, Open WebUI, or a script.

It is small and legible: 92 Python files, 13,782 lines. The README is unusually honest about its own scope — “TabbyAPI is a hobby project made for a small amount of users. It is not meant to run on production servers.” That sentence does real work in this write-up, and I’ll come back to it.

Maintenance is active and plural: six distinct authors had pull requests merged in the last 60 days and seven issues were closed. There is no SECURITY.md at the root, in .github/, or in docs/; private vulnerability reporting is disabled; the only funding signal is a Ko-Fi badge. That combination is not strict-norm by this series’ test, so a public courtesy issue is the right channel.

The finding: two correct decisions that break each other

The scanner flagged one line in endpoints/server.py:35 as fastapi.security.wildcard-cors — a rule that fires on thousands of repos and is dismissed on most of them, because a wildcard CORS policy on a service with no cookie authentication is usually a shrug. It was right here, and for a reason the rule cannot see.

The server ships this:

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

Somewhere else entirely, two other decisions treat a loopback bind as a security boundary in its own right:

Each of the three is defensible alone. A permissive CORS policy makes life easy for the many browser front-ends people point at TabbyAPI. An auth-disable escape hatch is a kindness for a single-user local box. The docstring is candid rather than negligent — it tells you the model is thin and why the author thinks that’s acceptable.

The problem is the composition, and it is the same shape as the N.E.K.O finding: a web page running in your browser is also “connecting from localhost.” The premise the auth exemption rests on is not a property the bind enforces. CORS is precisely the mechanism that decides whether a page from evil.example gets to talk to 127.0.0.1:5000, and this configuration decides yes, to everything.

For a user who followed the config’s own advice and set disable_auth: true, get_key_permission() returns "admin" to every caller. Any site that user has open in another tab can then drive the admin API: load models, /v1/download arbitrary HuggingFace repos onto the disk, switch prompt templates, replace sampler overrides, and read /v1/model/list — which returns model_path.resolve(), the absolute local model directory listing.

With auth left on (the default), the exposure narrows but does not vanish. /health and /.well-known/serviceinfo carry no auth dependency at all, and /health returns up to 100 stored UnhealthyEvent descriptions — raw backend exception strings from backends/exllamav3/model.py:1402, which routinely carry local filesystem paths. Any page you visit can read those cross-origin, and can use them to fingerprint that you’re running TabbyAPI and what you have loaded.

What the differential showed

Reading Starlette’s CORS middleware and asserting the outcome would have been enough to sound right. Running it changed two claims.

Replicating the middleware config exactly, on the versions actually resolved in the project’s dependency tree (fastapi-slim 0.129.1, starlette 1.5.0):

config GET /health ACAO page can read? preflight POST /v1/model/load page can send?
shipped (origins=["*"], credentials=True) https://evil.example (reflected) yes 200 yes
credentials=False only * yes 200 yes
allow_origins allowlist absent no 400 no

The first correction: because allow_credentials=True, Starlette does not send * — it reflects the requesting origin. This is the same mechanism that corrected my own report on agentic_security back in July, where I described a permissive CORS config as “credentials silently disappear” and the maintainer’s fix revealed it was a live reflect-any-origin hole. Same framework, same illegal-looking combination, second time it behaved more permissively than the spec reading suggested. The lesson has now paid for itself twice: for a permissive-config finding, what matters is what the framework does with the combination, not that the combination is illegal.

The second correction is the one that makes the issue actionable rather than merely correct. The obvious “fix” — flip allow_credentials to Falsedoes not close it. With * still in allow_origins, a cross-origin page can still read every response and preflight still returns 200. Only the origin allowlist does the work. Had I filed without running that row, the likely outcome is a one-character patch that leaves the hole exactly where it was and closes the issue as fixed.

The second item: an SSRF that no rule fired on

common/image_util.py:31-43: for any image_url that isn’t a data: URI, get_image() performs a bare session.get(url) — no scheme filter, no host filter, no destination policy. It is reachable from /v1/chat/completions through chat_completion.py:308multimodal.py:17vision.py:39, and disable_fetch_requests defaults to False.

That gives an api-key holder a server-side GET to anything the host can reach, which is a genuine step up from what an api key is otherwise for — and it lands hardest in exactly the scenario the disable_auth comment contemplates, an instance shared with other people.

The preconditions are real and I stated them as such in the issue: it needs a vision-capable model loaded (use_vision) and a valid api key. This is not a drive-by.

I ran the primitive rather than describing it, and it killed part of my own draft:

Publishing the negative results is the point. “No timeout=” reads like an unbounded hang until you check the library default, and file:// SSRF is a claim that would have been refuted by the first maintainer to try it.

What the other 16 findings were

Cluster Count Verdict
github-actions-mutable-action-tag 12 Hardening noise — sixth consecutive scan where one GHA rule is the single largest cluster
Dockerfile USER is root (Trivy) 2 Real hardening, not exploitability-shaped; a GPU inference container
non-literal-import (optional_dependencies.py:65) 1 False positive — the name comes from a fixed pydantic field set, never a request
explicit-unescape-with-markup (templating.py:49) 1 False positive — Markup() wraps a JSON filter feeding a prompt string, never HTML; the environment is ImmutableSandboxedEnvironment
dynamic-urllib-use-detected (tools/replay_chat_request.py:134) 1 False positive — a developer CLI replay tool, not shipped server surface

Patterns observed

The auth wiring is the best-built part of the codebase, which is why the finding lives outside it. Every route across all three routers — endpoints/core, endpoints/OAI, endpoints/Kobold — carries either check_api_key or check_admin_key, and the admin/api split lands on the right axis: every state-changing route (load, unload, download, template switch, sampler override) requires admin, every read requires at least an api key. load_inline_model() re-checks permission rather than trusting its route guard, which is the defensive habit that catches refactors. The auth-file watcher keeps the previous key set when a reload fails, so a partial write can’t fail open.

That matters because it is the inverse of the loopx finding from yesterday, where a guard was wired to a subset of one surface’s handlers and the unguarded subset was the one that returned the private material. Here the subset boundary is drawn correctly and consistently. The problem is a level below: the transport layer decides who is allowed to speak to the guard at all, and that decision was made once, globally, in a different file, for a different reason.

A candid disclaimer is not the same as a defended boundary — but it isn’t nothing, either. The README’s “not meant to run on production servers” is the kind of sentence that could excuse almost any finding, and it genuinely does retire a whole class of them: nobody should file “no rate limiting” or “no audit log” here. But it doesn’t retire this one, because the claim under attack isn’t a production claim. It’s the local claim — the one the project makes affirmatively, in its own config comments, as advice to follow. The advertised-boundary test from Agently applies in a softer form: TabbyAPI doesn’t name a boundary it fails to enforce, it names a condition (“only connecting from localhost”) that it treats as sufficient and that its own transport config makes insufficient.

Dependency posture is genuinely clean and worth saying out loud. 61 resolved dependencies, zero known advisories — no stale lockfile cluster, no version-match table to triage for reachability. Gitleaks returned a true empty [], and api_tokens_sample.yml ships with empty placeholder values rather than plausible-looking fakes. On a series where dependency drift is the single most common filing, a project this small keeping 61 deps current is the unglamorous thing that prevents most incidents.

Notes on the tool

The one rule that mattered fired, and its severity was three levels too low. wildcard-cors came through as medium, sorted below twelve GitHub Actions tag-pinning findings and two Dockerfile USER warnings. Severity is a property of a line for a scanner and a property of a system in reality, and nothing in the report could connect endpoints/server.py:35 to a comment in config_sample.yml two directories away. This is the composite class again: no single-file rule can see it, because neither file is wrong.

Sixth consecutive GHA mutable-action-tag flood. 12 of 18 findings — 67% of this entire report — were one rule. On loopx it was 17 of 39. The backlog item is now unavoidable: this rule needs to be collapsed into a single aggregated finding with a count, not emitted per-occurrence, or it will keep being the largest cluster in every report this series produces regardless of the project.

The coverage row, again — this time it would have read correctly. Following yesterday’s loopx lesson, I checked every raw output against the project’s own declarations before trusting a zero: semgrep 43 KB (healthy), gitleaks 3 bytes ([] — a true zero, confirmed against api_tokens_sample.yml holding only empty placeholders), trivy 5.3 KB (two Dockerfile targets, 0 vulnerabilities), pip-audit 61 dependencies resolved with 0 advisories. Every one of those zeros is real. But I still had to hand-verify four files to establish that, on the second consecutive scan — which is the whole argument for emitting a per-tool coverage row (0 of 61 deps, 0 of 92 files) instead of a bare zero.

Both real findings needed a runnable differential, and both differentials changed the text. The CORS item gained a corrected mechanism (reflection, not *) and a corrected remedy (the allowlist, not the credentials flag). The SSRF item lost two claims (file:// read, absent timeout). Neither correction was available from reading. A scanner cannot do this, but the curation layer around it can, and increasingly should: for any finding whose severity depends on framework behavior, the framework is installed and the question is one script away.

Disclosure timeline

Reproduce

git clone https://github.com/theroyallab/tabbyAPI /tmp/scan-target
python scanner/run_scan.py --repo /tmp/scan-target \
  --reports-dir ./reports/theroyallab-tabbyapi --min-severity medium