Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions TECHNICAL_REPORTS/pr-2282-native-video-temp-hardening.en.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Technical Report: PR #2282 - Native-Video Temporary File Hardening

**Date**: 2026-10-13
**Status**: Ready for review
**Languages**: Rust, Markdown
**Risk Level**: Medium

## Executive Summary

Native-video data URI and HTTP payloads are now stored as create-new 0600 files inside per-request 0700 directories. The directory guard is created inside the blocking closure before any byte is written, so request cancellation and partial-write failures remove the complete private directory after the writer finishes.

Native-video request preparation now receives the same immutable `MLXCEL_VIDEO_DIR_ALLOWLIST` snapshot as the frame fallback. Single-server, router-loaded model, and in-process startup paths all install this snapshot, and operator documentation states that environment or directory changes require a restart.

## 1. Problem Statement

The native-video resolver previously wrote directly under the system temporary directory with default permissions and created its cleanup guard only after a successful asynchronous write. A dropped request could therefore leave a readable clip behind, while a failed write could leave partial content. It also re-read and canonicalized the directory allowlist per request, producing behavior different from the frame fallback.

## 2. Technical Review

The implementation reuses `PrivateTempDir`, which creates its directory with mode 0700 and files with mode 0600 and `create_new`. The blocking closure owns the guard before invoking the writer; if its awaiting future is aborted, the closure completes and its unclaimed guarded result is dropped. Owned payload buffers move into the closure without an additional payload-sized copy.

The startup allowlist is passed explicitly into request preparation. A review found that the public in-process server installed media capability flags without the allowlist; a shared startup helper now installs both together, and a constructor regression test covers that integration.

## 3. Technical Decisions

### Keep synchronous file creation on the blocking pool

`PrivateTempDir` uses synchronous filesystem operations to guarantee exclusive creation and fixed Unix modes. Running the entire guard-create/write/result sequence in `spawn_blocking` avoids blocking Tokio workers while preserving one ownership boundary for cleanup.

### Treat the allowlist as startup configuration

All native and frame-fallback requests use the canonical directory list captured during model startup. A directory added later remains unavailable until restart, so request behavior no longer depends on when a path happens to appear.

## 7. Change Summary

| Category | Count | Summary |
|---|---:|---|
| Security | 2 | Private temporary storage and cancellation-safe cleanup |
| Integration | 1 | Startup allowlist propagated through every production server constructor |
| Tests | 18 | Temp lifecycle, startup snapshot, in-process constructor, and existing resolver coverage |
| Documentation | 2 | Restart requirements for native and fallback video paths |

## Appendix: Validation Evidence

- Implementation source commit: `790d981647901fdb690e223749a0162ed8f632f6`.
- Candidate `mlxcel-server` SHA-256: `fc7c3ebc3f1948fcd2dd9d535428123535a9cc12fd88b2139f576f87f635f9b4`.
- Focused CUDA-feature library tests passed: 3 temp lifecycle tests, 1 request allowlist snapshot test, 13 existing resolver tests, and 1 in-process startup test.
- A Qwen2.5-VL 3B 4-bit native data-video request answered `The main color in the video is red.` with 83 prompt tokens and 9 completion tokens. The private temp root was empty before and after the request, matching the baseline answer and counts.
- Contract gates passed with 123 Python checks and 49 WebUI checks. Formatting and diff checks passed.
- Independent implementation, security, and finalization reviews reported no remaining findings after the in-process constructor fix.

The end-to-end model run used Linux CUDA. macOS Metal inference was not run locally.
52 changes: 52 additions & 0 deletions TECHNICAL_REPORTS/pr-2282-native-video-temp-hardening.ko.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# 기술 보고서: PR #2282 - Native-Video 임시 파일 보안 강화

**작성일**: 2026-10-13
**상태**: 리뷰 준비 완료
**언어**: Rust, Markdown
**위험도**: 중간

## 요약

Native-video data URI와 HTTP payload를 요청별 0700 디렉터리 안의 create-new 0600 파일에 저장합니다. Blocking closure가 어떤 바이트도 쓰기 전에 디렉터리 guard를 생성하므로, 요청 취소와 부분 쓰기 실패가 발생해도 writer가 끝난 뒤 private 디렉터리 전체가 제거됩니다.

Native-video 요청 준비는 이제 frame fallback과 동일한 불변 `MLXCEL_VIDEO_DIR_ALLOWLIST` snapshot을 받습니다. 단일 서버, router가 로드한 모델, in-process startup 경로가 모두 이 snapshot을 설치하며, 환경 변수나 디렉터리를 변경한 뒤에는 재시작해야 한다고 운영 문서에 명시했습니다.

## 1. 문제 정의

기존 native-video resolver는 system temporary directory 바로 아래에 기본 권한으로 파일을 쓰고, 비동기 쓰기가 성공한 뒤에야 cleanup guard를 만들었습니다. 따라서 요청이 중단되면 읽을 수 있는 clip이 남을 수 있었고, 쓰기가 실패하면 일부 내용이 남을 수 있었습니다. 또한 요청마다 directory allowlist를 다시 읽고 canonicalize하여 frame fallback과 다른 동작을 보였습니다.

## 2. 기술적 검토 사항

구현은 디렉터리를 0700으로, 파일을 0600과 `create_new`로 만드는 `PrivateTempDir`를 재사용합니다. Blocking closure는 writer를 호출하기 전에 guard를 소유합니다. 대기 중인 future가 중단되면 closure가 완료된 뒤 수신되지 않은 guarded result가 drop됩니다. Payload buffer는 payload 크기만큼 다시 복사하지 않고 closure로 이동합니다.

Startup allowlist를 request preparation에 명시적으로 전달합니다. 리뷰에서 public in-process server가 media capability만 설치하고 allowlist는 설치하지 않는 통합 누락을 발견했으며, 이제 공통 startup helper가 둘을 함께 설치하고 constructor 회귀 테스트가 이 경로를 검증합니다.

## 3. 기술적 선택과 그 이유

### 동기 파일 생성을 blocking pool에서 수행

`PrivateTempDir`는 배타적 생성과 고정된 Unix mode를 보장하기 위해 동기 파일 시스템 연산을 사용합니다. Guard 생성, 쓰기, 결과 반환 전체를 `spawn_blocking`에서 실행하여 Tokio worker를 막지 않으면서 cleanup을 위한 하나의 ownership 경계를 유지합니다.

### Allowlist를 startup 설정으로 취급

Native 경로와 frame fallback의 모든 요청은 모델 startup 중 canonicalize한 directory 목록을 사용합니다. 이후 추가된 디렉터리는 재시작 전까지 허용되지 않으므로, 요청 동작이 경로 생성 시점에 따라 달라지지 않습니다.

## 7. 변경 요약

| 분류 | 수 | 요약 |
|---|---:|---|
| 보안 | 2 | Private 임시 저장소와 취소 안전 cleanup |
| 통합 | 1 | 모든 production server constructor에 startup allowlist 전달 |
| 테스트 | 18 | Temp lifecycle, startup snapshot, in-process constructor, 기존 resolver 검증 |
| 문서 | 2 | Native 및 fallback video 경로의 재시작 요구사항 |

## 부록: 검증 증거

- 구현 source commit: `790d981647901fdb690e223749a0162ed8f632f6`.
- Candidate `mlxcel-server` SHA-256: `fc7c3ebc3f1948fcd2dd9d535428123535a9cc12fd88b2139f576f87f635f9b4`.
- CUDA feature를 사용한 집중 library test가 통과했습니다. Temp lifecycle 3개, request allowlist snapshot 1개, 기존 resolver 13개, in-process startup 1개입니다.
- Qwen2.5-VL 3B 4-bit native data-video 요청이 `The main color in the video is red.`라고 답했습니다. Prompt token은 83개, completion token은 9개였고 private temp root는 요청 전후 모두 비어 있었습니다. Baseline의 답변과 token 수가 같습니다.
- Contract gate는 Python 123개와 WebUI 49개 검증을 통과했습니다. Formatting 및 diff 검사도 통과했습니다.
- 독립 구현, 보안, finalization 리뷰는 in-process constructor 수정을 반영한 뒤 남은 문제를 보고하지 않았습니다.

End-to-end 모델 실행은 Linux CUDA에서 수행했습니다. macOS Metal inference는 로컬에서 실행하지 않았습니다.
2 changes: 1 addition & 1 deletion docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,7 @@ both be on `PATH` and must be **ffmpeg 5.0 (2022) or newer**. See

| Variable | Values | Default | Notes |
|----------|--------|---------|-------|
| `MLXCEL_VIDEO_DIR_ALLOWLIST` | comma-separated directories | unset | Local `video_url` file paths are rejected unless they resolve under one of these canonicalized directories. Keep directories owner-writable only; group/world-writable entries warn at startup. The video-frames fallback resolves the list once when the server starts (once per model the router loads), so an entry that does not exist yet at that point is dropped with a warning until the next restart; the native-video families still read it per request. |
| `MLXCEL_VIDEO_DIR_ALLOWLIST` | comma-separated directories | unset | Local `video_url` file paths are rejected unless they resolve under one of these canonicalized directories. Keep directories owner-writable only; group/world-writable entries warn at startup. Both native-video families and the video-frames fallback resolve the list once when the server starts (once per model the router loads), so an entry that does not exist yet at that point is dropped with a warning until the next restart. Restart the server after changing the variable or creating a listed directory. |
| `MLXCEL_VIDEO_MAX_PIXELS` | unsigned integer | `16777216` | Rejects source videos whose `width × height` exceeds the cap. |
| `MLXCEL_VIDEO_MAX_DURATION_SEC` | float seconds | `600` | Rejects source videos longer than the cap. |
| `MLXCEL_VIDEO_MAX_PNG_FRAME_BYTES` | unsigned integer bytes | `268435456` | Per-frame cap for the ffmpeg PNG stream splitter. |
Expand Down
2 changes: 1 addition & 1 deletion docs/llama-server-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,7 @@ model gemma-3-4b-it-4bit has no native video path; sending 8 of 8 sampled frames

That `info` line is the only signal that the substitution happened; `GET /props` reports `video: true` either way, because either way the block is accepted. The families with a native path (Gemma 4 VL, Gemma 4 Unified, Inkling, Kimi-VL / Kimi-VL 2.5, and the Qwen-VL generations) are untouched by this and never log it. Muse Glimmer is excluded on purpose: the CLI refuses `--video` for that family by name, and admitting the clip on the HTTP boundary alone would leave the two fronts disagreeing about one checkpoint. A checkpoint with no vision tower keeps its 501.

Two limits are worth knowing. The frames spend the ordinary per-request image budget, so `--video-max-frames` above `--max-images` (default 16 for both) is refused with a message naming the frame count, the caller's own image count and the limit rather than with a bare image-count error. And the substitution carries no temporal information: the model receives ordered pictures and the sentence saying they are one clip, not frame timestamps or motion, so a reply can describe what changes across the frames but cannot be asked about speed or duration. A local `file://` path still has to sit inside a directory named by `MLXCEL_VIDEO_DIR_ALLOWLIST`, which is fail-closed and resolved once at startup. A client that disconnects stops the substitution before the next clip is resolved and before a queued decode starts; a clip already decoding runs to completion.
Two limits are worth knowing. The frames spend the ordinary per-request image budget, so `--video-max-frames` above `--max-images` (default 16 for both) is refused with a message naming the frame count, the caller's own image count and the limit rather than with a bare image-count error. And the substitution carries no temporal information: the model receives ordered pictures and the sentence saying they are one clip, not frame timestamps or motion, so a reply can describe what changes across the frames but cannot be asked about speed or duration. A local `file://` path still has to sit inside a directory named by `MLXCEL_VIDEO_DIR_ALLOWLIST`, which is fail-closed and resolved once at startup for both native-video families and this fallback. Restart the server after changing the variable or creating a listed directory; request handling does not re-read it. A client that disconnects stops the substitution before the next clip is resolved and before a queued decode starts; a clip already decoding runs to completion.

### `--image-min-tokens` / `--image-max-tokens` move real pixel bounds

Expand Down
3 changes: 2 additions & 1 deletion src/multimodal/video.rs
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,8 @@ impl Drop for TempFile {
/// Built on `std` rather than the `tempfile` crate, which is a dev-dependency
/// only.
///
/// Used by: `commands::generate::CliVideoFrames` (CLI video-frames fallback).
/// Used by: `commands::generate::CliVideoFrames` (CLI video-frames fallback)
/// and `server::media` (native-video data URI and HTTP materialization).
#[derive(Debug)]
pub struct PrivateTempDir {
path: PathBuf,
Expand Down
43 changes: 38 additions & 5 deletions src/server/chat_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ use super::chat_template_kwargs::{
strip_think_block,
};
use super::media::{
MediaRequestMetadata, ResolvedVideo, extract_chat_video_paths, try_extract_chat_audio_data,
try_extract_chat_image_data,
MediaRequestMetadata, ResolvedVideo, extract_chat_video_paths_with_allowlist,
try_extract_chat_audio_data, try_extract_chat_image_data,
};
use super::prompt_cache::key::resolve_session_key;
use super::types::request::{
Expand Down Expand Up @@ -768,14 +768,42 @@ pub(crate) async fn prepare_chat_request(
request: &ChatCompletionRequest,
server_default_kwargs: Option<&ChatTemplateKwargs>,
) -> Result<PreparedChatRequest> {
prepare_chat_request_with_cache(
prepare_chat_request_with_cache_and_video_allowlist(
processor,
request,
server_default_kwargs,
false,
false,
false,
&crate::tokenizer::ThinkingMarkers::default(),
&[],
)
.await
}

/// Test-only compatibility entry point for focused renderer tests that do not
/// construct [`super::state::AppState`]. Production routes must call
/// [`prepare_chat_request_with_cache_and_video_allowlist`] with the immutable
/// startup snapshot.
#[cfg(test)]
pub(crate) async fn prepare_chat_request_with_cache(
processor: &ChatTemplateProcessor,
request: &ChatCompletionRequest,
server_default_kwargs: Option<&ChatTemplateKwargs>,
prompt_cache_enabled: bool,
snapshot_reuse_capable: bool,
prefill_assistant: bool,
thinking_markers: &crate::tokenizer::ThinkingMarkers,
) -> Result<PreparedChatRequest> {
prepare_chat_request_with_cache_and_video_allowlist(
processor,
request,
server_default_kwargs,
prompt_cache_enabled,
snapshot_reuse_capable,
prefill_assistant,
thinking_markers,
&[],
)
.await
}
Expand All @@ -792,14 +820,15 @@ pub(crate) async fn prepare_chat_request(
///
/// When `prompt_cache_enabled` is `false` this is identical to the legacy
/// [`prepare_chat_request`].
pub(crate) async fn prepare_chat_request_with_cache(
pub(crate) async fn prepare_chat_request_with_cache_and_video_allowlist(
processor: &ChatTemplateProcessor,
request: &ChatCompletionRequest,
server_default_kwargs: Option<&ChatTemplateKwargs>,
prompt_cache_enabled: bool,
snapshot_reuse_capable: bool,
prefill_assistant: bool,
thinking_markers: &crate::tokenizer::ThinkingMarkers,
video_dir_allowlist: &[PathBuf],
) -> Result<PreparedChatRequest> {
// Refuse rather than fall back to the generic template when the loaded
// tokenizer's vocabulary family is Kimi K3 but no native renderer could be
Expand Down Expand Up @@ -1070,7 +1099,7 @@ pub(crate) async fn prepare_chat_request_with_cache(
let (image_data, audio_data, videos) = tokio::join!(
try_extract_chat_image_data(request),
try_extract_chat_audio_data(request),
extract_chat_video_paths(request),
extract_chat_video_paths_with_allowlist(request, video_dir_allowlist),
);
let image_data = image_data?;
let audio_data = audio_data?;
Expand Down Expand Up @@ -2175,3 +2204,7 @@ mod tests;
#[cfg(test)]
#[path = "chat_request_reasoning_content_tests.rs"]
mod reasoning_content_tests;

#[cfg(test)]
#[path = "chat_request_video_allowlist_tests.rs"]
mod video_allowlist_tests;
112 changes: 112 additions & 0 deletions src/server/chat_request_video_allowlist_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Copyright 2025-2026 Lablup Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use std::fs;

use super::prepare_chat_request_with_cache_and_video_allowlist;
use crate::server::chat_template::ChatTemplateProcessor;
use crate::server::types::request::VideoUrl;
use crate::server::types::{
ChatCompletionRequest, ContentPart, Message, MessageContent, Role, SamplingParams,
};

fn video_request(path: &std::path::Path) -> ChatCompletionRequest {
ChatCompletionRequest {
model: "test-model".to_string(),
messages: vec![Message {
role: Role::User,
content: MessageContent::Parts(vec![ContentPart::VideoUrl {
video_url: VideoUrl {
url: path.to_string_lossy().into_owned(),
fps: None,
},
}]),
name: None,
tool_call_id: None,
reasoning: None,
tool_calls: None,
}],
stream: false,
stream_options: None,
logprobs: None,
top_logprobs: None,
tools: None,
tool_choice: None,
parallel_tool_calls: None,
chat_template_kwargs: None,
extra_body: None,
prompt_cache_key: None,
cache_prompt: None,
user: None,
reasoning_effort: None,
extra_body_fields: serde_json::Map::new(),
response_format: None,
params: SamplingParams::default(),
}
}

#[tokio::test]
async fn native_video_preparation_uses_startup_allowlist_snapshot() {
let startup_dir =
std::env::temp_dir().join(format!("mlxcel-video-startup-{}", uuid::Uuid::new_v4()));
fs::create_dir(&startup_dir).unwrap();
let startup_dir = fs::canonicalize(startup_dir).unwrap();
let startup_video = startup_dir.join("startup.mp4");
fs::write(&startup_video, b"startup video").unwrap();
let startup_allowlist = vec![startup_dir.clone()];

let later_dir =
std::env::temp_dir().join(format!("mlxcel-video-later-{}", uuid::Uuid::new_v4()));
fs::create_dir(&later_dir).unwrap();
let later_video = later_dir.join("later.mp4");
fs::write(&later_video, b"later video").unwrap();

let processor = ChatTemplateProcessor::with_template("ok".to_string());
let markers = crate::tokenizer::ThinkingMarkers::default();
let prepared_at_startup = prepare_chat_request_with_cache_and_video_allowlist(
&processor,
&video_request(&startup_video),
None,
false,
false,
false,
&markers,
&startup_allowlist,
)
.await
.unwrap();
assert_eq!(prepared_at_startup.videos.len(), 1);
drop(prepared_at_startup);

let prepared_after_new_directory = prepare_chat_request_with_cache_and_video_allowlist(
&processor,
&video_request(&later_video),
None,
false,
false,
false,
&markers,
&startup_allowlist,
)
.await
.unwrap();
assert!(
prepared_after_new_directory.videos.is_empty(),
"a directory created after startup is not accepted until restart"
);
drop(prepared_after_new_directory);

fs::remove_dir_all(startup_dir).unwrap();
fs::remove_dir_all(later_dir).unwrap();
}
Loading
Loading