diff --git a/.bazelrc b/.bazelrc index 71f054bdfb..58e2112ec2 100644 --- a/.bazelrc +++ b/.bazelrc @@ -264,6 +264,7 @@ test:linux --test_env LD_LIBRARY_PATH=/opt/opencv/lib/:/opt/intel/openvino/runti test:linux --test_env OPENVINO_TOKENIZERS_PATH_GENAI=/opt/intel/openvino/runtime/lib/intel64/libopenvino_tokenizers.so test:linux --test_env PYTHONPATH=/opt/intel/openvino/python:/ovms/bazel-bin/src/python/binding test:linux --test_env no_proxy=localhost +test:linux --test_env "OVMS_MEDIA_URL_ALLOW_REDIRECTS=1" # Bazelrc imports ############################################################################################################################ # file below should contain sth like diff --git a/demos/continuous_batching/vlm/README.md b/demos/continuous_batching/vlm/README.md index 8fb9348cdc..2a391f20dc 100644 --- a/demos/continuous_batching/vlm/README.md +++ b/demos/continuous_batching/vlm/README.md @@ -30,7 +30,7 @@ Select deployment option depending on how you prepared models in the previous st Running this command starts the container with CPU only target device: ```bash mkdir -p models -docker run -d -u $(id -u):$(id -g) --rm -p 8000:8000 -v $(pwd)/models:/models:rw openvino/model_server:latest --rest_port 8000 --source_model OpenVINO/InternVL2-2B-int4-ov --model_repository_path /models --model_name OpenGVLab/InternVL2-2B --task text_generation --pipeline_type VLM +docker run -d -u $(id -u):$(id -g) --rm -p 8000:8000 -v $(pwd)/models:/models:rw openvino/model_server:latest --rest_port 8000 --source_model OpenVINO/InternVL2-2B-int4-ov --model_repository_path /models --model_name OpenGVLab/InternVL2-2B --task text_generation --pipeline_type VLM --allowed_media_domains raw.githubusercontent.com ``` **GPU** @@ -39,7 +39,7 @@ to `docker run` command, use the image with GPU support. It can be applied using the commands below: ```bash mkdir -p models -docker run -d -u $(id -u):$(id -g) --rm -p 8000:8000 --device /dev/dri --group-add=$(stat -c "%g" /dev/dri/render* | head -n 1) -v $(pwd)/models:/models:rw openvino/model_server:latest-gpu --rest_port 8000 --source_model OpenVINO/InternVL2-2B-int4-ov --model_repository_path models --model_name OpenGVLab/InternVL2-2B --task text_generation --target_device GPU --pipeline_type VLM +docker run -d -u $(id -u):$(id -g) --rm -p 8000:8000 --device /dev/dri --group-add=$(stat -c "%g" /dev/dri/render* | head -n 1) -v $(pwd)/models:/models:rw openvino/model_server:latest-gpu --rest_port 8000 --source_model OpenVINO/InternVL2-2B-int4-ov --model_repository_path models --model_name OpenGVLab/InternVL2-2B --task text_generation --target_device GPU --pipeline_type VLM --allowed_media_domains raw.githubusercontent.com ``` ::: @@ -49,11 +49,11 @@ If you run on GPU make sure to have appropriate drivers installed, so the device ```bat mkdir models -ovms --rest_port 8000 --source_model OpenVINO/InternVL2-2B-int4-ov --model_repository_path models --model_name OpenGVLab/InternVL2-2B --task text_generation --pipeline_type VLM --target_device CPU +ovms --rest_port 8000 --source_model OpenVINO/InternVL2-2B-int4-ov --model_repository_path models --model_name OpenGVLab/InternVL2-2B --task text_generation --pipeline_type VLM --target_device CPU --allowed_media_domains raw.githubusercontent.com ``` or ```bat -ovms --rest_port 8000 --source_model OpenVINO/InternVL2-2B-int4-ov --model_repository_path models --model_name OpenGVLab/InternVL2-2B --task text_generation --pipeline_type VLM --target_device GPU +ovms --rest_port 8000 --source_model OpenVINO/InternVL2-2B-int4-ov --model_repository_path models --model_name OpenGVLab/InternVL2-2B --task text_generation --pipeline_type VLM --target_device GPU --allowed_media_domains raw.githubusercontent.com ``` ::: @@ -140,7 +140,7 @@ Select deployment option depending on how you prepared models in the previous st Running this command starts the container with CPU only target device: ```bash -docker run -d --rm -p 8000:8000 -v $(pwd)/models:/models:ro openvino/model_server:latest --rest_port 8000 --model_name OpenGVLab/InternVL2-2B --model_path /models/OpenGVLab/InternVL2-2B +docker run -d --rm -p 8000:8000 -v $(pwd)/models:/models:ro openvino/model_server:latest --rest_port 8000 --model_name OpenGVLab/InternVL2-2B --model_path /models/OpenGVLab/InternVL2-2B --allowed_media_domains raw.githubusercontent.com ``` **GPU** @@ -148,7 +148,7 @@ In case you want to use GPU device to run the generation, add extra docker param to `docker run` command, use the image with GPU support. Export the models with precision matching the GPU capacity and adjust pipeline configuration. It can be applied using the commands below: ```bash -docker run -d --rm -p 8000:8000 --device /dev/dri --group-add=$(stat -c "%g" /dev/dri/render* | head -n 1) -v $(pwd)/models:/models:ro openvino/model_server:latest-gpu --rest_port 8000 --model_name OpenGVLab/InternVL2-2B --model_path /models/OpenGVLab/InternVL2-2B +docker run -d --rm -p 8000:8000 --device /dev/dri --group-add=$(stat -c "%g" /dev/dri/render* | head -n 1) -v $(pwd)/models:/models:ro openvino/model_server:latest-gpu --rest_port 8000 --model_name OpenGVLab/InternVL2-2B --model_path /models/OpenGVLab/InternVL2-2B --allowed_media_domains raw.githubusercontent.com ``` ::: @@ -200,7 +200,7 @@ Let's send a request with text an image in the messages context. ![zebra](../../../demos/common/static/images/zebra.jpeg) :::{dropdown} **Unary call with curl using image url** - +**Note**: using urls in request requires `--allowed_media_domains` parameter described [here](parameters.md) ```bash curl http://localhost:8000/v3/chat/completions -H "Content-Type: application/json" -d "{ \"model\": \"OpenGVLab/InternVL2-2B\", \"messages\":[{\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"Describe what is one the picture.\"},{\"type\": \"image_url\", \"image_url\": {\"url\": \"http://raw.githubusercontent.com/openvinotoolkit/model_server/refs/heads/releases/2025/3/demos/common/static/images/zebra.jpeg\"}}]}], \"max_completion_tokens\": 100}" diff --git a/docs/model_server_rest_api_chat.md b/docs/model_server_rest_api_chat.md index 6f01683dc8..4ab8c1cded 100644 --- a/docs/model_server_rest_api_chat.md +++ b/docs/model_server_rest_api_chat.md @@ -112,6 +112,7 @@ curl http://localhost/v3/chat/completions \ "max_completion_tokens": 128 }' ``` +**Note**: using urls in request requires `--allowed_media_domains` parameter described [here](parameters.md) 3) Image from local filesystem: ``` diff --git a/docs/parameters.md b/docs/parameters.md index 9aa06e9507..b0cef276c8 100644 --- a/docs/parameters.md +++ b/docs/parameters.md @@ -20,7 +20,6 @@ | `"low_latency_transformation"` | `bool` | If set to true, model server will apply [low latency transformation](https://docs.openvino.ai/2025/openvino-workflow/running-inference/inference-request/stateful-models/obtaining-stateful-openvino-model.html#lowlatency2-transformation) on model load. | | `"metrics_enable"` | `bool` | Flag enabling [metrics](metrics.md) endpoint on rest_port. | | `"metrics_list"` | `string` | Comma separated list of [metrics](metrics.md). If unset, only default metrics will be enabled.| -| `"allowed_local_media_path"` | `string` | Path to the directory containing images to include in requests. If unset, local filesystem images in requests are not supported.| > **Note** : Specifying config_path is mutually exclusive with putting model parameters in the CLI ([serving multiple models](./starting_server.md)). @@ -57,6 +56,8 @@ Configuration options for the server are defined only via command-line options a | `allowed_methods` | `string` (default: *) | Comma-separated list of allowed methods in CORS requests. | | `allowed_origins` | `string` (default: *) | Comma-separated list of allowed origins in CORS requests. | | `api_key_file` | `string` | Path to the text file with the API key for generative endpoints `/v3/`. The value of first line is used. If not specified, server is using environment variable API_KEY. If not set, requests will not require authorization.| +| `allowed_local_media_path` | `string` | Path to the directory containing images to include in requests. If unset, local filesystem images in requests are not supported.| +| `allowed_media_domains` | `string` | Comma separated list of media domains from which URLs can be used as input for LLMs. Set to \"all\" to disable this restrictions. If unset, URLs in requests are not supported." ## Config management mode options diff --git a/docs/security_considerations.md b/docs/security_considerations.md index 5c61a70702..142a6f82f4 100644 --- a/docs/security_considerations.md +++ b/docs/security_considerations.md @@ -26,6 +26,8 @@ See also: Generative endpoints starting with `/v3`, might be restricted with authorization and API key. It can be set during the server initialization with a parameter `api_key_file` or environment variable `API_KEY`. The `api_key_file` should contain a path to the file containing the value of API key. The content of the file first line is used. If parameter api_key_file and variable API_KEY are not set, the server will not require any authorization. The client should send the API key inside the `Authorization` header as `Bearer `. +OVMS supports multimodal models with image inputs provided as URL. However, to prevent Request Forgery (SSRF) attacks, all the URLs are restricted by default. To allow fetching image from specific domains use `--allowed_media_domains` parameter described [here](parameters.md). Also, consider setting OVMS_MEDIA_URL_ALLOW_REDIRECTS=1 to allow HTTP redirects, by default disabled to prevent from bypassing domain restrictions. + --- OpenVINO Model Server has a set of mechanisms preventing denial of service attacks from the client applications. They include the following: diff --git a/src/capi_frontend/capi.cpp b/src/capi_frontend/capi.cpp index 97694d938d..6bdc385c2a 100644 --- a/src/capi_frontend/capi.cpp +++ b/src/capi_frontend/capi.cpp @@ -574,6 +574,38 @@ DLL_PUBLIC OVMS_Status* OVMS_ServerSettingsSetLogPath(OVMS_ServerSettings* setti return nullptr; } +DLL_PUBLIC OVMS_Status* OVMS_ServerSettingsSetAllowedLocalMediaPath(OVMS_ServerSettings* settings, + const char* allowed_local_media_path) { + if (settings == nullptr) { + return reinterpret_cast(new Status(StatusCode::NONEXISTENT_PTR, "server settings")); + } + if (allowed_local_media_path == nullptr) { + return reinterpret_cast(new Status(StatusCode::NONEXISTENT_PTR, "log path")); + } + ovms::ServerSettingsImpl* serverSettings = reinterpret_cast(settings); + serverSettings->allowedLocalMediaPath = allowed_local_media_path; + return nullptr; +} + +DLL_PUBLIC OVMS_Status* OVMS_ServerSettingsSetAllowedMediaDomains(OVMS_ServerSettings* settings, + const char* allowed_media_domains) { + if (settings == nullptr) { + return reinterpret_cast(new Status(StatusCode::NONEXISTENT_PTR, "server settings")); + } + if (allowed_media_domains == nullptr) { + return reinterpret_cast(new Status(StatusCode::NONEXISTENT_PTR, "log path")); + } + std::vector domains; + std::string domain; + std::istringstream ss(allowed_media_domains); + while (std::getline(ss, domain, ',')) { + domains.push_back(domain); + } + ovms::ServerSettingsImpl* serverSettings = reinterpret_cast(settings); + serverSettings->allowedMediaDomains = domains; + return nullptr; +} + DLL_PUBLIC OVMS_Status* OVMS_ModelsSettingsSetConfigPath(OVMS_ModelsSettings* settings, const char* config_path) { if (settings == nullptr) { diff --git a/src/capi_frontend/server_settings.hpp b/src/capi_frontend/server_settings.hpp index 3078aa7d9d..05ed94528c 100644 --- a/src/capi_frontend/server_settings.hpp +++ b/src/capi_frontend/server_settings.hpp @@ -187,6 +187,7 @@ struct ServerSettingsImpl { std::string metricsList; std::string cpuExtensionLibraryPath; std::optional allowedLocalMediaPath; + std::optional> allowedMediaDomains; std::string logLevel = "INFO"; std::string logPath; bool allowCredentials = false; diff --git a/src/cli_parser.cpp b/src/cli_parser.cpp index 9eb6219a0b..7393c890ca 100644 --- a/src/cli_parser.cpp +++ b/src/cli_parser.cpp @@ -147,6 +147,10 @@ std::variant> CLIParser::parse(int argc, char* "A path to shared library containing custom CPU layer implementation. Default: empty.", cxxopts::value()->default_value(""), "CPU_EXTENSION") + ("allowed_media_domains", + "Comma separated list of media domains from which URLs can be used as input for LLMs. Set to \"all\" to disable this restriction.", + cxxopts::value>(), + "ALLOWED_MEDIA_DOMAINS") ("allowed_local_media_path", "Path to directory that contains multimedia files that can be used as input for LLMs.", cxxopts::value(), @@ -502,6 +506,9 @@ void CLIParser::prepareServer(ServerSettingsImpl& serverSettings) { if (result->count("cpu_extension")) { serverSettings.cpuExtensionLibraryPath = result->operator[]("cpu_extension").as(); } + if (result->count("allowed_media_domains")) { + serverSettings.allowedMediaDomains = result->operator[]("allowed_media_domains").as>(); + } if (result->count("allowed_local_media_path")) { serverSettings.allowedLocalMediaPath = result->operator[]("allowed_local_media_path").as(); } diff --git a/src/llm/apis/openai_completions.cpp b/src/llm/apis/openai_completions.cpp index 3b0c0e2ce1..ae2492abd7 100644 --- a/src/llm/apis/openai_completions.cpp +++ b/src/llm/apis/openai_completions.cpp @@ -21,6 +21,7 @@ #include "src/port/rapidjson_stringbuffer.hpp" #include "src/port/rapidjson_writer.hpp" #include +#include #include "openai_json_response.hpp" @@ -100,7 +101,6 @@ static size_t appendChunkCallback(void* downloadedChunk, size_t size, size_t nme if (status == CURLE_OK) { \ status = setopt; \ } - static absl::Status downloadImage(const char* url, std::string& image, const int64_t& sizeLimit) { CURL* curl_handle = curl_easy_init(); if (!curl_handle) { @@ -113,7 +113,11 @@ static absl::Status downloadImage(const char* url, std::string& image, const int CURL_SETOPT(curl_easy_setopt(curl_handle, CURLOPT_WRITEFUNCTION, appendChunkCallback)) CURL_SETOPT(curl_easy_setopt(curl_handle, CURLOPT_WRITEDATA, &image)) CURL_SETOPT(curl_easy_setopt(curl_handle, CURLOPT_SSL_OPTIONS, CURLSSLOPT_NATIVE_CA)) - CURL_SETOPT(curl_easy_setopt(curl_handle, CURLOPT_FOLLOWLOCATION, 1L)) + const char* envAllowRedirects = std::getenv("OVMS_MEDIA_URL_ALLOW_REDIRECTS"); + if (envAllowRedirects != nullptr && (std::strcmp(envAllowRedirects, "1") == 0)) { + SPDLOG_LOGGER_TRACE(llm_calculator_logger, "URL redirects allowed"); + CURL_SETOPT(curl_easy_setopt(curl_handle, CURLOPT_FOLLOWLOCATION, 1L)) + } CURL_SETOPT(curl_easy_setopt(curl_handle, CURLOPT_MAXFILESIZE, sizeLimit)) if (status != CURLE_OK) { @@ -131,6 +135,37 @@ static absl::Status downloadImage(const char* url, std::string& image, const int return absl::OkStatus(); } +static bool isDomainAllowed(const std::vector& allowedDomains, const char* url) { + if (allowedDomains.size() == 1 && allowedDomains[0] == "all") { + return true; + } + CURLUcode rc; + CURLU* parsedUrl = curl_url(); + rc = curl_url_set(parsedUrl, CURLUPART_URL, url, 0); + if (rc) { + SPDLOG_LOGGER_DEBUG(llm_calculator_logger, "Parsing url {} failed", url); + curl_url_cleanup(parsedUrl); + return false; + } + char* host; + rc = curl_url_get(parsedUrl, CURLUPART_HOST, &host, 0); + if (rc) { + SPDLOG_LOGGER_DEBUG(llm_calculator_logger, "Parsing url {} hostname failed", url); + curl_url_cleanup(parsedUrl); + return false; + } + bool allowed = false; + for (const auto& allowedDomain : allowedDomains) { + if (allowedDomain.compare(host) == 0) { + allowed = true; + break; + } + } + curl_free(host); + curl_url_cleanup(parsedUrl); + return allowed; +} + absl::Status OpenAIChatCompletionsHandler::ensureArgumentsInToolCalls(Value& messageObj, bool& jsonChanged) { auto& allocator = doc.GetAllocator(); auto toolCallsIt = messageObj.FindMember("tool_calls"); @@ -159,7 +194,7 @@ absl::Status OpenAIChatCompletionsHandler::ensureArgumentsInToolCalls(Value& mes return absl::OkStatus(); } -absl::Status OpenAIChatCompletionsHandler::parseMessages(std::optional allowedLocalMediaPath) { +absl::Status OpenAIChatCompletionsHandler::parseMessages(std::optional allowedLocalMediaPath, std::optional> allowedMediaDomains) { auto it = doc.FindMember("messages"); if (it == doc.MemberEnd()) return absl::InvalidArgumentError("Messages missing in request"); @@ -237,6 +272,9 @@ absl::Status OpenAIChatCompletionsHandler::parseMessages(std::optional maxTokensLimit, std::optional allowedLocalMediaPath) { +absl::Status OpenAIChatCompletionsHandler::parseChatCompletionsPart(std::optional maxTokensLimit, std::optional allowedLocalMediaPath, std::optional> allowedMediaDomains) { // messages: [{role: content}, {role: content}, ...]; required - auto status = parseMessages(allowedLocalMediaPath); + auto status = parseMessages(allowedLocalMediaPath, allowedMediaDomains); if (status != absl::OkStatus()) { return status; } @@ -791,14 +829,14 @@ void OpenAIChatCompletionsHandler::incrementProcessedTokens(size_t numTokens) { usage.completionTokens += numTokens; } -absl::Status OpenAIChatCompletionsHandler::parseRequest(std::optional maxTokensLimit, uint32_t bestOfLimit, std::optional maxModelLength, std::optional allowedLocalMediaPath) { +absl::Status OpenAIChatCompletionsHandler::parseRequest(std::optional maxTokensLimit, uint32_t bestOfLimit, std::optional maxModelLength, std::optional allowedLocalMediaPath, std::optional> allowedMediaDomains) { absl::Status status = parseCommonPart(maxTokensLimit, bestOfLimit, maxModelLength); if (status != absl::OkStatus()) return status; if (endpoint == Endpoint::COMPLETIONS) status = parseCompletionsPart(); else - status = parseChatCompletionsPart(maxTokensLimit, allowedLocalMediaPath); + status = parseChatCompletionsPart(maxTokensLimit, allowedLocalMediaPath, allowedMediaDomains); return status; } diff --git a/src/llm/apis/openai_completions.hpp b/src/llm/apis/openai_completions.hpp index e3b3a4bd43..7292d99c01 100644 --- a/src/llm/apis/openai_completions.hpp +++ b/src/llm/apis/openai_completions.hpp @@ -72,7 +72,7 @@ class OpenAIChatCompletionsHandler { std::unique_ptr outputParser = nullptr; absl::Status parseCompletionsPart(); - absl::Status parseChatCompletionsPart(std::optional maxTokensLimit, std::optional allowedLocalMediaPath); + absl::Status parseChatCompletionsPart(std::optional maxTokensLimit, std::optional allowedLocalMediaPath, std::optional> allowedMediaDomains); absl::Status parseCommonPart(std::optional maxTokensLimit, uint32_t bestOfLimit, std::optional maxModelLength); ParsedOutput parseOutputIfNeeded(const std::vector& generatedIds); @@ -112,8 +112,8 @@ class OpenAIChatCompletionsHandler { void incrementProcessedTokens(size_t numTokens = 1); - absl::Status parseRequest(std::optional maxTokensLimit, uint32_t bestOfLimit, std::optional maxModelLength, std::optional allowedLocalMediaPath = std::nullopt); - absl::Status parseMessages(std::optional allowedLocalMediaPath = std::nullopt); + absl::Status parseRequest(std::optional maxTokensLimit, uint32_t bestOfLimit, std::optional maxModelLength, std::optional allowedLocalMediaPath = std::nullopt, std::optional> allowedMediaDomains = std::nullopt); + absl::Status parseMessages(std::optional allowedLocalMediaPath = std::nullopt, std::optional> allowedMediaDomains = std::nullopt); absl::Status parseTools(); const bool areToolsAvailable() const; diff --git a/src/llm/visual_language_model/legacy/servable.cpp b/src/llm/visual_language_model/legacy/servable.cpp index 722f57c997..817bb54d23 100644 --- a/src/llm/visual_language_model/legacy/servable.cpp +++ b/src/llm/visual_language_model/legacy/servable.cpp @@ -84,7 +84,7 @@ absl::Status VisualLanguageModelLegacyServable::parseRequest(std::shared_ptrtokenizer); auto& config = ovms::Config::instance(); - auto status = executionContext->apiHandler->parseRequest(getProperties()->maxTokensLimit, getProperties()->bestOfLimit, getProperties()->maxModelLength, config.getServerSettings().allowedLocalMediaPath); + auto status = executionContext->apiHandler->parseRequest(getProperties()->maxTokensLimit, getProperties()->bestOfLimit, getProperties()->maxModelLength, config.getServerSettings().allowedLocalMediaPath, config.getServerSettings().allowedMediaDomains); if (!status.ok()) { SPDLOG_LOGGER_ERROR(llm_calculator_logger, "Failed to parse request: {}", status.message()); return status; diff --git a/src/ovms.h b/src/ovms.h index 8ca223aa58..a67334e4e2 100644 --- a/src/ovms.h +++ b/src/ovms.h @@ -33,7 +33,7 @@ typedef struct OVMS_ServableMetadata_ OVMS_ServableMetadata; typedef struct OVMS_Metadata_ OVMS_Metadata; #define OVMS_API_VERSION_MAJOR 1 -#define OVMS_API_VERSION_MINOR 2 +#define OVMS_API_VERSION_MINOR 3 // Function to retrieve OVMS API version. // @@ -337,6 +337,24 @@ OVMS_Status* OVMS_ServerSettingsSetLogLevel(OVMS_ServerSettings* settings, OVMS_Status* OVMS_ServerSettingsSetLogPath(OVMS_ServerSettings* settings, const char* log_path); +// Set the server allowed_local_media_path setting. Equivalent of starting server with +// --allowed_local_media_path. +// +// \param settings The server settings object to be set +// \param allowed_local_media_path The value to be set +// \return OVMS_Status object in case of failure +OVMS_Status* OVMS_ServerSettingsSetAllowedLocalMediaPath(OVMS_ServerSettings* settings, + const char* allowed_local_media_path); + +// Set the server allowed_media_domains setting. Equivalent of starting server with +// --allowed_media_domains. +// +// \param settings The server settings object to be set +// \param allowed_media_domains The value to be set as comma separated string +// \return OVMS_Status object in case of failure +OVMS_Status* OVMS_ServerSettingsSetAllowedMediaDomains(OVMS_ServerSettings* settings, + const char* allowed_media_domains); + //// //// OVMS_ModelsSettings //// Options for starting multi model server controlled by config.json file diff --git a/src/test/c_api_tests.cpp b/src/test/c_api_tests.cpp index 10b86d98d8..bff2ddc415 100644 --- a/src/test/c_api_tests.cpp +++ b/src/test/c_api_tests.cpp @@ -131,6 +131,8 @@ TEST(CAPIConfigTest, MultiModelConfiguration) { ASSERT_CAPI_STATUS_NULL(OVMS_ServerSettingsSetLogLevel(_serverSettings, OVMS_LOG_TRACE)); ASSERT_CAPI_STATUS_NOT_NULL_EXPECT_CODE(OVMS_ServerSettingsSetLogLevel(_serverSettings, static_cast(99)), StatusCode::NONEXISTENT_LOG_LEVEL); ASSERT_CAPI_STATUS_NULL(OVMS_ServerSettingsSetLogPath(_serverSettings, getGenericFullPathForTmp("/tmp/logs").c_str())); + ASSERT_CAPI_STATUS_NULL(OVMS_ServerSettingsSetAllowedLocalMediaPath(_serverSettings, getGenericFullPathForTmp("/tmp/path").c_str())); + ASSERT_CAPI_STATUS_NULL(OVMS_ServerSettingsSetAllowedMediaDomains(_serverSettings, "raw.githubusercontent.com,githubusercontent.com,google.com")); ASSERT_CAPI_STATUS_NULL(OVMS_ModelsSettingsSetConfigPath(_modelsSettings, getGenericFullPathForTmp("/tmp/config").c_str())); // check nullptr ASSERT_CAPI_STATUS_NOT_NULL_EXPECT_CODE(OVMS_ServerSettingsSetGrpcPort(nullptr, 5555), StatusCode::NONEXISTENT_PTR); @@ -155,6 +157,10 @@ TEST(CAPIConfigTest, MultiModelConfiguration) { ASSERT_CAPI_STATUS_NOT_NULL_EXPECT_CODE(OVMS_ServerSettingsSetLogLevel(nullptr, OVMS_LOG_TRACE), StatusCode::NONEXISTENT_PTR); ASSERT_CAPI_STATUS_NOT_NULL_EXPECT_CODE(OVMS_ServerSettingsSetLogPath(nullptr, getGenericFullPathForTmp("/tmp/logs").c_str()), StatusCode::NONEXISTENT_PTR); ASSERT_CAPI_STATUS_NOT_NULL_EXPECT_CODE(OVMS_ServerSettingsSetLogPath(_serverSettings, nullptr), StatusCode::NONEXISTENT_PTR); + ASSERT_CAPI_STATUS_NOT_NULL_EXPECT_CODE(OVMS_ServerSettingsSetAllowedLocalMediaPath(nullptr, getGenericFullPathForTmp("/tmp/images").c_str()), StatusCode::NONEXISTENT_PTR); + ASSERT_CAPI_STATUS_NOT_NULL_EXPECT_CODE(OVMS_ServerSettingsSetAllowedLocalMediaPath(_serverSettings, nullptr), StatusCode::NONEXISTENT_PTR); + ASSERT_CAPI_STATUS_NOT_NULL_EXPECT_CODE(OVMS_ServerSettingsSetAllowedMediaDomains(nullptr, "raw.githubusercontent.com,githubusercontent.com,google.com"), StatusCode::NONEXISTENT_PTR); + ASSERT_CAPI_STATUS_NOT_NULL_EXPECT_CODE(OVMS_ServerSettingsSetAllowedMediaDomains(_serverSettings, nullptr), StatusCode::NONEXISTENT_PTR); ASSERT_CAPI_STATUS_NOT_NULL_EXPECT_CODE(OVMS_ModelsSettingsSetConfigPath(nullptr, getGenericFullPathForTmp("/tmp/config").c_str()), StatusCode::NONEXISTENT_PTR); ASSERT_CAPI_STATUS_NOT_NULL_EXPECT_CODE(OVMS_ModelsSettingsSetConfigPath(_modelsSettings, nullptr), StatusCode::NONEXISTENT_PTR); @@ -170,6 +176,13 @@ TEST(CAPIConfigTest, MultiModelConfiguration) { EXPECT_EQ(serverSettings->cpuExtensionLibraryPath, getGenericFullPathForSrcTest("/ovms/src/test")); EXPECT_EQ(serverSettings->logLevel, "TRACE"); EXPECT_EQ(serverSettings->logPath, getGenericFullPathForTmp("/tmp/logs")); + ASSERT_TRUE(serverSettings->allowedLocalMediaPath.has_value()); + EXPECT_EQ(serverSettings->allowedLocalMediaPath.value(), getGenericFullPathForTmp("/tmp/path")); + ASSERT_TRUE(serverSettings->allowedMediaDomains.has_value()); + EXPECT_EQ(serverSettings->allowedMediaDomains.value().size(), 3); + EXPECT_EQ(serverSettings->allowedMediaDomains.value()[0], "raw.githubusercontent.com"); + EXPECT_EQ(serverSettings->allowedMediaDomains.value()[1], "githubusercontent.com"); + EXPECT_EQ(serverSettings->allowedMediaDomains.value()[2], "google.com"); // trace path // not tested since it is not supported in C-API EXPECT_EQ(serverSettings->grpcChannelArguments, "grpcargs"); EXPECT_EQ(serverSettings->grpcMaxThreads, 100); diff --git a/src/test/http_openai_handler_test.cpp b/src/test/http_openai_handler_test.cpp index 3218796818..fea0b272c7 100644 --- a/src/test/http_openai_handler_test.cpp +++ b/src/test/http_openai_handler_test.cpp @@ -464,7 +464,44 @@ TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesSucceedsUrlHttp) { doc.Parse(json.c_str()); ASSERT_FALSE(doc.HasParseError()); std::shared_ptr apiHandler = std::make_shared(doc, ovms::Endpoint::CHAT_COMPLETIONS, std::chrono::system_clock::now(), *tokenizer); - ASSERT_EQ(apiHandler->parseMessages(), absl::OkStatus()); + std::vector allowedDomains = {"raw.githubusercontent.com"}; + ASSERT_EQ(apiHandler->parseMessages(std::nullopt, allowedDomains), absl::OkStatus()); + const ovms::ImageHistory& imageHistory = apiHandler->getImageHistory(); + ASSERT_EQ(imageHistory.size(), 1); + auto [index, image] = imageHistory[0]; + EXPECT_EQ(index, 0); + EXPECT_EQ(image.get_element_type(), ov::element::u8); + EXPECT_EQ(image.get_size(), 225792); + json = apiHandler->getProcessedJson(); + EXPECT_EQ(json, std::string("{\"model\":\"llama\",\"messages\":[{\"role\":\"user\",\"content\":\"What is in this image?\"}]}")); +} + +TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesSucceedsUrlHttpMultipleAllowedDomains) { + std::string json = R"({ + "model": "llama", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "What is in this image?" + }, + { + "type": "image_url", + "image_url": { + "url": "http://raw.githubusercontent.com/openvinotoolkit/model_server/refs/heads/main/demos/common/static/images/zebra.jpeg" + } + } + ] + } + ] +})"; + doc.Parse(json.c_str()); + ASSERT_FALSE(doc.HasParseError()); + std::shared_ptr apiHandler = std::make_shared(doc, ovms::Endpoint::CHAT_COMPLETIONS, std::chrono::system_clock::now(), *tokenizer); + std::vector allowedDomains = {"raw.githubusercontent.com", "githubusercontent.com", "google.com"}; + ASSERT_EQ(apiHandler->parseMessages(std::nullopt, allowedDomains), absl::OkStatus()); const ovms::ImageHistory& imageHistory = apiHandler->getImageHistory(); ASSERT_EQ(imageHistory.size(), 1); auto [index, image] = imageHistory[0]; @@ -499,7 +536,44 @@ TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesSucceedsUrlHttps) { doc.Parse(json.c_str()); ASSERT_FALSE(doc.HasParseError()); std::shared_ptr apiHandler = std::make_shared(doc, ovms::Endpoint::CHAT_COMPLETIONS, std::chrono::system_clock::now(), *tokenizer); - ASSERT_EQ(apiHandler->parseMessages(), absl::OkStatus()); + std::vector allowedDomains = {"raw.githubusercontent.com"}; + ASSERT_EQ(apiHandler->parseMessages(std::nullopt, allowedDomains), absl::OkStatus()); + const ovms::ImageHistory& imageHistory = apiHandler->getImageHistory(); + ASSERT_EQ(imageHistory.size(), 1); + auto [index, image] = imageHistory[0]; + EXPECT_EQ(index, 0); + EXPECT_EQ(image.get_element_type(), ov::element::u8); + EXPECT_EQ(image.get_size(), 225792); + json = apiHandler->getProcessedJson(); + EXPECT_EQ(json, std::string("{\"model\":\"llama\",\"messages\":[{\"role\":\"user\",\"content\":\"What is in this image?\"}]}")); +} + +TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesSucceedsUrlHttpsAllowedDomainAll) { + std::string json = R"({ +"model": "llama", +"messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "What is in this image?" + }, + { + "type": "image_url", + "image_url": { + "url": "https://raw.githubusercontent.com/openvinotoolkit/model_server/refs/heads/main/demos/common/static/images/zebra.jpeg" + } + } + ] + } +] +})"; + doc.Parse(json.c_str()); + ASSERT_FALSE(doc.HasParseError()); + std::shared_ptr apiHandler = std::make_shared(doc, ovms::Endpoint::CHAT_COMPLETIONS, std::chrono::system_clock::now(), *tokenizer); + std::vector allowedDomains = {"all"}; + ASSERT_EQ(apiHandler->parseMessages(std::nullopt, allowedDomains), absl::OkStatus()); const ovms::ImageHistory& imageHistory = apiHandler->getImageHistory(); ASSERT_EQ(imageHistory.size(), 1); auto [index, image] = imageHistory[0]; @@ -572,6 +646,118 @@ TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesImageStringWithNoPrefixFails EXPECT_EQ(apiHandler->parseMessages(), absl::InvalidArgumentError("Loading images from local filesystem is disabled.")); } +TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesFailsUrlHttpNotAllowedDomain) { + std::string json = R"({ + "model": "llama", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "What is in this image?" + }, + { + "type": "image_url", + "image_url": { + "url": "http://raw.githubusercontent.com/openvinotoolkit/model_server/refs/heads/main/demos/common/static/images/zebra.jpeg" + } + } + ] + } + ] +})"; + doc.Parse(json.c_str()); + ASSERT_FALSE(doc.HasParseError()); + std::shared_ptr apiHandler = std::make_shared(doc, ovms::Endpoint::CHAT_COMPLETIONS, std::chrono::system_clock::now(), *tokenizer); + std::vector allowedDomains = {"wikipedia.com"}; + ASSERT_EQ(apiHandler->parseMessages(std::nullopt, allowedDomains), absl::InvalidArgumentError("Given url does not match any allowed domain from allowed_media_domains")); +} + +TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesFailsUrlMatchAllowedDomainPartially1) { + std::string json = R"({ + "model": "llama", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "What is in this image?" + }, + { + "type": "image_url", + "image_url": { + "url": "http://raw.githubusercontent.com/openvinotoolkit/model_server/refs/heads/main/demos/common/static/images/zebra.jpeg" + } + } + ] + } + ] +})"; + doc.Parse(json.c_str()); + ASSERT_FALSE(doc.HasParseError()); + std::shared_ptr apiHandler = std::make_shared(doc, ovms::Endpoint::CHAT_COMPLETIONS, std::chrono::system_clock::now(), *tokenizer); + std::vector allowedDomains = {"githubusercontent.com"}; + ASSERT_EQ(apiHandler->parseMessages(std::nullopt, allowedDomains), absl::InvalidArgumentError("Given url does not match any allowed domain from allowed_media_domains")); +} + +TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesFailsUrlMatchAllowedDomainPartially2) { + std::string json = R"({ + "model": "llama", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "What is in this image?" + }, + { + "type": "image_url", + "image_url": { + "url": "http://raw.githubusercontent.com/openvinotoolkit/model_server/refs/heads/main/demos/common/static/images/zebra.jpeg" + } + } + ] + } + ] +})"; + doc.Parse(json.c_str()); + ASSERT_FALSE(doc.HasParseError()); + std::shared_ptr apiHandler = std::make_shared(doc, ovms::Endpoint::CHAT_COMPLETIONS, std::chrono::system_clock::now(), *tokenizer); + std::vector allowedDomains = {"host.raw.githubusercontent.com"}; + ASSERT_EQ(apiHandler->parseMessages(std::nullopt, allowedDomains), absl::InvalidArgumentError("Given url does not match any allowed domain from allowed_media_domains")); +} + +TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesFailsRegexNotSupported) { + std::string json = R"({ + "model": "llama", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "What is in this image?" + }, + { + "type": "image_url", + "image_url": { + "url": "http://raw.githubusercontent.com/openvinotoolkit/model_server/refs/heads/main/demos/common/static/images/zebra.jpeg" + } + } + ] + } + ] +})"; + doc.Parse(json.c_str()); + ASSERT_FALSE(doc.HasParseError()); + std::shared_ptr apiHandler = std::make_shared(doc, ovms::Endpoint::CHAT_COMPLETIONS, std::chrono::system_clock::now(), *tokenizer); + std::vector allowedDomains = {"*githubusercontent.com"}; + ASSERT_EQ(apiHandler->parseMessages(std::nullopt, allowedDomains), absl::InvalidArgumentError("Given url does not match any allowed domain from allowed_media_domains")); +} + TEST_F(HttpOpenAIHandlerParsingTest, ParsingMessagesImageLocalFilesystem) { std::string json = R"({ "model": "llama", diff --git a/src/test/ovmsconfig_test.cpp b/src/test/ovmsconfig_test.cpp index ade6f80db3..9c3d41346e 100644 --- a/src/test/ovmsconfig_test.cpp +++ b/src/test/ovmsconfig_test.cpp @@ -2334,13 +2334,14 @@ TEST(OvmsConfigTest, positiveMulti) { #endif "--cache_dir", "/tmp/model_cache", "--allowed_local_media_path", "/tmp/path", + "--allowed_media_domains", "raw.githubusercontent.com,githubusercontent.com,google.com", "--log_path", "/tmp/log_path", "--log_level", "ERROR", "--grpc_max_threads", "100", "--grpc_memory_quota", "1000000", "--config_path", "/config.json"}; - int arg_count = 44; + int arg_count = 46; ConstructorEnabledConfig config; config.parse(arg_count, n_argv); @@ -2364,6 +2365,11 @@ TEST(OvmsConfigTest, positiveMulti) { EXPECT_EQ(config.cacheDir(), "/tmp/model_cache"); ASSERT_TRUE(config.getServerSettings().allowedLocalMediaPath.has_value()); EXPECT_EQ(config.getServerSettings().allowedLocalMediaPath.value(), "/tmp/path"); + ASSERT_TRUE(config.getServerSettings().allowedMediaDomains.has_value()); + EXPECT_EQ(config.getServerSettings().allowedMediaDomains.value().size(), 3); + EXPECT_EQ(config.getServerSettings().allowedMediaDomains.value()[0], "raw.githubusercontent.com"); + EXPECT_EQ(config.getServerSettings().allowedMediaDomains.value()[1], "githubusercontent.com"); + EXPECT_EQ(config.getServerSettings().allowedMediaDomains.value()[2], "google.com"); EXPECT_EQ(config.logPath(), "/tmp/log_path"); EXPECT_EQ(config.logLevel(), "ERROR"); EXPECT_EQ(config.configPath(), "/config.json"); diff --git a/windows_test.bat b/windows_test.bat index 5dbd47bc17..592b9ad67b 100644 --- a/windows_test.bat +++ b/windows_test.bat @@ -27,6 +27,7 @@ IF "%~1"=="" ( set "bazelStartupCmd=--output_user_root=!BAZEL_SHORT_PATH!" set "openvino_dir=!BAZEL_SHORT_PATH!/openvino/runtime/cmake" +set "OVMS_MEDIA_URL_ALLOW_REDIRECTS=1" IF "%~2"=="--with_python" ( set "bazelBuildArgs=--config=win_mp_on_py_on --action_env OpenVINO_DIR=%openvino_dir%"