#3232·DB-GPT

[Bug] Incomplete fix for CVE-2026-73034: unvalidated `user-id` path component survives in /api/v1/examples/use

Author: MAXI8594Created Sep 10, 2026Updated Sep 10, 2026

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 onlypython_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):

python
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 directory

Those 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):

python
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 directory

Those 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_FILES dictionary ("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 with user_id, which has already been concatenated into upload_dir before 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:

  1. Create directories at an arbitrary path and depth, anywhere the server process can write — os.makedirs() runs on the already-traversed path.
  2. Overwrite an existing file in a directory of their choosingshutil.copy2 replaces 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:N5.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).

  1. Put the repository on PYTHONPATH, install fastapi uvicorn cachetools cloudpickle httpx python-multipart, and set cwd to a writable directory (this is what base_dir resolves to).
  2. Mount the real examples_api and python_upload_api routers under /api.
  3. Send, with no credentials of any kind:
bash
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):

http
> 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_skill cannot be used: docker/examples/txt/sql_skill.txt is absent from the repository and from the hatch_build.py map, so the handler returns before reaching this code.

  1. Overwrite: create /tmp/victim-config/Walmart_Sales.csv with known content, repeat with user-id: ../../../../tmp/victim-config. Observed: 34 bytes → 357,297 bytes, original content replaced.
  2. Directory creation: with user-id: ../../../../tmp/a/b/c/d/e/f, the whole tree is created.

Negative controls.

  • Same endpoint, benign headeruser-id: legituser lands in python_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.csv is in the repository and the run above resolved it.
  • wheel — read, not executed. packages/dbgpt-app/hatch_build.py maps that file to dbgpt_app/_builtin_examples/excel/Walmart_Sales.csv at build time and pyproject.toml force-includes it in the sdist. This was verified by reading the build hook; no pip install was 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!