Status: This is a write-up, not a set of fixes. Nothing in the code has been patched. Each finding below describes the problem, where it lives, the impact, and a direction for remediation — the actual defensive code is left to be written deliberately, one OWASP risk per branch.
Scope reviewed:
flask_api.py,create_key.py,client.js,examples/basic.js. Mapped to the OWASP Top 10 for LLM Applications.
| # | Finding | OWASP | Severity | Location |
|---|---|---|---|---|
| 1 | Path traversal via Authorization header |
LLM05 / general | Critical | flask_api.py:42-44, 19-25 |
| 2 | Hardcoded master key & model placeholder in source | LLM02 / hygiene | Critical | flask_api.py:11, 17 |
| 3 | API keys are bearer secrets stored as plaintext filenames | LLM02 | High | create_key.py:39, flask_api.py:21 |
| 4 | No quota / rate-limit enforcement | LLM10 | High | flask_api.py:51-81 (absent) |
| 5 | No validation of messages structure/content |
LLM01 / LLM05 | High | flask_api.py:62-63 |
| 6 | No system-prompt-leak protection | LLM07 | Medium | flask_api.py:70-81 (absent) |
| 7 | No output handling / response sanitization | LLM05 | Medium | flask_api.py:79-81 |
| 8 | Server binds 0.0.0.0 with no transport security |
general | Medium | flask_api.py:118 |
| 9 | Auth uses non-constant-time existence check | general | Low | flask_api.py:44 |
| 10 | Usage tracking is racy (read-modify-write to file) | general | Low | flask_api.py:33-40 |
Where: flask_api.py:42-44 (is_valid_key) and flask_api.py:19-25 (load_key_data).
def is_valid_key(api_key):
return os.path.exists(os.path.join(ALLOWED_KEYS_DIR, f"{api_key}.json"))Problem: the Authorization header value is taken from the client and
spliced directly into a filesystem path. os.path.join does not sanitize —
if the header contains ../, the path escapes ./allowed/. A value like
../../../../etc/passwd%00 (or any traversal sequence) lets an attacker probe
for the existence of arbitrary files on the host. Because load_key_data uses
the same pattern, /key_info/<api_key> can be used to read the contents of
any reachable .json file the process can open.
Impact: authentication bypass and arbitrary-file disclosure on the host.
Remediation direction: never let request-controlled input become a path
component. Validate the key against a strict allowlist charset (e.g.
^[A-Za-z0-9_-]+$) before touching the filesystem, reject anything else, and
additionally confirm the resolved absolute path stays inside ALLOWED_KEYS_DIR.
This is the fix/auth-path-traversal branch in your conventions.
Where: flask_api.py:17 (MASTER_KEY = "KEY_HERE") and flask_api.py:11
(MODEL_NAME = "INSERT_MODEL_HERE").
Problem: the master key — which authorizes /update_params to change global
generation behavior — is a literal in the source file. Any commit containing a
real value leaks it permanently into git history. The model name is also baked
in rather than configured.
Impact: full compromise of the privileged endpoint if a real key is ever committed; no environment-specific configuration.
Remediation direction: read both from environment variables (e.g.
os.environ["MASTER_KEY"]), fail fast on startup if unset, and keep them in a
.env file that is git-ignored (now covered by .gitignore). Aligns with the
"no hardcoded secrets" rule.
Where: create_key.py:39 writes ./allowed/<api_key>.json; the key string
is the filename and also appears inside the file ("API Key": api_key).
Problem: the bearer secret is stored in cleartext and is enumerable by
directory listing. Anyone with read access to ./allowed/ obtains every valid
key. There is no hashing.
Impact: total credential disclosure from a single directory read or backup.
Remediation direction: store a hash of the key (e.g. salted SHA-256) as the lookup, not the raw secret; keep the directory permissions tight; never log raw keys.
Where: flask_api.py:51-81. Usage Limit is recorded by create_key.py
and Usage Times is incremented, but nothing ever compares them.
Problem: unbounded use of an expensive resource (GPU inference). A single key — or an unauthenticated flood hitting the auth check — can exhaust compute.
Impact: denial of wallet / denial of service; the documented quota feature is non-functional (the README even admits this).
Remediation direction: enforce Usage Times < Usage Limit before
generating; add per-key and per-IP rate limiting (token bucket or a library
such as Flask-Limiter). This is the feat/llm10-rate-limiting branch.
Where: flask_api.py:62-63 — messages = data["messages"] is passed
straight to tokenizer.apply_chat_template.
Problem: the only check is that a messages key exists. There is no
verification that it's a list of {role, content} objects, no size/length cap,
and no prompt-injection inspection. Malformed input can crash the tokenizer;
hostile input is forwarded to the model unfiltered.
Impact: crashes (availability) and a wide-open prompt-injection surface.
Remediation direction: schema-validate each message (allowed roles,
string content, max count, max length) and run injection detection before
generation. This is the feat/llm01-prompt-injection work.
Where: generation path flask_api.py:70-81. The client can supply its own
system message and the model output is returned verbatim.
Problem: there is no guard preventing the model from echoing a privileged system prompt, nor any separation between caller-supplied and server-enforced instructions.
Remediation direction: keep server-side system instructions out of client-controllable content and scan responses for leak patterns before returning.
Where: flask_api.py:79-81 returns the decoded model text directly.
Problem: improper output handling — raw model text is handed to clients with no encoding, filtering, or PII/secret redaction. Downstream consumers that render it (HTML, shell, SQL) inherit injection risk.
Remediation direction: treat model output as untrusted; apply contextual encoding and PII/secret redaction (your LLM02 redaction work) before returning.
Where: flask_api.py:118 — app.run(debug=False, port=5000, host='0.0.0.0').
Problem: the dev server listens on all interfaces over plain HTTP. Bearer keys and prompts travel unencrypted; Flask's built-in server isn't meant for production exposure.
Remediation direction: terminate TLS at a reverse proxy (nginx/caddy) or a
production WSGI server, and bind to 127.0.0.1 when only local access is needed.
Where: flask_api.py:44. Validity is "does this file exist," and the master
key compare at flask_api.py:88 uses !=.
Problem: existence checks and ==/!= on secrets can leak timing
information. Minor here, but worth noting for the master-key compare.
Remediation direction: compare secrets with hmac.compare_digest.
Where: flask_api.py:33-40. update_key_usage does read-JSON →
modify-in-memory → write-JSON with no locking.
Problem: concurrent requests with the same key can interleave and lose counter increments or IP entries — and this becomes correctness-critical the moment quota enforcement (#4) depends on the counter.
Remediation direction: serialize updates (file lock) or move usage state to a store with atomic increments.
These aren't attacks but they mean the client and server don't actually interoperate as written — relevant when testing any fix:
- Response key mismatch: server returns
{"response": ...}(flask_api.py:81); client readsresponse.data.content(client.js:118) andexamples/basic.js:21readsresponse.content→ both getundefined. Bearerprefix mismatch: client sendsAuthorization: Bearer <key>(client.js:18); server matches the raw header against<key>.json(flask_api.py:44) → it would look for a file literally namedBearer <key>.json./key_infoURL mismatch: client calls/key_infowith no key (client.js:133); server route requires/key_info/<api_key>(flask_api.py:103) → 404.