diff --git a/python/cuopt_server/cuopt_server/tests/test_file_path_validation.py b/python/cuopt_server/cuopt_server/tests/test_file_path_validation.py new file mode 100644 index 0000000000..0805c83322 --- /dev/null +++ b/python/cuopt_server/cuopt_server/tests/test_file_path_validation.py @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import os + +import pytest +from fastapi import HTTPException + +from cuopt_server import webserver +from cuopt_server.utils import settings + + +@pytest.fixture(autouse=True) +def restore_data_dir(): + original_data_dir = settings.get_data_dir() + try: + yield + finally: + settings.set_data_dir(original_data_dir) + + +def test_validate_file_path_returns_file_if_relative_path_stays_in_data_dir( + tmp_path, +): + data_dir = tmp_path / "data" + data_dir.mkdir() + data_file = data_dir / "input.json" + data_file.write_text("{}", encoding="utf-8") + settings.set_data_dir(str(data_dir)) + + assert webserver.validate_file_path("input.json") == str(data_file) + + +def test_validate_file_path_rejects_absolute_path(tmp_path): + data_dir = tmp_path / "data" + data_dir.mkdir() + outside_file = tmp_path / "input.json" + outside_file.write_text("{}", encoding="utf-8") + settings.set_data_dir(str(data_dir)) + + with pytest.raises(HTTPException) as exc_info: + webserver.validate_file_path(str(outside_file)) + + assert exc_info.value.status_code == 400 + assert "relative to CUOPT_DATA_DIR" in exc_info.value.detail + + +def test_validate_file_path_rejects_parent_directory_escape(tmp_path): + data_dir = tmp_path / "data" + data_dir.mkdir() + outside_file = tmp_path / "input.json" + outside_file.write_text("{}", encoding="utf-8") + settings.set_data_dir(str(data_dir)) + + with pytest.raises(HTTPException) as exc_info: + webserver.validate_file_path("../input.json") + + assert exc_info.value.status_code == 400 + assert "stay inside CUOPT_DATA_DIR" in exc_info.value.detail + + +def test_validate_file_path_rejects_symlink_escape(tmp_path): + data_dir = tmp_path / "data" + data_dir.mkdir() + outside_file = tmp_path / "input.json" + outside_file.write_text("{}", encoding="utf-8") + os.symlink(outside_file, data_dir / "linked-input.json") + settings.set_data_dir(str(data_dir)) + + with pytest.raises(HTTPException) as exc_info: + webserver.validate_file_path("linked-input.json") + + assert exc_info.value.status_code == 400 + assert "stay inside CUOPT_DATA_DIR" in exc_info.value.detail diff --git a/python/cuopt_server/cuopt_server/webserver.py b/python/cuopt_server/cuopt_server/webserver.py index dd8da0b04c..dc2775e46d 100644 --- a/python/cuopt_server/cuopt_server/webserver.py +++ b/python/cuopt_server/cuopt_server/webserver.py @@ -190,36 +190,36 @@ def get_output_name(resultdir, CUOPT_DATA_FILE, CUOPT_RESULT_FILE): # Validate if given data file and file path exists -def validate_file_path(CUOPT_DATA_FILE): +def validate_file_path(cuopt_data_file): ddir = settings.get_data_dir() - try: - file_path = os.path.join(ddir, CUOPT_DATA_FILE) - if not ddir: - logging.error("cuopt data directory not set!") - # If no datadir was set but the path is relative, - # this can't work - if not CUOPT_DATA_FILE.startswith("/"): - raise ValueError( - f"cuopt server was started without data directory " - f"defined but local path {CUOPT_DATA_FILE} " - "was specified" - ) - if not os.path.exists(file_path): - logging.error(f"File path '{file_path}' doesn't exist") - msg = f"Specified path '{file_path}' does not exist" - if CUOPT_DATA_FILE.startswith("/"): - dir = os.path.dirname(CUOPT_DATA_FILE) - if not os.path.isdir(dir): - msg += f". Absolute path '{dir}' does not exist" - msg += ". Perhaps you did not intend to " - "specify an absolute path?" - raise ValueError(msg) - except Exception as e: + if not ddir: + logging.error("cuopt data directory not set!") + raise HTTPException( + status_code=400, + detail="cuopt data directory not set", + ) + + if os.path.isabs(cuopt_data_file): + raise HTTPException( + status_code=400, + detail="cuopt-data-file must be relative to CUOPT_DATA_DIR", + ) + + root = os.path.realpath(ddir) + file_path = os.path.realpath(os.path.join(root, cuopt_data_file)) + if os.path.commonpath([root, file_path]) != root: + raise HTTPException( + status_code=400, + detail="cuopt-data-file must stay inside CUOPT_DATA_DIR", + ) + + if not os.path.isfile(file_path): + logging.error(f"File path '{file_path}' doesn't exist") raise HTTPException( status_code=400, - detail="unable to read " - "optimization data from file %s, %s" % (file_path, str(e)), + detail=f"specified data file does not exist: {cuopt_data_file}", ) + return file_path