Skip to content

Commit 18a80df

Browse files
committed
auth: defer to LoRA dispatch when alias shadows LoRA claim
Bugbot/Codex flagged: if a JWT lists the same string in both model_aliases and lora (or as a <lora>-... step adapter), the prior canonical_for_alias would rewrite the request to the base model, silently swapping a LoRA call for a base-model call. Fix is to consult the LoRA branch before rewriting — LoRA matches now take precedence, so the original name reaches vLLM and dispatch works. Adds tests for both the exact-lora and step-versioned-lora collisions.
1 parent 5f2c4ad commit 18a80df

1 file changed

Lines changed: 55 additions & 1 deletion

File tree

src/auth.rs

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,13 @@ impl RftClaims {
125125
/// the canonical model name (`self.model`) so the request body can
126126
/// be rewritten before forwarding to vLLM. Returns `None` if the
127127
/// request already targets the base model or a LoRA — those need to
128-
/// pass through unchanged.
128+
/// pass through unchanged so vLLM can dispatch the LoRA adapter.
129+
///
130+
/// LoRA-shadowing is the load-bearing case: a pathological JWT could
131+
/// list the same string in both `lora` (or as a `<lora>-…` step
132+
/// adapter) and `model_aliases`. Rewriting such a request to the
133+
/// base model would silently swap a LoRA call for a base-model call,
134+
/// so we treat LoRA matches as taking precedence over alias matches.
129135
pub fn canonical_for_alias(&self, requested: &str) -> Option<String> {
130136
if !self.is_model_alias(requested) {
131137
return None;
@@ -134,8 +140,31 @@ impl RftClaims {
134140
if base.is_empty() || base == requested {
135141
return None;
136142
}
143+
if self.matches_lora(requested) {
144+
return None;
145+
}
137146
Some(base.to_string())
138147
}
148+
149+
/// Whether `requested` is authorized via the `lora` claim — either
150+
/// an exact match or a `<lora>-<suffix>` step adapter. Mirrors the
151+
/// LoRA branch of `allows_model` so `canonical_for_alias` can defer
152+
/// to LoRA dispatch when both branches would otherwise authorize.
153+
fn matches_lora(&self, requested: &str) -> bool {
154+
let Some(lora) = self.lora.as_deref() else {
155+
return false;
156+
};
157+
if lora.is_empty() {
158+
return false;
159+
}
160+
if requested == lora {
161+
return true;
162+
}
163+
if let Some(rest) = requested.strip_prefix(lora) {
164+
return rest.starts_with('-');
165+
}
166+
false
167+
}
139168
}
140169

141170
#[cfg(test)]
@@ -257,4 +286,29 @@ mod tests {
257286
assert!(c.allows_model("meta-llama/Llama-3.2-1B-Instruct"));
258287
assert_eq!(c.canonical_for_alias("meta-llama/Llama-3.2-1B-Instruct"), None);
259288
}
289+
290+
#[test]
291+
fn alias_matching_lora_does_not_rewrite() {
292+
// Pathological config: an alias entry collides with the lora
293+
// claim. Rewriting would silently swap the LoRA call for a
294+
// base-model call, so LoRA matches take precedence.
295+
let c = claims_with_aliases(
296+
Some("meta-llama/Llama-3.2-1B-Instruct"),
297+
Some("rft-abc"),
298+
&["rft-abc"],
299+
);
300+
assert!(c.allows_model("rft-abc"));
301+
assert_eq!(c.canonical_for_alias("rft-abc"), None);
302+
}
303+
304+
#[test]
305+
fn alias_matching_step_versioned_lora_does_not_rewrite() {
306+
let c = claims_with_aliases(
307+
Some("meta-llama/Llama-3.2-1B-Instruct"),
308+
Some("rft-abc"),
309+
&["rft-abc-step-42"],
310+
);
311+
assert!(c.allows_model("rft-abc-step-42"));
312+
assert_eq!(c.canonical_for_alias("rft-abc-step-42"), None);
313+
}
260314
}

0 commit comments

Comments
 (0)