[Rust Frontend][gRPC] Enforce LoRA path validation across transports (#53756)

Signed-off-by: Connor Carpenter <connorc@nvidia.com> Co-authored-by: Bugen Zhao <i@bugenzhao.com>

Connor Carpenter committed Aug 27, 2026 at 19:00 UTC 3cc015acd265d0e4e4c9d0a73bf7e5d5a5e66eef
4 files changed +197 -175
rust/src/server/src/grpc/control.rs
+5 -1
@@ -13,7 +13,7 @@ use vllm_engine_core_client::protocol::lora::LoraRequest;
13 use vllm_engine_core_client::protocol::utility::PauseMode as EnginePauseMode;
14
15 use super::{ControlServer, pb};
16 -use crate::lora::{LoadLoraError, LoraDisabledError, UnloadLoraError};
16 +use crate::lora::{LoadLoraError, LoraDisabledError, LoraPathAccessError, UnloadLoraError};
17 use crate::state::AppState;
18
19 pub(crate) type ControlGrpcService = ControlServer<ControlServiceImpl>;
@@ -148,6 +148,10 @@ fn load_lora_status(error: LoadLoraError) -> Status {
148 let code = match &error {
149 LoadLoraError::Disabled(_) => Code::FailedPrecondition,
150 LoadLoraError::InvalidRequest(_) => Code::InvalidArgument,
151 + LoadLoraError::PathAccess(LoraPathAccessError::InvalidPath { .. }) => Code::InvalidArgument,
152 + LoadLoraError::PathAccess(LoraPathAccessError::InvalidConfiguration { .. }) => {
153 + Code::Internal
154 + }
155 LoadLoraError::AlreadyLoaded { .. } | LoadLoraError::BaseModelName { .. } => {
156 Code::AlreadyExists
157 }
rust/src/server/src/grpc/tests.rs
+12
@@ -1632,6 +1632,18 @@ async fn control_lora_lifecycle_selects_adapter_for_generation() {
1632 .await;
1633 let mut control_client = ControlClient::new(channel.clone());
1634 let mut inference_client = InferenceClient::new(channel);
1635 +
1636 + let denied_source_path =
1637 + std::env::temp_dir().join(format!("vllm-grpc-lora-denied-{}", std::process::id()));
1638 + let denied = control_client
1639 + .load_lora(pb::LoadLoraRequest {
1640 + lora_name: "denied-adapter".to_string(),
1641 + source_path: denied_source_path.to_string_lossy().into_owned(),
1642 + })
1643 + .await
1644 + .expect_err("local LoRA path should be validated before engine load");
1645 + assert_eq!(denied.code(), tonic::Code::InvalidArgument);
1646 +
1647 let loaded = control_client
1648 .load_lora(pb::LoadLoraRequest {
1649 lora_name: "adapter".to_string(),
rust/src/server/src/lora.rs
+172
@@ -1,14 +1,86 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +use std::path::{Component, Path, PathBuf};
5 use std::sync::atomic::{AtomicU64, Ordering};
6
7 use indexmap::IndexMap;
8 use thiserror::Error;
9 +use thiserror_ext::Macro;
10 use tokio::sync::{Mutex, RwLock};
11 use vllm_engine_core_client::EngineCoreClient;
12 use vllm_engine_core_client::protocol::lora::{LoraRequest, LoraRequestError};
13
14 +const RUNTIME_LORA_ALLOWED_PATH_PREFIXES_ENV: &str = "VLLM_RUNTIME_LORA_ALLOWED_PATH_PREFIXES";
15 +
16 +#[derive(Debug, Error, Macro)]
17 +pub(crate) enum LoraPathAccessError {
18 + #[error("{message}")]
19 + InvalidPath { message: String },
20 + #[error("{message}")]
21 + InvalidConfiguration { message: String },
22 +}
23 +
24 +fn runtime_lora_allowed_path_prefixes() -> Option<Vec<PathBuf>> {
25 + let prefixes = std::env::var_os(RUNTIME_LORA_ALLOWED_PATH_PREFIXES_ENV)?;
26 + let prefixes: Vec<_> = std::env::split_paths(&prefixes)
27 + .filter(|path| !path.as_os_str().is_empty())
28 + .collect();
29 + (!prefixes.is_empty()).then_some(prefixes)
30 +}
31 +
32 +fn looks_like_local_lora_path(lora_path: &str) -> bool {
33 + let path = Path::new(lora_path);
34 + path.is_absolute()
35 + || lora_path.starts_with('~')
36 + || lora_path.starts_with('.')
37 + || path.components().any(|component| matches!(component, Component::ParentDir))
38 +}
39 +
40 +fn validate_lora_path_access(
41 + lora_path: &str,
42 + allowed_prefixes: Option<&[PathBuf]>,
43 +) -> Result<Option<String>, LoraPathAccessError> {
44 + let path = Path::new(lora_path);
45 + if !looks_like_local_lora_path(lora_path) && !path.exists() {
46 + return Ok(None);
47 + }
48 +
49 + let Some(allowed_prefixes) = allowed_prefixes else {
50 + return Err(invalid_path!(
51 + "Local LoRA adapter paths require {RUNTIME_LORA_ALLOWED_PATH_PREFIXES_ENV} to be configured."
52 + ));
53 + };
54 +
55 + if !path.is_absolute() {
56 + return Err(invalid_path!(
57 + "Local LoRA adapter paths must be absolute and under one of the prefixes configured by {RUNTIME_LORA_ALLOWED_PATH_PREFIXES_ENV}."
58 + ));
59 + }
60 +
61 + let canonical_path = path
62 + .canonicalize()
63 + .map_err(|_| invalid_path!("Local LoRA adapter path must exist and be accessible."))?;
64 + let canonical_prefixes = allowed_prefixes
65 + .iter()
66 + .map(|prefix| {
67 + prefix.canonicalize().map_err(|_| {
68 + invalid_configuration!(
69 + "configured {RUNTIME_LORA_ALLOWED_PATH_PREFIXES_ENV} path prefix must exist and be accessible"
70 + )
71 + })
72 + })
73 + .collect::<Result<Vec<_>, _>>()?;
74 +
75 + if !canonical_prefixes.iter().any(|prefix| canonical_path.starts_with(prefix)) {
76 + return Err(invalid_path!(
77 + "Local LoRA adapter path is outside the configured allowed prefixes."
78 + ));
79 + }
80 +
81 + Ok(Some(canonical_path.to_string_lossy().into_owned()))
82 +}
83 +
84 /// Snapshot of the currently served model names plus the requested LoRA, if
85 /// the model name resolves to a dynamic adapter.
86 #[derive(Debug, Clone)]
@@ -37,6 +109,8 @@ pub(crate) enum LoadLoraError {
109 Disabled(#[from] LoraDisabledError),
110 #[error(transparent)]
111 InvalidRequest(#[from] LoraRequestError),
112 + #[error(transparent)]
113 + PathAccess(#[from] LoraPathAccessError),
114 #[error("LoRA adapter `{lora_name}` is already loaded")]
115 AlreadyLoaded { lora_name: String },
116 #[error("LoRA adapter `{lora_name}` conflicts with a served base model")]
@@ -117,6 +191,9 @@ impl LoraManager {
191 load_inplace: bool,
192 is_3d_lora_weight: bool,
193 ) -> Result<LoraRequest, LoadLoraError> {
194 + let allowed_prefixes = runtime_lora_allowed_path_prefixes();
195 + let lora_path = validate_lora_path_access(&lora_path, allowed_prefixes.as_deref())?
196 + .unwrap_or(lora_path);
197 let _guard = self.update_lock.lock().await;
198 if base_model_names.iter().any(|name| name == &lora_name) {
199 return Err(LoadLoraError::BaseModelName { lora_name });
@@ -195,3 +272,98 @@ impl LoraManager {
272 Ok(self.requests.write().await.shift_remove(lora_name).unwrap_or(lora_request))
273 }
274 }
275 +
276 +#[cfg(test)]
277 +mod tests {
278 + use std::fs;
279 + use std::path::PathBuf;
280 + use std::time::{SystemTime, UNIX_EPOCH};
281 +
282 + use super::validate_lora_path_access;
283 +
284 + fn temp_lora_dir(test_name: &str) -> PathBuf {
285 + let suffix = SystemTime::now()
286 + .duration_since(UNIX_EPOCH)
287 + .expect("clock should be after unix epoch")
288 + .as_nanos();
289 + let path = std::env::temp_dir().join(format!(
290 + "vllm-lora-{test_name}-{}-{suffix}",
291 + std::process::id()
292 + ));
293 + fs::create_dir_all(&path).expect("create temp lora dir");
294 + path
295 + }
296 +
297 + #[test]
298 + fn lora_path_allows_hf_repo_ids_without_prefixes() {
299 + assert_eq!(
300 + validate_lora_path_access("org/adapter-a", None).expect("hf repo id should be allowed"),
301 + None
302 + );
303 + }
304 +
305 + #[test]
306 + fn lora_path_rejects_local_paths_without_prefixes() {
307 + assert!(validate_lora_path_access("/tmp/adapter-a", None).is_err());
308 + assert!(validate_lora_path_access("./adapter-a", None).is_err());
309 + assert!(validate_lora_path_access("~/adapter-a", None).is_err());
310 + assert!(validate_lora_path_access("subdir/../../../etc/sensitive", None).is_err());
311 + }
312 +
313 + #[test]
314 + fn lora_path_rejects_existing_bare_relative_paths_without_prefixes() {
315 + let root =
316 + PathBuf::from("target").join(format!("vllm-lora-relative-{}", std::process::id()));
317 + let adapter = root.join("adapter-a");
318 + fs::create_dir_all(&adapter).expect("create relative adapter dir");
319 +
320 + assert!(
321 + validate_lora_path_access(adapter.to_str().expect("utf-8 temp path"), None).is_err()
322 + );
323 +
324 + fs::remove_dir_all(root).ok();
325 + }
326 +
327 + #[test]
328 + fn lora_path_allows_absolute_paths_under_configured_prefixes() {
329 + let root = temp_lora_dir("allowed-prefix");
330 + let allowed = root.join("allowed");
331 + let adapter = allowed.join("adapter-a");
332 + fs::create_dir_all(&adapter).expect("create adapter dir");
333 +
334 + let prefixes = [allowed];
335 + let resolved =
336 + validate_lora_path_access(adapter.to_str().expect("utf-8 temp path"), Some(&prefixes))
337 + .expect("path under configured prefix should be allowed");
338 + assert_eq!(
339 + resolved.as_deref(),
340 + Some(
341 + adapter
342 + .canonicalize()
343 + .expect("canonical adapter")
344 + .to_str()
345 + .expect("utf-8 temp path")
346 + )
347 + );
348 +
349 + fs::remove_dir_all(root).ok();
350 + }
351 +
352 + #[test]
353 + fn lora_path_rejects_parent_escape_from_configured_prefixes() {
354 + let root = temp_lora_dir("parent-escape");
355 + let allowed = root.join("allowed");
356 + let private_adapter = root.join("private").join("adapter-a");
357 + fs::create_dir_all(&allowed).expect("create allowed dir");
358 + fs::create_dir_all(&private_adapter).expect("create private adapter dir");
359 +
360 + let escaped = allowed.join("../private/adapter-a");
361 + let prefixes = [allowed];
362 + assert!(
363 + validate_lora_path_access(escaped.to_str().expect("utf-8 temp path"), Some(&prefixes))
364 + .is_err()
365 + );
366 +
367 + fs::remove_dir_all(root).ok();
368 + }
369 +}
rust/src/server/src/routes/lora.rs
+8 -174
@@ -1,7 +1,6 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 -use std::path::{Component, Path, PathBuf};
4 use std::sync::Arc;
5
6 use axum::extract::State;
@@ -10,13 +9,11 @@ use thiserror_ext::AsReport;
9 use validator::Validate;
10
11 use crate::error::ApiError;
13 -use crate::lora::{LoadLoraError, UnloadLoraError};
12 +use crate::lora::{LoadLoraError, LoraPathAccessError, UnloadLoraError};
13 use crate::routes::openai::utils::types::Normalizable;
14 use crate::routes::openai::utils::validated_json::ValidatedJson;
15 use crate::state::AppState;
16
18 -const RUNTIME_LORA_ALLOWED_PATH_PREFIXES_ENV: &str = "VLLM_RUNTIME_LORA_ALLOWED_PATH_PREFIXES";
19 -
17 #[derive(Debug, Deserialize, Validate)]
18 pub(crate) struct LoadLoraAdapterRequest {
19 lora_name: String,
@@ -38,81 +35,17 @@ pub(crate) struct UnloadLoraAdapterRequest {
35
36 impl Normalizable for UnloadLoraAdapterRequest {}
37
41 -fn runtime_lora_allowed_path_prefixes() -> Option<Vec<PathBuf>> {
42 - let prefixes = std::env::var_os(RUNTIME_LORA_ALLOWED_PATH_PREFIXES_ENV)?;
43 - let prefixes: Vec<_> = std::env::split_paths(&prefixes)
44 - .filter(|path| !path.as_os_str().is_empty())
45 - .collect();
46 - (!prefixes.is_empty()).then_some(prefixes)
47 -}
48 -
49 -fn looks_like_local_lora_path(lora_path: &str) -> bool {
50 - let path = Path::new(lora_path);
51 - path.is_absolute()
52 - || lora_path.starts_with('~')
53 - || lora_path.starts_with('.')
54 - || path.components().any(|component| matches!(component, Component::ParentDir))
55 -}
56 -
57 -fn validate_lora_path_access(
58 - lora_path: &str,
59 - allowed_prefixes: Option<&[PathBuf]>,
60 -) -> Result<Option<String>, ApiError> {
61 - let path = Path::new(lora_path);
62 - if !looks_like_local_lora_path(lora_path) && !path.exists() {
63 - return Ok(None);
64 - }
65 -
66 - let Some(allowed_prefixes) = allowed_prefixes else {
67 - return Err(ApiError::invalid_request(
68 - format!(
69 - "Local LoRA adapter paths require {RUNTIME_LORA_ALLOWED_PATH_PREFIXES_ENV} to be configured."
70 - ),
71 - Some("lora_path"),
72 - ));
73 - };
74 -
75 - if !path.is_absolute() {
76 - return Err(ApiError::invalid_request(
77 - format!(
78 - "Local LoRA adapter paths must be absolute and under one of the prefixes configured by {RUNTIME_LORA_ALLOWED_PATH_PREFIXES_ENV}."
79 - ),
80 - Some("lora_path"),
81 - ));
82 - }
83 -
84 - let canonical_path = path.canonicalize().map_err(|_| {
85 - ApiError::invalid_request(
86 - "Local LoRA adapter path must exist and be accessible.".to_string(),
87 - Some("lora_path"),
88 - )
89 - })?;
90 - let canonical_prefixes = allowed_prefixes
91 - .iter()
92 - .map(|prefix| {
93 - prefix.canonicalize().map_err(|_| {
94 - ApiError::server_error(format!(
95 - "configured {RUNTIME_LORA_ALLOWED_PATH_PREFIXES_ENV} path prefix must exist and be accessible"
96 - ))
97 - })
98 - })
99 - .collect::<Result<Vec<_>, _>>()?;
100 -
101 - if !canonical_prefixes.iter().any(|prefix| canonical_path.starts_with(prefix)) {
102 - return Err(ApiError::invalid_request(
103 - "Local LoRA adapter path is outside the configured allowed prefixes.".to_string(),
104 - Some("lora_path"),
105 - ));
106 - }
107 -
108 - Ok(Some(canonical_path.to_string_lossy().into_owned()))
109 -}
110 -
38 fn load_lora_api_error(error: LoadLoraError) -> ApiError {
39 let message = error.to_report_string();
40 match error {
41 LoadLoraError::Disabled(_) => ApiError::invalid_request(message, None),
42 LoadLoraError::InvalidRequest(_) => ApiError::invalid_request(message, None),
43 + LoadLoraError::PathAccess(LoraPathAccessError::InvalidPath { .. }) => {
44 + ApiError::invalid_request(message, Some("lora_path"))
45 + }
46 + LoadLoraError::PathAccess(LoraPathAccessError::InvalidConfiguration { .. }) => {
47 + ApiError::server_error(message)
48 + }
49 LoadLoraError::AlreadyLoaded { .. } => ApiError::invalid_request(
50 format!(
51 "{message}. If you want to load the adapter in place, set 'load_inplace' to true."
@@ -147,15 +80,11 @@ pub async fn load_lora_adapter(
80 State(state): State<Arc<AppState>>,
81 ValidatedJson(request): ValidatedJson<LoadLoraAdapterRequest>,
82 ) -> Result<String, ApiError> {
150 - let allowed_prefixes = runtime_lora_allowed_path_prefixes();
151 - let lora_path = validate_lora_path_access(&request.lora_path, allowed_prefixes.as_deref())?
152 - .unwrap_or(request.lora_path);
153 -
83 let lora_name = request.lora_name;
84 state
85 .load_lora(
86 lora_name.clone(),
158 - lora_path,
87 + request.lora_path,
88 request.load_inplace,
89 request.is_3d_lora_weight,
90 )
@@ -189,98 +118,3 @@ pub async fn unload_lora_adapter(
118 lora_request.lora_name
119 ))
120 }
192 -
193 -#[cfg(test)]
194 -mod tests {
195 - use std::fs;
196 - use std::path::PathBuf;
197 - use std::time::{SystemTime, UNIX_EPOCH};
198 -
199 - use super::validate_lora_path_access;
200 -
201 - fn temp_lora_dir(test_name: &str) -> PathBuf {
202 - let suffix = SystemTime::now()
203 - .duration_since(UNIX_EPOCH)
204 - .expect("clock should be after unix epoch")
205 - .as_nanos();
206 - let path = std::env::temp_dir().join(format!(
207 - "vllm-lora-{test_name}-{}-{suffix}",
208 - std::process::id()
209 - ));
210 - fs::create_dir_all(&path).expect("create temp lora dir");
211 - path
212 - }
213 -
214 - #[test]
215 - fn lora_path_allows_hf_repo_ids_without_prefixes() {
216 - assert_eq!(
217 - validate_lora_path_access("org/adapter-a", None).expect("hf repo id should be allowed"),
218 - None
219 - );
220 - }
221 -
222 - #[test]
223 - fn lora_path_rejects_local_paths_without_prefixes() {
224 - assert!(validate_lora_path_access("/tmp/adapter-a", None).is_err());
225 - assert!(validate_lora_path_access("./adapter-a", None).is_err());
226 - assert!(validate_lora_path_access("~/adapter-a", None).is_err());
227 - assert!(validate_lora_path_access("subdir/../../../etc/sensitive", None).is_err());
228 - }
229 -
230 - #[test]
231 - fn lora_path_rejects_existing_bare_relative_paths_without_prefixes() {
232 - let root =
233 - PathBuf::from("target").join(format!("vllm-lora-relative-{}", std::process::id()));
234 - let adapter = root.join("adapter-a");
235 - fs::create_dir_all(&adapter).expect("create relative adapter dir");
236 -
237 - assert!(
238 - validate_lora_path_access(adapter.to_str().expect("utf-8 temp path"), None).is_err()
239 - );
240 -
241 - fs::remove_dir_all(root).ok();
242 - }
243 -
244 - #[test]
245 - fn lora_path_allows_absolute_paths_under_configured_prefixes() {
246 - let root = temp_lora_dir("allowed-prefix");
247 - let allowed = root.join("allowed");
248 - let adapter = allowed.join("adapter-a");
249 - fs::create_dir_all(&adapter).expect("create adapter dir");
250 -
251 - let prefixes = [allowed];
252 - let resolved =
253 - validate_lora_path_access(adapter.to_str().expect("utf-8 temp path"), Some(&prefixes))
254 - .expect("path under configured prefix should be allowed");
255 - assert_eq!(
256 - resolved.as_deref(),
257 - Some(
258 - adapter
259 - .canonicalize()
260 - .expect("canonical adapter")
261 - .to_str()
262 - .expect("utf-8 temp path")
263 - )
264 - );
265 -
266 - fs::remove_dir_all(root).ok();
267 - }
268 -
269 - #[test]
270 - fn lora_path_rejects_parent_escape_from_configured_prefixes() {
271 - let root = temp_lora_dir("parent-escape");
272 - let allowed = root.join("allowed");
273 - let private_adapter = root.join("private").join("adapter-a");
274 - fs::create_dir_all(&allowed).expect("create allowed dir");
275 - fs::create_dir_all(&private_adapter).expect("create private adapter dir");
276 -
277 - let escaped = allowed.join("../private/adapter-a");
278 - let prefixes = [allowed];
279 - assert!(
280 - validate_lora_path_access(escaped.to_str().expect("utf-8 temp path"), Some(&prefixes))
281 - .is_err()
282 - );
283 -
284 - fs::remove_dir_all(root).ok();
285 - }
286 -}