[Bug] Incomplete fix for CVE-2026-73034: unvalidated `user-id` path component survives in /api/v1/examples/use
Search before asking
- I had searched in the issues and found no similar issues.
Operating system information
Linux
Python version information
=3.11
DB-GPT version
main
Related scenes
- Chat Data
- Chat Excel
- Chat DB
- Chat Knowledge
- Model Management
- Dashboard
- Plugins
Installation Information
Device information
Models information
What happened
Summary
The fix for CVE-2026-73034 (commit e0c741b, 2026-08-08) validated the user-id header in one endpoint only — python_upload_api.py. The two vulnerable statements it replaced still exist verbatim in examples_api.py, which the patch never touched, and that endpoint still builds a filesystem path from the unvalidated header for os.makedirs() and shutil.copy2().
v0.8.2 — the release that ships the CVE-2026-73034 fix — is still affected through this second endpoint.
Scope, up front: this is a constrained write. The attacker chooses the destination directory; the file name and its contents are fixed by the package. No arbitrary content write and no RCE is claimed — those are what made the original CVE a 9.8, and they do not apply here.
Details
packages/dbgpt-app/src/dbgpt_app/openapi/api_v1/examples_api.py, in use_example_file() (POST /api/v1/examples/use, router registered at dbgpt_server.py:63):
user_id = user_token.user_id or "default" # from the user-id header, unvalidated
...
upload_dir = os.path.join(base_dir, "python_uploads", user_id) # user_id is a path component
os.makedirs(upload_dir, exist_ok=True) # creates the escaped directory
target_name = _validate_example_filename(example["name"]) # validates the NAME only
target_path = os.path.join(upload_dir, target_name)
shutil.copy2(source_path, target_path) # writes into the escaped directoryThose first two statements are exactly the two that e0c741b replaced in python_upload_api.py. _validate_example_filename() correctly constrains the file name; what is unconstrained is the directory component taken from the header.
user_token comes from get_user_from_headers (packages/dbgpt-serve/src/dbgpt_serve/utils/auth.py:25), the same mock the original report described: it trusts the user-id header verbatim and returns role="admin" for any request. PR #3143 ("replace mock headers with revocable sessions") was closed without merging, so this is still the behaviour on main.
What the patch added, and where it is missing. e0c741b introduced _SAFE_USER_ID_RE (^[A-Za-z0-9_\-]+$, applied with fullmatch), _resolve_upload_dir() with a Path.relative_to() containment check, and a re-check of the final path. None of that exists in examples_api.py, which has not been modified since 79ea62cd (2026-05-21) — almost three months before the fix.
Affected versions
Everything below was executed against main @ 04559f9. The bridge to the
released tags is the blob hash of the file itself, so this is identity, not
inference:
| Ref | examples_api.py blob |
Same file as tested? |
|---|---|---|
main @ 04559f9 (executed) |
393cdbbf |
— |
| v0.8.2 (2026-08-26) | 393cdbbf |
byte-identical |
| v0.8.1 (2026-06-18) | 393cdbbf |
byte-identical |
| v0.8.0 (2026-03-27) | 3844e724 |
different blob |
- Confirmed affected — v0.8.1 and v0.8.2, because the vulnerable file is the exact blob that was executed.
- v0.8.0 contains the same sink but was not executed and is a different blob. It is reported as likely affected, verified by reading only, not folded into the confirmed range.
And the patched sibling, which is what makes v0.8.2 the interesting row:
| Ref | python_upload_api.py blob |
Carries e0c741b? |
|---|---|---|
main @ 04559f9 |
be02ba9a |
yes |
| v0.8.2 | be02ba9a |
yes |
| v0.8.1 | f6f8e053 |
no |
v0.8.2 was released 18 days after the fix commit and carries it — for the endpoint that was reported, and only that one.
Details
packages/dbgpt-app/src/dbgpt_app/openapi/api_v1/examples_api.py, in use_example_file() (POST /api/v1/examples/use, router registered at dbgpt_server.py:63):
user_id = user_token.user_id or "default" # from the user-id header, unvalidated
...
upload_dir = os.path.join(base_dir, "python_uploads", user_id) # user_id is a path component
os.makedirs(upload_dir, exist_ok=True) # creates the escaped directory
target_name = _validate_example_filename(example["name"]) # validates the NAME only
target_path = os.path.join(upload_dir, target_name)
shutil.copy2(source_path, target_path) # writes into the escaped directoryThose first two statements are exactly the two that e0c741b replaced in python_upload_api.py. _validate_example_filename() correctly constrains the file name; what is unconstrained is the directory component taken from the header.
user_token comes from get_user_from_headers (packages/dbgpt-serve/src/dbgpt_serve/utils/auth.py:25), the same mock the original report described: it trusts the user-id header verbatim and returns role="admin" for any request. PR #3143 ("replace mock headers with revocable sessions") was closed without merging, so this is still the behaviour on main.
What the patch added, and where it is missing. e0c741b introduced _SAFE_USER_ID_RE (^[A-Za-z0-9_\-]+$, applied with fullmatch), _resolve_upload_dir() with a Path.relative_to() containment check, and a re-check of the final path. None of that exists in examples_api.py, which has not been modified since 79ea62cd (2026-05-21) — almost three months before the fix.
Relation to previously reported issues
I checked the existing reports before filing; none of them covers this sink.
| Issue | Covers | Overlap? |
|---|---|---|
| #3104 → CVE-2026-73034 | the user-id header on /api/v1/python/file/upload |
No — that endpoint is patched; this is the sibling the patch missed |
| #3028 (closed 2026-05-21) | the example["name"] sink in this same function |
No — different sink; see below |
| #3017 | the filename parameter on the upload endpoint |
No — different sink |
| #3168 | /api/get_file traversal (file read) |
No — different endpoint, read not write |
| #3025, #3083 | execute_code, plugin install via branch_name |
No — different components |
#3028 deserves a direct answer, because it is the same function. It reported the other sink in use_example_file(): example["name"], the file name taken from the EXAMPLE_FILES dict. It was closed on 2026-05-21 — the same date as 79ea62cd, the commit that added _validate_example_filename(). That function is present today and works: the file name is constrained.
This report is about the directory component, user_id, which comes from the user-id HTTP header and is still unvalidated. Two differences worth noting:
- Different reachability. #3028 required an attacker to somehow influence the
EXAMPLE_FILESdictionary ("through configuration injection, database manipulation, or other means"). Here the input is a plain HTTP header on an unauthenticated request — no precondition beyond reaching the endpoint. - Different fix.
_validate_example_filename()validates a name. It cannot help withuser_id, which has already been concatenated intoupload_dirbefore that validation runs.
So this function has now had one of its two path components fixed while the other was left alone, twice: in May the name was hardened here, and in August the e0c741b patch hardened the same user_id pattern in the sibling file without coming back to this one.
Impact
An unauthenticated client can:
- Create directories at an arbitrary path and depth, anywhere the server process can write —
os.makedirs()runs on the already-traversed path. - Overwrite an existing file in a directory of their choosing —
shutil.copy2replaces the destination.
Not claimed: arbitrary content write, arbitrary file name, or remote code execution. The content is a fixed example bundled with the package, and of the four example_id values only two names can actually be written (Walmart_Sales.csv and the example PDF).
CVSS 3.1 AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N → 5.3. I:L rather than I:H reflects the constraint above; A:N because no availability effect was observed in the run, and none is claimed.
Suggested fix
Rather than reimplementing the validation, extract _resolve_user_id() and _resolve_upload_dir() from python_upload_api.py into a shared module and call them from both endpoints, so the next endpoint using this pattern inherits the validation instead of repeating the bug.
What you expected to happen
POST /api/v1/examples/use should reject a user-id header containing path separators or parent-directory segments — the same way POST /api/v1/python/file/upload already does since e0c741b — and the copy should stay inside <base_dir>/python_uploads/.
How to reproduce
Reproduced in an isolated python:3.11-slim container with --network none, running uvicorn and loading both routers verbatim from source with no modification (same /api prefix dbgpt_server.py uses).
- Put the repository on
PYTHONPATH, installfastapi uvicorn cachetools cloudpickle httpx python-multipart, and setcwdto a writable directory (this is whatbase_dirresolves to). - Mount the real
examples_apiandpython_upload_apirouters under/api. - Send, with no credentials of any kind:
curl -v -X POST 'http://127.0.0.1:5670/api/v1/examples/use' \
-H 'Content-Type: application/json' \
-H 'user-id: ../../../../tmp/dbgpt-traversal-poc' \
-d '{"example_id":"walmart_sales"}'Captured exchange (real socket, uvicorn, not an in-process test client):
> POST /api/v1/examples/use HTTP/1.1
> Host: 127.0.0.1:5670
> Content-Type: application/json
> user-id: ../../../../tmp/dbgpt-traversal-poc
> Content-Length: 30
>
{"example_id":"walmart_sales"}
< HTTP/1.1 200 OK
< server: uvicorn
< content-type: application/json
<
{"success":true,"err_code":null,"err_msg":null,"data":"/tmp/dbgpt-traversal-poc/Walmart_Sales.csv","host_name":"67d626a30f44"}The endpoint returns the absolute path itself, outside the uploads root, and the file is created there (/tmp/dbgpt-traversal-poc/Walmart_Sales.csv exists: yes).
Use
walmart_sales.create_sql_skillcannot be used:docker/examples/txt/sql_skill.txtis absent from the repository and from thehatch_build.pymap, so the handler returns before reaching this code.
- Overwrite: create
/tmp/victim-config/Walmart_Sales.csvwith known content, repeat withuser-id: ../../../../tmp/victim-config. Observed: 34 bytes → 357,297 bytes, original content replaced. - Directory creation: with
user-id: ../../../../tmp/a/b/c/d/e/f, the whole tree is created.
Negative controls.
- Same endpoint, benign header —
user-id: legituserlands inpython_uploads/legituser/, inside the root. The traversal is what escapes, not the endpoint in general. - Paired control — same process, same unauthenticated client, same traversed header; the only variable is which endpoint serves it:
| Endpoint | Carries e0c741b? |
Response |
|---|---|---|
POST /api/v1/python/file/upload |
yes | 400 Invalid user_id: only alphanumeric characters, underscores and hyphens are allowed |
POST /api/v1/examples/use |
no | 200, file written outside the root |
- Integrity check on that control — the 400 tracks the header value rather than being constant:
legituser→ 200, no header → 200,a/../b→ 400,/tmp/abs→ 400.
Additional context
The precondition is that the example file resolves — _resolve_example_source() runs before this code and returns early if the file is absent. It is met in a default installation in both distribution modes, with different strength of evidence per mode:
- source / docker — executed.
docker/examples/excel/Walmart_Sales.csvis in the repository and the run above resolved it. - wheel — read, not executed.
packages/dbgpt-app/hatch_build.pymaps that file todbgpt_app/_builtin_examples/excel/Walmart_Sales.csvat build time andpyproject.tomlforce-includes it in the sdist. This was verified by reading the build hook; nopip installwas performed, so for wheel installs treat this as source-verified rather than observed.
Checked and not reported: _extract_skill_from_zip() in agentic_data_api.py ends in shutil.rmtree() on a name derived from an uploaded archive, but it does reject traversal entries and archive_root cannot become absolute.
Happy to share the full runner and raw transcripts if useful, and to open a PR for the shared-helper refactor if the maintainers would prefer that over doing it in-house.
If you would rather this detail were not public while a fix is prepared, say so and I will edit it down — I posted here because the repository has no SECURITY.md and private vulnerability reporting is not enabled, and because #3104 was handled this way. Enabling GitHub's private vulnerability reporting would give future reports a non-public path.
Are you willing to submit PR?
- Yes I am willing to submit a PR!
Source: eosphoros-ai/DB-GPT