Skip to content

Commit 852cf5c

Browse files
committed
fix(security): add path traversal and header injection defenses to files and conversations
Signed-off-by: Doug Edgar <dedgar@redhat.com>
1 parent c604b90 commit 852cf5c

13 files changed

Lines changed: 422 additions & 27 deletions

File tree

src/llama_stack/core/conversations/conversations.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from pydantic import BaseModel, TypeAdapter
1212

13+
from llama_stack.core.conversations.validation import CONVERSATION_ID_PATTERN
1314
from llama_stack.core.datatypes import AccessRule, StackConfig
1415
from llama_stack.core.storage.sqlstore.authorized_sqlstore import AuthorizedSqlStore
1516
from llama_stack.core.storage.sqlstore.sqlstore import sqlstore_impl
@@ -182,9 +183,13 @@ async def openai_delete_conversation(self, request: DeleteConversationRequest) -
182183
return ConversationDeletedResource(id=request.conversation_id)
183184

184185
def _validate_conversation_id(self, conversation_id: str) -> None:
185-
"""Validate conversation ID format."""
186-
if not conversation_id.startswith("conv_"):
187-
raise InvalidParameterError("conversation_id", conversation_id, "Conversation ID must begin with 'conv_'.")
186+
"""Validate conversation ID format matches ``conv_`` + 48 hex chars."""
187+
if not CONVERSATION_ID_PATTERN.fullmatch(conversation_id):
188+
raise InvalidParameterError(
189+
"conversation_id",
190+
conversation_id,
191+
"Conversation ID must match format 'conv_' followed by 48 hex characters.",
192+
)
188193

189194
def _get_or_generate_item_id(self, item: ConversationItem, item_dict: dict) -> str:
190195
"""Get existing item ID or generate one if missing."""
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the terms described in the LICENSE file in
5+
# the root directory of this source tree.
6+
7+
import re
8+
9+
CONVERSATION_ID_PATTERN = re.compile(r"^conv_[0-9a-f]{48}$")

src/llama_stack/providers/inline/agents/meta_reference/responses/openai_responses.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from pydantic import BaseModel, TypeAdapter
1414

15+
from llama_stack.core.conversations.validation import CONVERSATION_ID_PATTERN
1516
from llama_stack.log import get_logger
1617
from llama_stack.providers.utils.responses.responses_store import (
1718
ResponsesStore,
@@ -617,8 +618,12 @@ async def create_openai_response(
617618
"Provide only one of these parameters.",
618619
)
619620

620-
if not conversation.startswith("conv_"):
621-
raise InvalidParameterError("conversation", conversation, "Expected an ID that begins with 'conv_'.")
621+
if not CONVERSATION_ID_PATTERN.fullmatch(conversation):
622+
raise InvalidParameterError(
623+
"conversation",
624+
conversation,
625+
"Must match format 'conv_' followed by 48 hex characters.",
626+
)
622627

623628
if max_tool_calls is not None and max_tool_calls < 1:
624629
raise ValueError(f"Invalid {max_tool_calls=}; should be >= 1")

src/llama_stack/providers/inline/files/localfs/files.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@
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
723
import time
824
import uuid
925
from pathlib import Path
@@ -16,9 +32,11 @@
1632
from llama_stack.core.storage.sqlstore.authorized_sqlstore import AuthorizedSqlStore
1733
from llama_stack.core.storage.sqlstore.sqlstore import sqlstore_impl
1834
from llama_stack.log import get_logger
35+
from llama_stack.providers.utils.files.sanitize import sanitize_content_disposition_filename
1936
from 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
)

src/llama_stack/providers/remote/files/openai/files.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from llama_stack.core.datatypes import AccessRule
1414
from llama_stack.core.storage.sqlstore.authorized_sqlstore import AuthorizedSqlStore
1515
from llama_stack.core.storage.sqlstore.sqlstore import sqlstore_impl
16+
from llama_stack.providers.utils.files.sanitize import sanitize_content_disposition_filename
1617
from llama_stack_api import (
1718
DeleteFileRequest,
1819
ExpiresAfter,
@@ -249,5 +250,7 @@ async def openai_retrieve_file_content(self, request: RetrieveFileContentRequest
249250
return Response(
250251
content=file_content,
251252
media_type="application/octet-stream",
252-
headers={"Content-Disposition": f'attachment; filename="{row["filename"]}"'},
253+
headers={
254+
"Content-Disposition": f'attachment; filename="{sanitize_content_disposition_filename(row["filename"])}"'
255+
},
253256
)

src/llama_stack/providers/remote/files/s3/files.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from llama_stack.core.id_generation import generate_object_id
2121
from llama_stack.core.storage.sqlstore.authorized_sqlstore import AuthorizedSqlStore
2222
from llama_stack.core.storage.sqlstore.sqlstore import sqlstore_impl
23+
from llama_stack.providers.utils.files.sanitize import sanitize_content_disposition_filename
2324
from llama_stack_api import (
2425
ExpiresAfter,
2526
Files,
@@ -331,5 +332,7 @@ async def openai_retrieve_file_content(self, request: RetrieveFileContentRequest
331332
return Response(
332333
content=content,
333334
media_type="application/octet-stream",
334-
headers={"Content-Disposition": f'attachment; filename="{row["filename"]}"'},
335+
headers={
336+
"Content-Disposition": f'attachment; filename="{sanitize_content_disposition_filename(row["filename"])}"'
337+
},
335338
)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the terms described in the LICENSE file in
5+
# the root directory of this source tree.
6+
7+
import re
8+
9+
_SAFE_FILENAME_PATTERN = re.compile(r"[^a-zA-Z0-9._-]")
10+
11+
12+
def sanitize_content_disposition_filename(filename: str) -> str:
13+
"""Sanitize *filename* for safe inclusion in a ``Content-Disposition`` header.
14+
15+
The function strips null bytes and path separators, collapses ``..``
16+
sequences, prevents hidden-file names (leading dot), and replaces any
17+
remaining characters outside an ASCII alphanumeric allowlist. Returns
18+
``"download"`` when the result would otherwise be empty.
19+
"""
20+
filename = filename.replace("\x00", "")
21+
filename = filename.replace('"', "_") # prevent Content-Disposition header injection
22+
filename = filename.replace("/", "_").replace("\\", "_")
23+
filename = filename.replace("..", "_")
24+
if filename.startswith("."):
25+
filename = "_" + filename[1:]
26+
filename = _SAFE_FILENAME_PATTERN.sub("_", filename)
27+
return filename or "download"

tests/integration/responses/test_conversation_responses.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,12 @@ def test_conversation_error_handling(self, openai_client, text_model_id):
9494
)
9595
assert any(word in str(exc_info.value).lower() for word in ["conv", "invalid", "bad"])
9696

97-
# Nonexistent conversation ID
97+
# Nonexistent conversation ID (must be valid format to reach the DB lookup)
9898
with pytest.raises(Exception) as exc_info:
9999
openai_client.responses.create(
100100
model=text_model_id,
101101
input=[{"role": "user", "content": "Hello"}],
102-
conversation="conv_nonexistent123",
102+
conversation="conv_" + "0" * 48,
103103
)
104104
assert any(word in str(exc_info.value).lower() for word in ["not found", "404"])
105105

tests/integration/responses/test_responses_errors.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ def test_nonexistent_conversation_raises_not_found_error(self, openai_client, te
250250
Test that referencing a nonexistent conversation returns 404 and triggers
251251
openai.NotFoundError in the SDK.
252252
"""
253-
conversation_id = "conv_nonexistent123456"
253+
conversation_id = "conv_" + "0" * 48
254254
with pytest.raises(NotFoundError) as exc_info:
255255
openai_client.responses.create(
256256
model=text_model_id,

tests/unit/conversations/test_conversations.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,31 +108,32 @@ async def test_conversation_items(service):
108108

109109

110110
async def test_invalid_conversation_id(service):
111-
with pytest.raises(InvalidParameterError, match="Conversation ID must begin with 'conv_'"):
111+
with pytest.raises(InvalidParameterError, match="Conversation ID must match format"):
112112
await service.get_conversation(GetConversationRequest(conversation_id="invalid_id"))
113113

114114

115115
async def test_invalid_conversation_id_on_retrieve(service):
116-
with pytest.raises(InvalidParameterError, match="Conversation ID must begin with 'conv_'"):
116+
with pytest.raises(InvalidParameterError, match="Conversation ID must match format"):
117117
await service.retrieve(RetrieveItemRequest(conversation_id="bad_id", item_id="item_123"))
118118

119119

120120
async def test_invalid_conversation_id_on_update(service):
121121
from llama_stack_api.conversations import UpdateConversationRequest
122122

123-
with pytest.raises(InvalidParameterError, match="Conversation ID must begin with 'conv_'"):
123+
with pytest.raises(InvalidParameterError, match="Conversation ID must match format"):
124124
await service.update_conversation("bad_id", UpdateConversationRequest(metadata={}))
125125

126126

127127
async def test_invalid_conversation_id_on_delete(service):
128-
with pytest.raises(InvalidParameterError, match="Conversation ID must begin with 'conv_'"):
128+
with pytest.raises(InvalidParameterError, match="Conversation ID must match format"):
129129
await service.openai_delete_conversation(DeleteConversationRequest(conversation_id="bad_id"))
130130

131131

132132
async def test_nonexistent_conversation_raises_conversation_not_found(service):
133133
"""Test that get_conversation raises ConversationNotFoundError for nonexistent ID."""
134-
with pytest.raises(ConversationNotFoundError, match="Conversation 'conv_nonexistent' not found"):
135-
await service.get_conversation(GetConversationRequest(conversation_id="conv_nonexistent"))
134+
nonexistent_id = "conv_" + "0" * 48
135+
with pytest.raises(ConversationNotFoundError, match=f"Conversation '{nonexistent_id}' not found"):
136+
await service.get_conversation(GetConversationRequest(conversation_id=nonexistent_id))
136137

137138

138139
async def test_retrieve_nonexistent_item_raises_conversation_item_not_found(service):

0 commit comments

Comments
 (0)