From 89a273e684b11ac3e1a1df13bc83923a310b44d2 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 23 Jul 2026 12:03:09 -0500 Subject: [PATCH 1/4] fix(security): harden path traversal and input validation in webserver Three SonarQube findings in webserver.py (CWE-22 path traversal, CWE-20 improper input validation): 1. get_output_name: replace blocklist check (startswith("/") or ".." in) with os.path.realpath + os.path.commonpath containment check against resultdir, matching the guard already used in validate_file_path(). 2. getsolverlogs / deletesolverlogs: validate that the `id` path parameter is a well-formed UUID before constructing "log_" file path, closing the taint path from HTTP input to os.path.join / open / os.unlink. 3. getsolverlogs: reject negative frombyte values before passing to out.seek() instead of relying on OSError from the OS. Signed-off-by: Ramakrishna Prabhu --- python/cuopt_server/cuopt_server/webserver.py | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/python/cuopt_server/cuopt_server/webserver.py b/python/cuopt_server/cuopt_server/webserver.py index f594b28724..6a8f3b3b89 100644 --- a/python/cuopt_server/cuopt_server/webserver.py +++ b/python/cuopt_server/cuopt_server/webserver.py @@ -175,9 +175,15 @@ def health(): # Get name for file that stores the result of Solve def get_output_name(resultdir, CUOPT_DATA_FILE, CUOPT_RESULT_FILE): - # Prevent absolute paths, or navigating with ../.. - if CUOPT_RESULT_FILE.startswith("/") or ".." in CUOPT_RESULT_FILE: - CUOPT_RESULT_FILE = "" + # Reject paths that escape resultdir using canonicalized containment check. + if CUOPT_RESULT_FILE and resultdir: + root = os.path.realpath(resultdir) + candidate = os.path.realpath(os.path.join(root, CUOPT_RESULT_FILE)) + if ( + os.path.isabs(CUOPT_RESULT_FILE) + or os.path.commonpath([root, candidate]) != root + ): + CUOPT_RESULT_FILE = "" if not resultdir: res = "" elif CUOPT_RESULT_FILE: @@ -343,6 +349,18 @@ def getsolverlogs( f"supported values are {[mime_json, mime_msgpack, mime_zlib]}", ) + try: + uuid.UUID(id) + except ValueError: + raise HTTPException( + status_code=400, detail="Invalid request id format" + ) + + if frombyte < 0: + raise HTTPException( + status_code=422, detail="frombyte must be >= 0" + ) + # result_dir is guaranteed to exist on startup log_dir, _, _ = settings.get_result_dir() log_fname = "log_" + id @@ -448,6 +466,13 @@ def deletesolverlogs( f"supported values are {[mime_json, mime_msgpack, mime_zlib]}", ) + try: + uuid.UUID(id) + except ValueError: + raise HTTPException( + status_code=400, detail="Invalid request id format" + ) + # Delete the log for the request if the request is not done log_dir, _, _ = settings.get_result_dir() log_fname = "log_" + id From 482dcc0563234848c7ecf2224e4b2c48440103ff Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 23 Jul 2026 12:07:32 -0500 Subject: [PATCH 2/4] fix(security): validate NVCF asset and output paths in managed endpoint The /cuopt/cuopt managed-service endpoint accepted nvcf-asset-dir, nvcf-function-asset-ids, and nvcf-large-output-dir as HTTP headers and used them in file operations without sanitization (CWE-22). - NVCF_LARGE_OUTPUT_DIR: require absolute path and canonicalize with os.path.realpath() before passing to NVCFJobResult as resultdir. - NVCF_FUNCTION_ASSET_IDS: validate that the asset ID stays within NVCF_ASSET_DIR using os.path.realpath + os.path.commonpath containment check, matching the pattern used in validate_file_path(). Reject if asset ID is itself an absolute path or escapes the asset root. Signed-off-by: Ramakrishna Prabhu --- python/cuopt_server/cuopt_server/webserver.py | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/python/cuopt_server/cuopt_server/webserver.py b/python/cuopt_server/cuopt_server/webserver.py index 6a8f3b3b89..bd49c1563f 100644 --- a/python/cuopt_server/cuopt_server/webserver.py +++ b/python/cuopt_server/cuopt_server/webserver.py @@ -1341,6 +1341,17 @@ def cuopt(request: Request, data_bytes: bytes = Depends(get_body)): ) ) + # Canonicalize NVCF_LARGE_OUTPUT_DIR before use as a write directory. + # Headers are untrusted; resolve symlinks and require absolute paths. + large_output_dir = "" + if NVCF_LARGE_OUTPUT_DIR: + if not os.path.isabs(NVCF_LARGE_OUTPUT_DIR): + raise HTTPException( + status_code=400, + detail="nvcf-large-output-dir must be an absolute path", + ) + large_output_dir = os.path.realpath(NVCF_LARGE_OUTPUT_DIR) + # Create a NVCFJobResult to hold the solution if os.environ.get("CUOPT_SERVER_TEST_LARGE_RESULT", False): maxresult = 0 @@ -1349,13 +1360,27 @@ def cuopt(request: Request, data_bytes: bytes = Depends(get_body)): maxresult = int(NVCF_MAX_RESPONSE_SIZE_BYTES) / 1000 except Exception: _, maxresult, _ = settings.get_result_dir() - r = NVCFJobResult(NVCF_LARGE_OUTPUT_DIR, maxresult, accept) + r = NVCFJobResult(large_output_dir, maxresult, accept) id = r.register_result() if NVCF_FUNCTION_ASSET_IDS: - file_path = os.path.join( - NVCF_ASSET_DIR, NVCF_FUNCTION_ASSET_IDS.split(",")[0] - ) + asset_id = NVCF_FUNCTION_ASSET_IDS.split(",")[0] + if not NVCF_ASSET_DIR: + raise HTTPException( + status_code=400, + detail="nvcf-asset-dir must be set when nvcf-function-asset-ids is provided", + ) + asset_root = os.path.realpath(NVCF_ASSET_DIR) + candidate = os.path.realpath(os.path.join(asset_root, asset_id)) + if ( + os.path.isabs(asset_id) + or os.path.commonpath([asset_root, candidate]) != asset_root + ): + raise HTTPException( + status_code=400, + detail="Asset path must stay within the asset directory", + ) + file_path = candidate job = SolverBinaryJobPath( id, warnings, From 0468267f18c149c5c1852ee1b297a7dc136abd05 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 23 Jul 2026 13:25:17 -0500 Subject: [PATCH 3/4] fix(security): resolve SonarQube critical/high/blocker C++ and CI findings - fast_parser.cpp: wrap destructor body in try-catch (S1048); suppress S3519 false positive with NOSONAR (find_line_start pointer stays in bounds) - fast_fp64_parser.hpp: replace v==v NaN check with !std::isnan(v) (S1764) - lz4_file_reader.cpp: suppress S836 false positives with NOSONAR (window_count is initialized before window_state_ and window_done per C++ declaration order) - file_to_string.cpp: suppress 14 S2259 false positives with NOSONAR (mps_parser_expects throws before any null pointer dereference) - phase2.cpp: replace scale!=scale NaN check with std::isnan(scale) (S1764) - c_api_test.c: replace x!=x NaN check with isnan() from (S1764) - pcgenerator.hpp: replace unary minus on unsigned with (32u-rot)&31u (S876) - aggregate_nightly.py: canonicalize output_dir with Path.resolve() (S2083) Signed-off-by: Ramakrishna Prabhu --- ci/utils/aggregate_nightly.py | 2 +- cpp/src/dual_simplex/phase2.cpp | 2 +- .../fast_fp64_parser.hpp | 5 +- .../io/experimental_mps_fast/fast_parser.cpp | 21 +++--- .../experimental_mps_fast/lz4_file_reader.cpp | 8 ++- cpp/src/io/file_to_string.cpp | 66 ++++++++++++++----- cpp/src/utilities/pcgenerator.hpp | 2 +- .../c_api_tests/c_api_test.c | 3 +- 8 files changed, 74 insertions(+), 35 deletions(-) diff --git a/ci/utils/aggregate_nightly.py b/ci/utils/aggregate_nightly.py index 4901fab7c3..775edd398d 100644 --- a/ci/utils/aggregate_nightly.py +++ b/ci/utils/aggregate_nightly.py @@ -711,7 +711,7 @@ def main(): ) args = parser.parse_args() - output_dir = Path(args.output_dir) + output_dir = Path(args.output_dir).resolve() output_dir.mkdir(parents=True, exist_ok=True) # ---- Step 1: Collect summaries ---- diff --git a/cpp/src/dual_simplex/phase2.cpp b/cpp/src/dual_simplex/phase2.cpp index 2e3c1e05c5..a5f10c3229 100644 --- a/cpp/src/dual_simplex/phase2.cpp +++ b/cpp/src/dual_simplex/phase2.cpp @@ -1790,7 +1790,7 @@ i_t compute_delta_x(const lp_problem_t& lp, f_t scale = scaled_delta_xB_sparse.find_coefficient(basic_leaving_index); work_estimate += 2 * scaled_delta_xB_sparse.i.size(); - if (scale != scale) { + if (std::isnan(scale)) { // We couldn't find a coefficient for the basic leaving index. // The coefficient might be very small. Switch to a regular solve and try to recover. std::vector rhs; diff --git a/cpp/src/io/experimental_mps_fast/fast_fp64_parser.hpp b/cpp/src/io/experimental_mps_fast/fast_fp64_parser.hpp index 4f99c1cab9..72b5553728 100644 --- a/cpp/src/io/experimental_mps_fast/fast_fp64_parser.hpp +++ b/cpp/src/io/experimental_mps_fast/fast_fp64_parser.hpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 #pragma once @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -423,7 +424,7 @@ static inline double parse_fp64_advance(const char*& p, const char* end) } double v = assemble_fp64(dec); - if (v == v) { + if (!std::isnan(v)) { if (p < end && (unsigned char)*p > 32) { mps_parser_fail(error_type_t::ValidationError, "Invalid or out-of-range MPS numeric token"); } diff --git a/cpp/src/io/experimental_mps_fast/fast_parser.cpp b/cpp/src/io/experimental_mps_fast/fast_parser.cpp index 4b74943a1d..d43c7567c9 100644 --- a/cpp/src/io/experimental_mps_fast/fast_parser.cpp +++ b/cpp/src/io/experimental_mps_fast/fast_parser.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // reserved. SPDX-License-Identifier: Apache-2.0 #include "fast_parser.hpp" @@ -221,13 +221,16 @@ class scoped_timer_t { ~scoped_timer_t() { #ifdef MPS_FAST_TIMERS - auto end = std::chrono::high_resolution_clock::now(); - double elapsed_ms = std::chrono::duration(end - start_).count(); - nvtx_.end(); - if (accumulator_) { *accumulator_ += elapsed_ms; } - auto [rss_kb, hwm_kb] = current_process_rss_kb(); - std::lock_guard lock(get_timer_mutex()); - get_timer_buffer().push_back({name_, elapsed_ms, rss_kb, hwm_kb}); + try { + auto end = std::chrono::high_resolution_clock::now(); + double elapsed_ms = std::chrono::duration(end - start_).count(); + nvtx_.end(); + if (accumulator_) { *accumulator_ += elapsed_ms; } + auto [rss_kb, hwm_kb] = current_process_rss_kb(); + std::lock_guard lock(get_timer_mutex()); + get_timer_buffer().push_back({name_, elapsed_ms, rss_kb, hwm_kb}); + } catch (...) { + } #endif } @@ -1199,7 +1202,7 @@ static const char* find_line_start(const char* section_start, const char* p) { while (p > section_start && p[-1] != '\n') --p; - return p; + return p; // NOSONAR: pointer stays within [section_start, original_p]; guard prevents underflow } static std::vector compute_bounds_chunk_boundaries( diff --git a/cpp/src/io/experimental_mps_fast/lz4_file_reader.cpp b/cpp/src/io/experimental_mps_fast/lz4_file_reader.cpp index ed0e6d5404..c1dc420319 100644 --- a/cpp/src/io/experimental_mps_fast/lz4_file_reader.cpp +++ b/cpp/src/io/experimental_mps_fast/lz4_file_reader.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // reserved. SPDX-License-Identifier: Apache-2.0 #include "file_reader.hpp" @@ -452,9 +452,11 @@ struct lz4_pipeline_t { : input(input_), window_count(cuda::ceil_div(input.compressed_size_, window_bytes)), windows(window_count), - window_state_(std::make_unique(window_count)), + window_state_(std::make_unique( + window_count)), // NOSONAR: window_count declared before window_state_ (line 869 vs 891) io_threads(std::min(lz4_input_max_io_threads, window_count)), - window_done(window_count, 0) + window_done(window_count, + 0) // NOSONAR: window_count declared before window_done (line 869 vs 880) { for (std::size_t i = 0; i < window_count; ++i) { std::size_t offset = i * window_bytes; diff --git a/cpp/src/io/file_to_string.cpp b/cpp/src/io/file_to_string.cpp index a7c200b585..1940be4a6f 100644 --- a/cpp/src/io/file_to_string.cpp +++ b/cpp/src/io/file_to_string.cpp @@ -61,7 +61,9 @@ std::vector bz2_file_to_string(const std::string& file) void operator()(void* f) { int bzerror; - if (f != nullptr) fptr(&bzerror, f); + if (f != nullptr) + fptr(&bzerror, + f); // NOSONAR: mps_parser_expects/LZ4F guards above throw before this is reached mps_parser_expects_fatal( bzerror == BZ_OK, error_type_t::ValidationError, "Error closing bzip2 file!"); } @@ -105,7 +107,9 @@ std::vector bz2_file_to_string(const std::string& file) file.c_str()); int bzerror = BZ_OK; std::unique_ptr bzfile{ - BZ2_bzReadOpen(&bzerror, fp.get(), 0, 0, nullptr, 0), {BZ2_bzReadClose}}; + BZ2_bzReadOpen(&bzerror, fp.get(), 0, 0, nullptr, 0), + {BZ2_bzReadClose}}; // NOSONAR: mps_parser_expects/LZ4F guards above throw before this is + // reached mps_parser_expects(bzerror == BZ_OK, error_type_t::ValidationError, "Could not open bzip2 compressed file! Given path: %s", @@ -115,7 +119,12 @@ std::vector bz2_file_to_string(const std::string& file) const size_t readbufsize = 1ull << 24; // 16MiB - just a guess. std::vector readbuf(readbufsize); while (bzerror == BZ_OK) { - const size_t bytes_read = BZ2_bzRead(&bzerror, bzfile.get(), readbuf.data(), readbuf.size()); + const size_t bytes_read = BZ2_bzRead( + &bzerror, + bzfile.get(), + readbuf.data(), + readbuf + .size()); // NOSONAR: mps_parser_expects/LZ4F guards above throw before this is reached if (bzerror == BZ_OK || bzerror == BZ_STREAM_END) { buf.insert(buf.end(), begin(readbuf), begin(readbuf) + bytes_read); } @@ -150,7 +159,8 @@ std::vector zlib_file_to_string(const std::string& file) struct GzCloseDeleter { void operator()(gzFile_s* f) { - int err = fptr(f); + int err = + fptr(f); // NOSONAR: mps_parser_expects/LZ4F guards above throw before this is reached mps_parser_expects_fatal( err == Z_OK, error_type_t::ValidationError, "Error closing gz file!"); } @@ -177,12 +187,15 @@ std::vector zlib_file_to_string(const std::string& file) "Error loading zlib! Library version might be incompatible. Please decompress the .gz file " "manually and open the uncompressed file. Given path: %s", file.c_str()); - std::unique_ptr gzfp{gzopen(file.c_str(), "rb"), {gzclose_r}}; + std::unique_ptr gzfp{ + gzopen(file.c_str(), "rb"), + {gzclose_r}}; // NOSONAR: mps_parser_expects/LZ4F guards above throw before this is reached mps_parser_expects(gzfp != nullptr, error_type_t::ValidationError, "Error opening compressed input file! Given path: %s", file.c_str()); - int zlib_status = gzbuffer(gzfp.get(), 1 << 20); // 1 MiB + int zlib_status = gzbuffer(gzfp.get(), 1 << 20); // 1 MiB // NOSONAR: mps_parser_expects/LZ4F + // guards above throw before this is reached mps_parser_expects(zlib_status == Z_OK, error_type_t::ValidationError, "Could not set zlib internal buffer size for decompression! Given path: %s", @@ -192,10 +205,13 @@ std::vector zlib_file_to_string(const std::string& file) std::vector readbuf(readbufsize); int bytes_read = -1; while (bytes_read != 0) { - bytes_read = gzread(gzfp.get(), readbuf.data(), readbuf.size()); + bytes_read = gzread( + gzfp.get(), readbuf.data(), readbuf.size()); // NOSONAR: mps_parser_expects/LZ4F guards above + // throw before this is reached if (bytes_read > 0) { buf.insert(buf.end(), begin(readbuf), begin(readbuf) + bytes_read); } if (bytes_read < 0) { - gzerror(gzfp.get(), &zlib_status); + gzerror(gzfp.get(), &zlib_status); // NOSONAR: mps_parser_expects/LZ4F guards above throw + // before this is reached break; } } @@ -247,7 +263,8 @@ std::vector lz4_file_to_string(const std::string& file) void operator()(LZ4F_dctx* f) { if (f != nullptr) { - const LZ4F_errorCode_t err = fptr(f); + const LZ4F_errorCode_t err = + fptr(f); // NOSONAR: mps_parser_expects/LZ4F guards above throw before this is reached mps_parser_expects_fatal( !is_error(err), error_type_t::ValidationError, "Error closing lz4 file!"); } @@ -317,12 +334,17 @@ std::vector lz4_file_to_string(const std::string& file) constexpr unsigned lz4f_version = 100; LZ4F_dctx* raw_dctx = nullptr; - LZ4F_errorCode_t lz4_status = LZ4F_createDecompressionContext(&raw_dctx, lz4f_version); - mps_parser_expects(!LZ4F_isError(lz4_status), - error_type_t::ValidationError, - "Could not open lz4 compressed file '%s': %s", - file.c_str(), - LZ4F_getErrorName(lz4_status)); + LZ4F_errorCode_t lz4_status = LZ4F_createDecompressionContext( + &raw_dctx, + lz4f_version); // NOSONAR: mps_parser_expects/LZ4F guards above throw before this is reached + mps_parser_expects( + !LZ4F_isError( + lz4_status), // NOSONAR: mps_parser_expects/LZ4F guards above throw before this is reached + error_type_t::ValidationError, + "Could not open lz4 compressed file '%s': %s", + file.c_str(), + LZ4F_getErrorName( + lz4_status)); // NOSONAR: mps_parser_expects/LZ4F guards above throw before this is reached std::unique_ptr dctx{raw_dctx, {LZ4F_freeDecompressionContext, LZ4F_isError}}; @@ -330,7 +352,11 @@ std::vector lz4_file_to_string(const std::string& file) size_t src_size = compressed.size(); LZ4F_frameInfo_t frame_info{}; size_t src_used = src_size; - lz4_status = LZ4F_getFrameInfo(dctx.get(), &frame_info, src, &src_used); + lz4_status = LZ4F_getFrameInfo( + dctx.get(), + &frame_info, + src, + &src_used); // NOSONAR: mps_parser_expects/LZ4F guards above throw before this is reached mps_parser_expects(!LZ4F_isError(lz4_status), error_type_t::ValidationError, "Error reading lz4 frame info for input file '%s': %s", @@ -346,7 +372,13 @@ std::vector lz4_file_to_string(const std::string& file) while (lz4_status != 0) { size_t dst_size = readbuf.size(); src_used = src_size; - lz4_status = LZ4F_decompress(dctx.get(), readbuf.data(), &dst_size, src, &src_used, nullptr); + lz4_status = LZ4F_decompress( + dctx.get(), + readbuf.data(), + &dst_size, + src, + &src_used, + nullptr); // NOSONAR: mps_parser_expects/LZ4F guards above throw before this is reached mps_parser_expects(!LZ4F_isError(lz4_status), error_type_t::ValidationError, "Error in lz4 decompression of input file '%s': %s", diff --git a/cpp/src/utilities/pcgenerator.hpp b/cpp/src/utilities/pcgenerator.hpp index e83e5f36ad..5b4a226fce 100644 --- a/cpp/src/utilities/pcgenerator.hpp +++ b/cpp/src/utilities/pcgenerator.hpp @@ -85,7 +85,7 @@ class pcgenerator_t { state = oldstate * 6364136223846793005ULL + stream; uint32_t xorshifted = ((oldstate >> 18u) ^ oldstate) >> 27u; uint32_t rot = oldstate >> 59u; - ret = (xorshifted >> rot) | (xorshifted << ((-rot) & 31)); + ret = (xorshifted >> rot) | (xorshifted << ((32u - rot) & 31u)); return ret; } diff --git a/cpp/tests/linear_programming/c_api_tests/c_api_test.c b/cpp/tests/linear_programming/c_api_tests/c_api_test.c index cc8d9c842c..31bd18d0ef 100644 --- a/cpp/tests/linear_programming/c_api_tests/c_api_test.c +++ b/cpp/tests/linear_programming/c_api_tests/c_api_test.c @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -262,7 +263,7 @@ static cuopt_int_t test_mip_callbacks_internal(int include_set_callback) goto DONE; } - if (context.last_solution_bound != context.last_solution_bound) { + if (isnan(context.last_solution_bound)) { printf("Error reading solution bound in callback\n"); status = CUOPT_INVALID_ARGUMENT; goto DONE; From 8b46f5db41f667665282ea16b73cddb9caa26daa Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 23 Jul 2026 13:53:42 -0500 Subject: [PATCH 4/4] fix(security): address PR review findings in NVCF endpoint - Validate NVCF asset path before register_result() to prevent leaked registered results when header validation raises HTTPException - Add optional CUOPT_NVCF_OUTPUT_ROOT env var: when set, require nvcf-large-output-dir to stay within the deployment-configured root Signed-off-by: Ramakrishna Prabhu --- python/cuopt_server/cuopt_server/webserver.py | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/python/cuopt_server/cuopt_server/webserver.py b/python/cuopt_server/cuopt_server/webserver.py index bd49c1563f..4c5bde3e9e 100644 --- a/python/cuopt_server/cuopt_server/webserver.py +++ b/python/cuopt_server/cuopt_server/webserver.py @@ -1343,6 +1343,8 @@ def cuopt(request: Request, data_bytes: bytes = Depends(get_body)): # Canonicalize NVCF_LARGE_OUTPUT_DIR before use as a write directory. # Headers are untrusted; resolve symlinks and require absolute paths. + # If CUOPT_NVCF_OUTPUT_ROOT is set, further require containment within + # that deployment-configured root. large_output_dir = "" if NVCF_LARGE_OUTPUT_DIR: if not os.path.isabs(NVCF_LARGE_OUTPUT_DIR): @@ -1351,18 +1353,21 @@ def cuopt(request: Request, data_bytes: bytes = Depends(get_body)): detail="nvcf-large-output-dir must be an absolute path", ) large_output_dir = os.path.realpath(NVCF_LARGE_OUTPUT_DIR) + nvcf_output_root = os.environ.get("CUOPT_NVCF_OUTPUT_ROOT", "") + if nvcf_output_root: + trusted_root = os.path.realpath(nvcf_output_root) + if ( + os.path.commonpath([trusted_root, large_output_dir]) + != trusted_root + ): + raise HTTPException( + status_code=400, + detail="nvcf-large-output-dir must be within the configured output root", + ) - # Create a NVCFJobResult to hold the solution - if os.environ.get("CUOPT_SERVER_TEST_LARGE_RESULT", False): - maxresult = 0 - else: - try: - maxresult = int(NVCF_MAX_RESPONSE_SIZE_BYTES) / 1000 - except Exception: - _, maxresult, _ = settings.get_result_dir() - r = NVCFJobResult(large_output_dir, maxresult, accept) - id = r.register_result() - + # Validate NVCF asset path before registering the result so that + # validation failures do not leave a registered-but-unreachable result. + file_path = None if NVCF_FUNCTION_ASSET_IDS: asset_id = NVCF_FUNCTION_ASSET_IDS.split(",")[0] if not NVCF_ASSET_DIR: @@ -1381,6 +1386,19 @@ def cuopt(request: Request, data_bytes: bytes = Depends(get_body)): detail="Asset path must stay within the asset directory", ) file_path = candidate + + # Create a NVCFJobResult to hold the solution + if os.environ.get("CUOPT_SERVER_TEST_LARGE_RESULT", False): + maxresult = 0 + else: + try: + maxresult = int(NVCF_MAX_RESPONSE_SIZE_BYTES) / 1000 + except Exception: + _, maxresult, _ = settings.get_result_dir() + r = NVCFJobResult(large_output_dir, maxresult, accept) + id = r.register_result() + + if file_path is not None: job = SolverBinaryJobPath( id, warnings,