44# This source code is licensed under the terms described in the LICENSE file in
55# the root directory of this source tree.
66
7+ """Local-filesystem files provider with defense-in-depth path security.
8+
9+ Security boundaries
10+ -------------------
11+ * **Indirect references** -- Clients address files via opaque IDs
12+ (``file-<1-64 hex chars>``), never raw paths.
13+ * **ID format validation** -- ``_validate_file_id`` rejects any ID that does
14+ not match ``^file-[0-9a-f]{1,64}$``, blocking path separators, traversal
15+ sequences, null bytes, and any non-hex characters.
16+ * **Path containment** -- ``_validate_path_containment`` resolves symlinks and
17+ ``..`` components, then verifies the result stays inside ``storage_dir``.
18+ * **Filename sanitization** -- ``Content-Disposition`` filenames are stripped of
19+ path separators, traversal sequences, null bytes, and non-ASCII-safe chars.
20+ """
21+
22+ import re
723import time
824import uuid
925from pathlib import Path
1632from llama_stack .core .storage .sqlstore .authorized_sqlstore import AuthorizedSqlStore
1733from llama_stack .core .storage .sqlstore .sqlstore import sqlstore_impl
1834from llama_stack .log import get_logger
35+ from llama_stack .providers .utils .files .sanitize import sanitize_content_disposition_filename
1936from llama_stack_api import (
2037 DeleteFileRequest ,
2138 Files ,
39+ InvalidParameterError ,
2240 ListFilesRequest ,
2341 ListOpenAIFileResponse ,
2442 OpenAIFileDeleteResponse ,
@@ -67,16 +85,46 @@ async def initialize(self) -> None:
6785 async def shutdown (self ) -> None :
6886 pass
6987
88+ _FILE_ID_PATTERN = re .compile (r"^file-[0-9a-f]{1,64}$" )
89+
7090 def _generate_file_id (self ) -> str :
7191 """Generate a unique file ID for OpenAI API."""
7292 return generate_object_id ("file" , lambda : f"file-{ uuid .uuid4 ().hex } " )
7393
94+ def _validate_file_id (self , file_id : str ) -> None :
95+ """Validate that file_id contains only safe characters (hex digits) after the ``file-`` prefix."""
96+ if not self ._FILE_ID_PATTERN .fullmatch (file_id ):
97+ raise InvalidParameterError (
98+ "file_id" , file_id , "Must match format 'file-' followed by 1-64 hex characters."
99+ )
100+
101+ def _validate_path_containment (self , file_path : Path ) -> Path :
102+ """Canonicalize *file_path* and verify it resides inside storage_dir.
103+
104+ Returns the resolved (absolute, symlink-free) path so callers operate
105+ on the canonical location. Raises ``InvalidParameterError`` when the
106+ resolved path escapes the storage directory boundary.
107+ """
108+ resolved = file_path .resolve ()
109+ storage_dir = Path (self .config .storage_dir ).resolve ()
110+ if not resolved .is_relative_to (storage_dir ):
111+ raise InvalidParameterError (
112+ "file_path" ,
113+ file_path .name ,
114+ "File path does not resolve to a valid storage location." ,
115+ )
116+ return resolved
117+
74118 def _get_file_path (self , file_id : str ) -> Path :
75119 """Get the filesystem path for a file ID."""
76- return Path (self .config .storage_dir ) / file_id
120+ self ._validate_file_id (file_id )
121+ path = Path (self .config .storage_dir ) / file_id
122+ return self ._validate_path_containment (path )
77123
78124 async def _lookup_file_id (self , file_id : str , action : Action = Action .READ ) -> tuple [OpenAIFileObject , Path ]:
79125 """Look up a OpenAIFileObject and filesystem path from its ID."""
126+ self ._validate_file_id (file_id )
127+
80128 if not self .sql_store :
81129 raise RuntimeError ("Files provider not initialized" )
82130
@@ -85,6 +133,7 @@ async def _lookup_file_id(self, file_id: str, action: Action = Action.READ) -> t
85133 raise OpenAIFileObjectNotFoundError (file_id )
86134
87135 file_path = Path (row .pop ("file_path" ))
136+ file_path = self ._validate_path_containment (file_path )
88137 return OpenAIFileObject (** row ), file_path
89138
90139 # OpenAI Files API Implementation
@@ -224,5 +273,7 @@ async def openai_retrieve_file_content(self, request: RetrieveFileContentRequest
224273 return Response (
225274 content = file_path .read_bytes (),
226275 media_type = "application/octet-stream" ,
227- headers = {"Content-Disposition" : f'attachment; filename="{ file_obj .filename } "' },
276+ headers = {
277+ "Content-Disposition" : f'attachment; filename="{ sanitize_content_disposition_filename (file_obj .filename )} "'
278+ },
228279 )
0 commit comments