[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
-}