From 8a37c6855b433a518b6776493b26316b73ecd78d Mon Sep 17 00:00:00 2001 From: jannisd07 Date: Mon, 2 Nov 2026 00:31:56 +0100 Subject: [PATCH] fix: prevent path traversal in datasource importToDb (CWE-22) --- backend/apps/datasource/api/datasource.py | 10 +- backend/locales/en.json | 1 + backend/locales/ko-KR.json | 1 + backend/locales/th-TH.json | 1 + backend/locales/zh-CN.json | 1 + backend/locales/zh-TW.json | 1 + .../test_datasource_import_path_traversal.py | 225 ++++++++++++++++++ 7 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_datasource_import_path_traversal.py diff --git a/backend/apps/datasource/api/datasource.py b/backend/apps/datasource/api/datasource.py index a5221726..4949b373 100644 --- a/backend/apps/datasource/api/datasource.py +++ b/backend/apps/datasource/api/datasource.py @@ -580,7 +580,15 @@ def inner(): @router.post("/importToDb", response_model=None, summary=f"{PLACEHOLDER_PREFIX}ds_import_to_db") @require_permissions(permission=SqlbotPermission(role=['ws_admin'])) async def import_to_db(session: SessionDep, trans: Trans, import_req: ImportRequest): - save_path = os.path.join(path, import_req.filePath) + # filePath comes from the client; it must resolve to a file inside the + # upload directory to prevent path traversal (CWE-22). + base_dir = os.path.realpath(path) + try: + save_path = os.path.realpath(os.path.join(base_dir, import_req.filePath)) + except ValueError: # e.g. an embedded null byte + raise HTTPException(400, trans('i18n_ds_invalid_file_path')) + if save_path == base_dir or os.path.commonpath([save_path, base_dir]) != base_dir: + raise HTTPException(400, trans('i18n_ds_invalid_file_path')) if not os.path.exists(save_path): raise HTTPException(400, "File not found") diff --git a/backend/locales/en.json b/backend/locales/en.json index 972a0ae0..69e90986 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -54,6 +54,7 @@ "i18n_ds_invalid": "Datasource connection is invalid", "i18n_ds_unsupported_file_format": "Unsupported file format. Please upload an XLS, XLSX or CSV file.", "i18n_ds_upload_error": "Upload Failed", + "i18n_ds_invalid_file_path": "Invalid file path", "i18n_embedded": { "invalid_origin": "Domain name validation failed【{origin}】", "missing_sign": "Missing embedded signature (SQLBOT-EMBEDDED-SIGN); cannot verify the embedding party's identity.", diff --git a/backend/locales/ko-KR.json b/backend/locales/ko-KR.json index bfe56af0..832c15ab 100644 --- a/backend/locales/ko-KR.json +++ b/backend/locales/ko-KR.json @@ -54,6 +54,7 @@ "i18n_ds_invalid": "데이터 소스 연결이 무효합니다", "i18n_ds_unsupported_file_format": "지원하지 않는 파일 형식입니다. XLS, XLSX 또는 CSV 파일을 업로드해 주세요.", "i18n_ds_upload_error": "파일 업로드 실패", + "i18n_ds_invalid_file_path": "잘못된 파일 경로입니다", "i18n_embedded": { "invalid_origin": "도메인 이름 검증 실패 【{origin}】", "missing_sign": "임베드 서명(SQLBOT-EMBEDDED-SIGN)이 누락되어 임베드 당사자의 신원을 확인할 수 없습니다.", diff --git a/backend/locales/th-TH.json b/backend/locales/th-TH.json index f8dada33..9aa8b104 100644 --- a/backend/locales/th-TH.json +++ b/backend/locales/th-TH.json @@ -54,6 +54,7 @@ "i18n_ds_invalid": "การเชื่อมต่อแหล่งข้อมูลไม่ถูกต้อง", "i18n_ds_unsupported_file_format": "ไม่รองรับรูปแบบไฟล์ โปรดอัปโหลดไฟล์ XLS, XLSX หรือ CSV", "i18n_ds_upload_error": "อัปโหลดไฟล์ล้มเหลว", + "i18n_ds_invalid_file_path": "เส้นทางไฟล์ไม่ถูกต้อง", "i18n_embedded": { "invalid_origin": "ตรวจสอบโดเมนไม่สำเร็จ【{origin}】", "missing_sign": "ขาดลายเซ็นการฝัง (SQLBOT-EMBEDDED-SIGN) จึงไม่สามารถยืนยันตัวตนผู้ฝังได้", diff --git a/backend/locales/zh-CN.json b/backend/locales/zh-CN.json index 093ecd3e..c99c1120 100644 --- a/backend/locales/zh-CN.json +++ b/backend/locales/zh-CN.json @@ -54,6 +54,7 @@ "i18n_ds_invalid": "数据源连接无效", "i18n_ds_unsupported_file_format": "文件格式不支持,请上传 XLS、XLSX 或 CSV 格式的文件。", "i18n_ds_upload_error": "上传文件失败", + "i18n_ds_invalid_file_path": "文件路径无效", "i18n_embedded": { "invalid_origin": "域名校验失败【{origin}】", "missing_sign": "缺少嵌入签名(SQLBOT-EMBEDDED-SIGN),无法验证嵌入方身份。", diff --git a/backend/locales/zh-TW.json b/backend/locales/zh-TW.json index 8e8bec18..315e6f49 100644 --- a/backend/locales/zh-TW.json +++ b/backend/locales/zh-TW.json @@ -54,6 +54,7 @@ "i18n_ds_invalid": "數據源連接無效", "i18n_ds_unsupported_file_format": "檔案格式不支援,請上傳 XLS、XLSX 或 CSV 格式的檔案。", "i18n_ds_upload_error": "上傳文件失敗", + "i18n_ds_invalid_file_path": "檔案路徑無效", "i18n_embedded": { "invalid_origin": "網域校驗失敗【{origin}】", "missing_sign": "缺少嵌入簽名(SQLBOT-EMBEDDED-SIGN),無法驗證嵌入方身分。", diff --git a/backend/tests/test_datasource_import_path_traversal.py b/backend/tests/test_datasource_import_path_traversal.py new file mode 100644 index 00000000..49393df1 --- /dev/null +++ b/backend/tests/test_datasource_import_path_traversal.py @@ -0,0 +1,225 @@ +"""Offline HTTP regressions for path traversal in /datasource/importToDb (CWE-22). + +Identity, audit persistence, database sessions and the import engine are test +adapters. No application startup, external database or deployed service is used. +""" + +import os +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +import sqlbot_xpack # noqa: F401 # Initialize the published wheel before API imports. +from fastapi import FastAPI +from fastapi.testclient import TestClient +from starlette.exceptions import HTTPException as StarletteHTTPException + +from apps.datasource.api import datasource +from apps.system.schemas.permission import RequestContextMiddleware +from common.audit.schemas.logger_decorator import SystemLogger +from common.audit.schemas.request_context import RequestContextMiddlewareCommon +from common.core.db import get_session +from common.core.deps import get_i18n +from common.core.response_middleware import ResponseMiddleware, exception_handler + +IMPORT_URL = "/datasource/importToDb" +SHEETS = [ + {"sheetName": "Sheet1", "fields": [{"fieldName": "a", "fieldType": "string"}]} +] +# get_i18n is overridden to return the key itself. +INVALID_FILE_PATH = "i18n_ds_invalid_file_path" + + +@pytest.fixture +def api(monkeypatch, tmp_path): + upload_dir = tmp_path / "excel" + upload_dir.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + secret = outside / "secret.csv" + secret.write_text("a\nprivate data\n") + # Sibling whose name starts with the upload directory name. + sibling = tmp_path / "excel_backup" + sibling.mkdir() + (sibling / "secret.csv").write_text("a\nprivate data\n") + + app = FastAPI() + app.include_router(datasource.router) + app.add_exception_handler( + StarletteHTTPException, exception_handler.http_exception_handler + ) + app.add_exception_handler(Exception, exception_handler.global_exception_handler) + app.add_middleware(ResponseMiddleware) + app.add_middleware(RequestContextMiddleware) + app.add_middleware(RequestContextMiddlewareCommon) + + @app.middleware("http") + async def identity(request, call_next): + request.state.current_user = SimpleNamespace( + isAdmin=True, weight=1, id=1, oid=1, account="test", name="test" + ) + return await call_next(request) + + session = Mock() + app.dependency_overrides[get_session] = lambda: session + app.dependency_overrides[get_i18n] = lambda: lambda key: key + monkeypatch.setattr(SystemLogger, "create_log_record", AsyncMock()) + monkeypatch.setattr(datasource, "path", str(upload_dir)) + monkeypatch.setattr(datasource, "get_engine_conn", Mock()) + reader = Mock(side_effect=RuntimeError("reader reached")) + monkeypatch.setattr(datasource, "read_import_dataframe", reader) + # ResponseMiddleware tracks live instances globally in the application. + monkeypatch.setattr(ResponseMiddleware, "instances", []) + with TestClient(app, raise_server_exceptions=False) as client: + yield SimpleNamespace( + client=client, + upload_dir=upload_dir, + outside=outside, + secret=secret, + reader=reader, + tmp_path=tmp_path, + monkeypatch=monkeypatch, + ) + + +def post(api, file_path, sheets=SHEETS): + return api.client.post(IMPORT_URL, json={"filePath": file_path, "sheets": sheets}) + + +def assert_rejected(api, response): + assert response.status_code == 400, response.text + assert response.json() == INVALID_FILE_PATH + api.reader.assert_not_called() + + +def test_absolute_file_path_is_rejected(api): + response = api.client.post( + IMPORT_URL, json={"filePath": str(api.secret), "sheets": SHEETS} + ) + assert response.status_code == 400, response.text + assert response.json() == INVALID_FILE_PATH + api.reader.assert_not_called() + + +@pytest.mark.parametrize( + "file_path", + [ + "../outside/secret.csv", + "./../outside/secret.csv", + "nested/../../outside/secret.csv", + ], +) +def test_relative_traversal_is_rejected(api, file_path): + response = api.client.post( + IMPORT_URL, json={"filePath": file_path, "sheets": SHEETS} + ) + assert response.status_code == 400, response.text + assert response.json() == INVALID_FILE_PATH + api.reader.assert_not_called() + + +def test_symlink_leaving_upload_directory_is_rejected(api): + link = api.upload_dir / "link.csv" + os.symlink(api.secret, link) + response = api.client.post( + IMPORT_URL, json={"filePath": link.name, "sheets": SHEETS} + ) + assert response.status_code == 400, response.text + assert response.json() == INVALID_FILE_PATH + api.reader.assert_not_called() + + +@pytest.mark.parametrize("file_path", ["", "."]) +def test_upload_directory_itself_is_rejected(api, file_path): + response = api.client.post( + IMPORT_URL, json={"filePath": file_path, "sheets": SHEETS} + ) + assert response.status_code == 400, response.text + assert response.json() == INVALID_FILE_PATH + api.reader.assert_not_called() + + +def test_missing_file_in_upload_directory_returns_400(api): + response = api.client.post( + IMPORT_URL, json={"filePath": "missing_0123456789.csv", "sheets": SHEETS} + ) + assert response.status_code == 400, response.text + assert response.json() == "File not found" + api.reader.assert_not_called() + + +def test_uploaded_file_name_is_accepted(api): + uploaded = api.upload_dir / "orders_0123456789.csv" + uploaded.write_text("a\n1\n") + response = api.client.post( + IMPORT_URL, json={"filePath": uploaded.name, "sheets": []} + ) + assert response.status_code == 200, response.text + assert response.json()["data"] == {"filename": uploaded.name, "sheets": []} + + +def test_uploaded_file_is_read_from_upload_directory(api): + uploaded = api.upload_dir / "orders_0123456789.csv" + uploaded.write_text("a\n1\n") + response = api.client.post( + IMPORT_URL, json={"filePath": uploaded.name, "sheets": SHEETS} + ) + # The stub reader stops the import after the path check has passed. + assert response.status_code == 500, response.text + api.reader.assert_called_once() + assert api.reader.call_args.args[0] == os.path.realpath(uploaded) + + +@pytest.mark.parametrize( + "file_path", + ["../excel_backup/secret.csv", "../excel_backup", "..", "nested/.."], +) +def test_sibling_with_same_prefix_and_parent_are_rejected(api, file_path): + # A plain string prefix check (startswith/commonprefix) accepts these. + assert_rejected(api, post(api, file_path)) + + +@pytest.mark.parametrize( + "file_path", + ["../outside/does_not_exist.csv", "/nonexistent_0123456789/x.csv"], +) +def test_missing_file_outside_is_rejected_before_existence_check(api, file_path): + # Answering "File not found" here would reveal which paths exist outside. + assert_rejected(api, post(api, file_path)) + + +def test_directory_symlink_leaving_upload_directory_is_rejected(api): + os.symlink(api.outside, api.upload_dir / "sub") + assert_rejected(api, post(api, "sub/secret.csv")) + + +def test_symlink_inside_upload_directory_is_accepted(api): + target = api.upload_dir / "orders_0123456789.csv" + target.write_text("a\n1\n") + os.symlink(target, api.upload_dir / "alias.csv") + response = post(api, "alias.csv", sheets=[]) + assert response.status_code == 200, response.text + + +def test_upload_directory_behind_symlink_still_accepts_uploads(api): + # EXCEL_PATH may itself contain a symlink (e.g. /var -> /private/var on + # macOS or a mounted volume). Both sides of the comparison must be resolved. + link = api.tmp_path / "excel_link" + os.symlink(api.upload_dir, link) + api.monkeypatch.setattr(datasource, "path", str(link)) + uploaded = api.upload_dir / "orders_0123456789.csv" + uploaded.write_text("a\n1\n") + + accepted = post(api, uploaded.name, sheets=[]) + assert accepted.status_code == 200, accepted.text + + post(api, uploaded.name) + api.reader.assert_called_once() + assert api.reader.call_args.args[0] == os.path.realpath(uploaded) + + api.reader.reset_mock() + assert_rejected(api, post(api, "../outside/secret.csv")) + + +def test_null_byte_in_file_path_is_rejected(api): + assert_rejected(api, post(api, "secret.csv\x00"))