Security fixes for RLM. Fix type() builtin, AST validation for dict key access, sql injection
This commit is contained in:
parent
fb6bbca124
commit
4241a4b09e
8 changed files with 574 additions and 10 deletions
200
TOOLS_REFACTORING_PLAN.md
Normal file
200
TOOLS_REFACTORING_PLAN.md
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
# Tools Extraction Refactoring Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Extract tools from haiku.rag agents into a reusable `tools/` module, enabling users to create pydantic-ai agents outside haiku.rag and compose toolsets as needed.
|
||||||
|
|
||||||
|
## Target API
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pydantic_ai import Agent
|
||||||
|
from haiku.rag import HaikuRAG
|
||||||
|
from haiku.rag.tools import ToolContext, create_search_toolset, create_document_toolset
|
||||||
|
|
||||||
|
async with HaikuRAG(db_path) as client:
|
||||||
|
context = ToolContext()
|
||||||
|
search_tools = create_search_toolset(client, config, context)
|
||||||
|
doc_tools = create_document_toolset(client, config, context)
|
||||||
|
|
||||||
|
agent = Agent(
|
||||||
|
'anthropic:claude-sonnet',
|
||||||
|
toolsets=[search_tools, doc_tools]
|
||||||
|
)
|
||||||
|
result = await agent.run("Find documents about X")
|
||||||
|
|
||||||
|
# Access accumulated state after run
|
||||||
|
search_state = context.get("haiku.rag.search")
|
||||||
|
for result in search_state.results:
|
||||||
|
print(f"{result.document_title}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
1. **ToolContext is a pure generic container** - No special-cased fields. Toolsets register their own Pydantic model state under namespaces.
|
||||||
|
|
||||||
|
2. **Shared state via same namespace** - Multiple toolsets can share state (e.g., citations, filters) by registering under the same namespace.
|
||||||
|
|
||||||
|
3. **App manages identity** - ToolContext has no session/user identity. The app layer manages `session_id -> ToolContext` mapping.
|
||||||
|
|
||||||
|
4. **Toolsets are stateless factories** - `create_*_toolset()` returns a `FunctionToolset`. State lives in the context they're given.
|
||||||
|
|
||||||
|
## ToolContext Design
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ToolContext(BaseModel):
|
||||||
|
"""Generic state container for toolsets.
|
||||||
|
|
||||||
|
Toolsets register Pydantic model state under namespaces.
|
||||||
|
Multiple toolsets can share state via the same namespace.
|
||||||
|
"""
|
||||||
|
_namespaces: dict[str, BaseModel] = PrivateAttr(default_factory=dict)
|
||||||
|
|
||||||
|
def register(self, namespace: str, state: BaseModel) -> None: ...
|
||||||
|
def get(self, namespace: str) -> BaseModel | None: ...
|
||||||
|
def get_or_create(self, namespace: str, factory: Callable[[], T]) -> T: ...
|
||||||
|
def clear_namespace(self, namespace: str) -> None: ...
|
||||||
|
def clear_all(self) -> None: ...
|
||||||
|
def dump_namespaces(self) -> dict[str, dict[str, Any]]: ...
|
||||||
|
def load_namespace(self, namespace: str, state_type: type[T], data: dict) -> T: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Toolset State Examples
|
||||||
|
|
||||||
|
Each toolset defines its own state model:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Search toolset state
|
||||||
|
class SearchState(BaseModel):
|
||||||
|
results: list[SearchResult] = []
|
||||||
|
filter: str | None = None
|
||||||
|
|
||||||
|
SEARCH_NAMESPACE = "haiku.rag.search"
|
||||||
|
|
||||||
|
# QA toolset state
|
||||||
|
class QAState(BaseModel):
|
||||||
|
history: list[QAResult] = []
|
||||||
|
|
||||||
|
QA_NAMESPACE = "haiku.rag.qa"
|
||||||
|
|
||||||
|
# Shared citation state (used by multiple toolsets)
|
||||||
|
class CitationState(BaseModel):
|
||||||
|
registry: dict[str, int] = {}
|
||||||
|
|
||||||
|
def get_or_assign_index(self, chunk_id: str) -> int:
|
||||||
|
if chunk_id in self.registry:
|
||||||
|
return self.registry[chunk_id]
|
||||||
|
new_index = len(self.registry) + 1
|
||||||
|
self.registry[chunk_id] = new_index
|
||||||
|
return new_index
|
||||||
|
|
||||||
|
CITATION_NAMESPACE = "haiku.rag.citations"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multi-User/Session Management
|
||||||
|
|
||||||
|
App layer manages context routing:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# App maintains context per session
|
||||||
|
contexts: dict[str, ToolContext] = {}
|
||||||
|
|
||||||
|
def get_context(session_id: str) -> ToolContext:
|
||||||
|
if session_id not in contexts:
|
||||||
|
contexts[session_id] = ToolContext()
|
||||||
|
return contexts[session_id]
|
||||||
|
|
||||||
|
# When running agent
|
||||||
|
context = get_context(user_session_id)
|
||||||
|
toolsets = [create_search_toolset(client, config, context)]
|
||||||
|
await agent.run(prompt, toolsets=toolsets)
|
||||||
|
```
|
||||||
|
|
||||||
|
## New Module Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
haiku_rag_slim/haiku/rag/
|
||||||
|
├── tools/ # NEW
|
||||||
|
│ ├── __init__.py # Public exports
|
||||||
|
│ ├── context.py # ToolContext (generic state container)
|
||||||
|
│ ├── models.py # QAResult, AnalysisResult
|
||||||
|
│ ├── filters.py # build_document_filter, combine_filters
|
||||||
|
│ ├── search.py # create_search_toolset()
|
||||||
|
│ ├── document.py # create_document_toolset()
|
||||||
|
│ ├── qa.py # create_qa_toolset()
|
||||||
|
│ └── analysis.py # create_analysis_toolset()
|
||||||
|
├── agents/ # REFACTORED to use tools/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Chunks
|
||||||
|
|
||||||
|
### Chunk 1: Create tools module foundation ✅ DONE
|
||||||
|
- Created `tools/__init__.py`, `tools/context.py`, `tools/models.py`, `tools/filters.py`
|
||||||
|
- Created `ToolContext` as generic namespace-based Pydantic model
|
||||||
|
- Moved filter utilities from `agents/chat/state.py` to `tools/filters.py`
|
||||||
|
- Created result models (`QAResult`, `AnalysisResult`)
|
||||||
|
- Added tests for ToolContext and filters
|
||||||
|
|
||||||
|
### Chunk 2: Create SearchToolset ✅ DONE
|
||||||
|
- Created `tools/search.py` with `create_search_toolset()`
|
||||||
|
- Defined `SearchState` model for accumulating search results
|
||||||
|
- Core search logic: `client.search()` → `client.expand_context()` → `format_for_agent()`
|
||||||
|
- Results accumulated in `SearchState` under `SEARCH_NAMESPACE`
|
||||||
|
- Added 13 tests for SearchToolset
|
||||||
|
|
||||||
|
### Chunk 3: Refactor QA Agent to use SearchToolset ✅ DONE
|
||||||
|
- Updated `agents/qa/agent.py` to use `create_search_toolset()`
|
||||||
|
- Added `base_filter` and `tool_name` parameters to `create_search_toolset()`
|
||||||
|
- QA agent now uses ToolContext + SearchState for result accumulation
|
||||||
|
- Public interface (`answer(question, filter)`) unchanged
|
||||||
|
- All 5 QA tests pass
|
||||||
|
|
||||||
|
### Chunk 4: Create DocumentToolset ✅ DONE
|
||||||
|
- Created `tools/document.py` with `create_document_toolset()`
|
||||||
|
- Defined `DocumentState`, `DocumentInfo`, `DocumentListResponse` models
|
||||||
|
- Extracted `list_documents`, `get_document`, `summarize_document` tools
|
||||||
|
- Moved `find_document` helper (now public)
|
||||||
|
- Added 13 tests
|
||||||
|
|
||||||
|
### Chunk 5: Create QAToolset ✅ DONE
|
||||||
|
- Created `tools/qa.py` with `create_qa_toolset()`
|
||||||
|
- Defined `QAState` model (tracks QA history)
|
||||||
|
- Runs research graph, returns structured `QAResult`
|
||||||
|
- Supports `base_filter`, `tool_name`, `session_context`, `prior_answers` params
|
||||||
|
- Added 7 tests
|
||||||
|
|
||||||
|
### Chunk 6: Create AnalysisToolset ✅ DONE
|
||||||
|
- Created `tools/analysis.py` with `create_analysis_toolset()`
|
||||||
|
- Defined `AnalysisState` model (tracks CodeExecution history)
|
||||||
|
- Extracted `analyze` tool (RLM delegation with filter support)
|
||||||
|
- Fixed circular import by using direct submodule imports
|
||||||
|
- Added 6 tests
|
||||||
|
|
||||||
|
### Chunk 7: Refactor Chat Agent ✅ DONE
|
||||||
|
- Removed `analyze` tool from chat agent (kept hardcoded, not composing toolsets)
|
||||||
|
- Reverted system prompt to pre-analyze version
|
||||||
|
- Removed `test_analyze_tool` test and cassette file
|
||||||
|
- All 47 chat agent tests pass
|
||||||
|
|
||||||
|
### Chunk 8: Refactor Research Graph
|
||||||
|
- Update `_search_one_step_logic` to use search toolset
|
||||||
|
- Verify research tests pass
|
||||||
|
|
||||||
|
### Chunk 9: Public API and Documentation
|
||||||
|
- Export from `haiku.rag.tools` and `haiku.rag`
|
||||||
|
- Update CLAUDE.md
|
||||||
|
- Add usage examples
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Run `pytest` after each chunk
|
||||||
|
- Run `ty check` and `ruff check`
|
||||||
|
- Test with existing agents (QA, Chat, Research)
|
||||||
|
- Test with external agent using new toolsets
|
||||||
|
|
||||||
|
## Critical Files
|
||||||
|
|
||||||
|
- `haiku_rag_slim/haiku/rag/agents/chat/agent.py` - largest tool collection
|
||||||
|
- `haiku_rag_slim/haiku/rag/agents/qa/agent.py` - simplest, good starting point
|
||||||
|
- `haiku_rag_slim/haiku/rag/agents/chat/state.py` - filter utilities (now moved)
|
||||||
|
- `haiku_rag_slim/haiku/rag/agents/research/graph.py` - search tool inside step
|
||||||
|
- `haiku_rag_slim/haiku/rag/store/models/chunk.py` - SearchResult.format_for_agent()
|
||||||
|
|
@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from haiku.rag.agents.rlm.dependencies import RLMContext
|
from haiku.rag.agents.rlm.dependencies import RLMContext
|
||||||
from haiku.rag.config.models import RLMConfig
|
from haiku.rag.config.models import RLMConfig
|
||||||
|
from haiku.rag.store.repositories.document import _escape_sql_string
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from haiku.rag.client import HaikuRAG
|
from haiku.rag.client import HaikuRAG
|
||||||
|
|
@ -84,7 +85,9 @@ class REPLEnvironment:
|
||||||
"str": str,
|
"str": str,
|
||||||
"sum": sum,
|
"sum": sum,
|
||||||
"tuple": tuple,
|
"tuple": tuple,
|
||||||
"type": type,
|
"type": (
|
||||||
|
lambda obj: type(obj)
|
||||||
|
), # Single-arg only, blocks type(name, bases, dict)
|
||||||
"zip": zip,
|
"zip": zip,
|
||||||
"Exception": Exception,
|
"Exception": Exception,
|
||||||
"ValueError": ValueError,
|
"ValueError": ValueError,
|
||||||
|
|
@ -206,13 +209,14 @@ class REPLEnvironment:
|
||||||
doc = await self.client.get_document_by_id(id_or_title)
|
doc = await self.client.get_document_by_id(id_or_title)
|
||||||
if doc:
|
if doc:
|
||||||
return doc.content
|
return doc.content
|
||||||
|
safe_input = _escape_sql_string(id_or_title)
|
||||||
docs = await self.client.list_documents(
|
docs = await self.client.list_documents(
|
||||||
filter=f"title = '{id_or_title}'"
|
filter=f"title = '{safe_input}'"
|
||||||
)
|
)
|
||||||
if docs and docs[0].id:
|
if docs and docs[0].id:
|
||||||
full_doc = await self.client.get_document_by_id(docs[0].id)
|
full_doc = await self.client.get_document_by_id(docs[0].id)
|
||||||
return full_doc.content if full_doc else None
|
return full_doc.content if full_doc else None
|
||||||
docs = await self.client.list_documents(filter=f"uri = '{id_or_title}'")
|
docs = await self.client.list_documents(filter=f"uri = '{safe_input}'")
|
||||||
if docs and docs[0].id:
|
if docs and docs[0].id:
|
||||||
full_doc = await self.client.get_document_by_id(docs[0].id)
|
full_doc = await self.client.get_document_by_id(docs[0].id)
|
||||||
return full_doc.content if full_doc else None
|
return full_doc.content if full_doc else None
|
||||||
|
|
@ -230,13 +234,14 @@ class REPLEnvironment:
|
||||||
doc = await self.client.get_document_by_id(id_or_title)
|
doc = await self.client.get_document_by_id(id_or_title)
|
||||||
if doc:
|
if doc:
|
||||||
return doc.get_docling_document()
|
return doc.get_docling_document()
|
||||||
|
safe_input = _escape_sql_string(id_or_title)
|
||||||
docs = await self.client.list_documents(
|
docs = await self.client.list_documents(
|
||||||
filter=f"title = '{id_or_title}'"
|
filter=f"title = '{safe_input}'"
|
||||||
)
|
)
|
||||||
if docs and docs[0].id:
|
if docs and docs[0].id:
|
||||||
full_doc = await self.client.get_document_by_id(docs[0].id)
|
full_doc = await self.client.get_document_by_id(docs[0].id)
|
||||||
return full_doc.get_docling_document() if full_doc else None
|
return full_doc.get_docling_document() if full_doc else None
|
||||||
docs = await self.client.list_documents(filter=f"uri = '{id_or_title}'")
|
docs = await self.client.list_documents(filter=f"uri = '{safe_input}'")
|
||||||
if docs and docs[0].id:
|
if docs and docs[0].id:
|
||||||
full_doc = await self.client.get_document_by_id(docs[0].id)
|
full_doc = await self.client.get_document_by_id(docs[0].id)
|
||||||
return full_doc.get_docling_document() if full_doc else None
|
return full_doc.get_docling_document() if full_doc else None
|
||||||
|
|
@ -305,6 +310,16 @@ class REPLEnvironment:
|
||||||
raise SecurityError(
|
raise SecurityError(
|
||||||
f"Access to private/dunder attribute '{node.attr}' is not allowed"
|
f"Access to private/dunder attribute '{node.attr}' is not allowed"
|
||||||
)
|
)
|
||||||
|
# Block dictionary key access to dunder/private strings
|
||||||
|
# This prevents type.__dict__['__subclasses__'] attacks
|
||||||
|
if isinstance(node, ast.Subscript):
|
||||||
|
if isinstance(node.slice, ast.Constant):
|
||||||
|
if isinstance(
|
||||||
|
node.slice.value, str
|
||||||
|
) and node.slice.value.startswith("_"):
|
||||||
|
raise SecurityError(
|
||||||
|
f"Dictionary access to '{node.slice.value}' is not allowed"
|
||||||
|
)
|
||||||
|
|
||||||
def _execute_sync(self, code: str) -> REPLResult:
|
def _execute_sync(self, code: str) -> REPLResult:
|
||||||
"""Internal synchronous execution - must be called from executor thread."""
|
"""Internal synchronous execution - must be called from executor thread."""
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,10 @@ from haiku.rag.store.engine import Store
|
||||||
from haiku.rag.store.models.chunk import Chunk, SearchResult
|
from haiku.rag.store.models.chunk import Chunk, SearchResult
|
||||||
from haiku.rag.store.models.document import Document
|
from haiku.rag.store.models.document import Document
|
||||||
from haiku.rag.store.repositories.chunk import ChunkRepository
|
from haiku.rag.store.repositories.chunk import ChunkRepository
|
||||||
from haiku.rag.store.repositories.document import DocumentRepository
|
from haiku.rag.store.repositories.document import (
|
||||||
|
DocumentRepository,
|
||||||
|
_escape_sql_string,
|
||||||
|
)
|
||||||
from haiku.rag.store.repositories.settings import SettingsRepository
|
from haiku.rag.store.repositories.settings import SettingsRepository
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -1322,7 +1325,8 @@ class HaikuRAG:
|
||||||
for doc_ref in documents:
|
for doc_ref in documents:
|
||||||
doc = await self.get_document_by_id(doc_ref)
|
doc = await self.get_document_by_id(doc_ref)
|
||||||
if not doc:
|
if not doc:
|
||||||
docs = await self.list_documents(filter=f"title = '{doc_ref}'")
|
safe_ref = _escape_sql_string(doc_ref)
|
||||||
|
docs = await self.list_documents(filter=f"title = '{safe_ref}'")
|
||||||
if docs and docs[0].id:
|
if docs and docs[0].id:
|
||||||
doc = await self.get_document_by_id(docs[0].id)
|
doc = await self.get_document_by_id(docs[0].id)
|
||||||
if doc:
|
if doc:
|
||||||
|
|
|
||||||
|
|
@ -77,9 +77,10 @@ class DocumentRepository:
|
||||||
|
|
||||||
async def get_by_id(self, entity_id: str) -> Document | None:
|
async def get_by_id(self, entity_id: str) -> Document | None:
|
||||||
"""Get a document by its ID."""
|
"""Get a document by its ID."""
|
||||||
|
safe_id = _escape_sql_string(entity_id)
|
||||||
results = list(
|
results = list(
|
||||||
self.store.documents_table.search()
|
self.store.documents_table.search()
|
||||||
.where(f"id = '{entity_id}'")
|
.where(f"id = '{safe_id}'")
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.to_pydantic(DocumentRecord)
|
.to_pydantic(DocumentRecord)
|
||||||
)
|
)
|
||||||
|
|
@ -104,8 +105,9 @@ class DocumentRepository:
|
||||||
entity.updated_at = datetime.fromisoformat(now)
|
entity.updated_at = datetime.fromisoformat(now)
|
||||||
|
|
||||||
# Update the record
|
# Update the record
|
||||||
|
safe_id = _escape_sql_string(entity.id)
|
||||||
self.store.documents_table.update(
|
self.store.documents_table.update(
|
||||||
where=f"id = '{entity.id}'",
|
where=f"id = '{safe_id}'",
|
||||||
values={
|
values={
|
||||||
"content": entity.content,
|
"content": entity.content,
|
||||||
"uri": entity.uri,
|
"uri": entity.uri,
|
||||||
|
|
@ -136,7 +138,8 @@ class DocumentRepository:
|
||||||
await self.chunk_repository.delete_by_document_id(entity_id)
|
await self.chunk_repository.delete_by_document_id(entity_id)
|
||||||
|
|
||||||
# Delete the document
|
# Delete the document
|
||||||
self.store.documents_table.delete(f"id = '{entity_id}'")
|
safe_id = _escape_sql_string(entity_id)
|
||||||
|
self.store.documents_table.delete(f"id = '{safe_id}'")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def list_all(
|
async def list_all(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def vcr_cassette_dir():
|
||||||
|
return str(Path(__file__).parent.parent.parent / "cassettes" / "test_sandbox")
|
||||||
|
|
||||||
|
|
||||||
class TestSafeBuiltins:
|
class TestSafeBuiltins:
|
||||||
"""Test that safe builtins are available."""
|
"""Test that safe builtins are available."""
|
||||||
|
|
||||||
|
|
@ -572,6 +579,130 @@ class TestPreloadedDocuments:
|
||||||
assert "test://doc" in result.stdout
|
assert "test://doc" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
class TestSandboxEscapeVectors:
|
||||||
|
"""Test that known sandbox escape techniques are blocked.
|
||||||
|
|
||||||
|
Each test contains actual exploit code that would work without the fix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_type_dict_subclasses_escape_blocked(self, repl_env_empty):
|
||||||
|
"""Cannot escape via type.__dict__['__subclasses__'].
|
||||||
|
|
||||||
|
Without fix: This would enumerate all loaded classes and find
|
||||||
|
subprocess.Popen to execute arbitrary shell commands.
|
||||||
|
"""
|
||||||
|
result = await repl_env_empty.execute_async("""
|
||||||
|
# EXPLOIT: Access __subclasses__ via dict to bypass AST check
|
||||||
|
subclasses_method = type.__dict__['__subclasses__']
|
||||||
|
all_classes = subclasses_method(object)
|
||||||
|
print(f"Found {len(all_classes)} classes")
|
||||||
|
""")
|
||||||
|
assert not result.success
|
||||||
|
assert "not allowed" in result.stderr.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_popen_shell_execution_blocked(self, repl_env_empty):
|
||||||
|
"""Cannot execute shell commands via Popen.
|
||||||
|
|
||||||
|
Without fix: This would execute 'whoami' and return the username.
|
||||||
|
"""
|
||||||
|
result = await repl_env_empty.execute_async("""
|
||||||
|
# EXPLOIT: Find subprocess.Popen and execute shell commands
|
||||||
|
subclasses_method = type.__dict__['__subclasses__']
|
||||||
|
all_classes = subclasses_method(object)
|
||||||
|
popen = [c for c in all_classes if c.__name__ == 'Popen'][0]
|
||||||
|
proc = popen('whoami', shell=True, stdout=-1)
|
||||||
|
print(proc.stdout.read())
|
||||||
|
""")
|
||||||
|
assert not result.success
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_socket_creation_blocked(self, repl_env_empty):
|
||||||
|
"""Cannot create network sockets for data exfiltration.
|
||||||
|
|
||||||
|
Without fix: This would create a socket that could connect to external servers.
|
||||||
|
"""
|
||||||
|
result = await repl_env_empty.execute_async("""
|
||||||
|
# EXPLOIT: Find socket class and create network connection
|
||||||
|
subclasses_method = type.__dict__['__subclasses__']
|
||||||
|
all_classes = subclasses_method(object)
|
||||||
|
socket_cls = [c for c in all_classes if c.__name__ == 'socket'][0]
|
||||||
|
s = socket_cls(2, 1) # AF_INET, SOCK_STREAM
|
||||||
|
print(f"Created socket: {s}")
|
||||||
|
""")
|
||||||
|
assert not result.success
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_type_three_arg_class_creation_blocked(self, repl_env_empty):
|
||||||
|
"""Cannot use type() with 3 arguments to create classes dynamically."""
|
||||||
|
result = await repl_env_empty.execute_async(
|
||||||
|
"EvilClass = type('EvilClass', (object,), {'x': 1})"
|
||||||
|
)
|
||||||
|
assert not result.success
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dict_key_dunder_access_blocked(self, repl_env_empty):
|
||||||
|
"""Cannot access dunder methods via dictionary key access."""
|
||||||
|
result = await repl_env_empty.execute_async(
|
||||||
|
"method = str.__dict__['__add__']\nprint(method)"
|
||||||
|
)
|
||||||
|
assert not result.success
|
||||||
|
assert "not allowed" in result.stderr.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dict_key_private_access_blocked(self, repl_env_empty):
|
||||||
|
"""Cannot access private attributes via dictionary key access."""
|
||||||
|
result = await repl_env_empty.execute_async(
|
||||||
|
"method = object.__dict__['_private']\nprint(method)"
|
||||||
|
)
|
||||||
|
assert not result.success
|
||||||
|
assert "not allowed" in result.stderr.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.vcr()
|
||||||
|
async def test_sql_injection_in_get_document_blocked(self, temp_db_path):
|
||||||
|
"""SQL injection in get_document cannot bypass context filter.
|
||||||
|
|
||||||
|
Without fix: Injecting quotes would leak documents that should be
|
||||||
|
protected by the context filter.
|
||||||
|
"""
|
||||||
|
from haiku.rag.agents.rlm.dependencies import RLMContext
|
||||||
|
from haiku.rag.agents.rlm.sandbox import REPLEnvironment
|
||||||
|
from haiku.rag.client import HaikuRAG
|
||||||
|
from haiku.rag.config.models import RLMConfig
|
||||||
|
|
||||||
|
async with HaikuRAG(temp_db_path, create=True) as client:
|
||||||
|
# Create documents: one secret, one public
|
||||||
|
await client.create_document(
|
||||||
|
content="TOP SECRET: Launch codes 1234",
|
||||||
|
uri="secret://classified",
|
||||||
|
title="Classified Intel",
|
||||||
|
)
|
||||||
|
await client.create_document(
|
||||||
|
content="Public weather report",
|
||||||
|
uri="public://weather",
|
||||||
|
title="Weather",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sandbox restricted to public:// only
|
||||||
|
context = RLMContext(filter="uri LIKE 'public://%'")
|
||||||
|
repl = REPLEnvironment(client=client, config=RLMConfig(), context=context)
|
||||||
|
|
||||||
|
# EXPLOIT: SQL injection to access secret document
|
||||||
|
result = await repl.execute_async("""
|
||||||
|
# Injection payload breaks out of quotes and adds OR clause
|
||||||
|
content = get_document("x' OR uri LIKE 'secret://%")
|
||||||
|
if content:
|
||||||
|
print(f"LEAKED: {content}")
|
||||||
|
else:
|
||||||
|
print("NO LEAK")
|
||||||
|
""")
|
||||||
|
assert result.success
|
||||||
|
assert "TOP SECRET" not in result.stdout
|
||||||
|
assert "Launch codes" not in result.stdout
|
||||||
|
|
||||||
|
|
||||||
class TestSecurityEscapes:
|
class TestSecurityEscapes:
|
||||||
"""Test that common security escape attempts are blocked."""
|
"""Test that common security escape attempts are blocked."""
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1384,3 +1384,50 @@ async def test_client_convert_with_html_format(temp_db_path):
|
||||||
labels = [str(getattr(item, "label", "")) for item, _ in items]
|
labels = [str(getattr(item, "label", "")) for item, _ in items]
|
||||||
|
|
||||||
assert "title" in labels
|
assert "title" in labels
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.vcr()
|
||||||
|
async def test_sql_injection_is_blocked_with_escaping(temp_db_path):
|
||||||
|
"""SQL injection is blocked when using _escape_sql_string.
|
||||||
|
|
||||||
|
This test verifies that _escape_sql_string properly prevents SQL injection
|
||||||
|
by escaping single quotes in user input.
|
||||||
|
"""
|
||||||
|
from haiku.rag.store.repositories.document import _escape_sql_string
|
||||||
|
|
||||||
|
async with HaikuRAG(temp_db_path, create=True) as client:
|
||||||
|
# Create documents
|
||||||
|
await client.create_document(
|
||||||
|
content="Secret classified data XYZ",
|
||||||
|
uri="secret://doc",
|
||||||
|
title="Secret",
|
||||||
|
)
|
||||||
|
await client.create_document(
|
||||||
|
content="Public report about weather",
|
||||||
|
uri="public://report",
|
||||||
|
title="Weather Report",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Without escaping, this injection would match all documents
|
||||||
|
# by breaking out of the string literal: title = 'x' OR title LIKE '%'
|
||||||
|
injection_payload = "x' OR title LIKE '%"
|
||||||
|
|
||||||
|
# With proper escaping, single quotes become double quotes
|
||||||
|
# so the filter becomes: title = 'x'' OR title LIKE ''%'
|
||||||
|
# which searches for a literal title containing the injection string
|
||||||
|
safe_payload = _escape_sql_string(injection_payload)
|
||||||
|
docs = await client.list_documents(filter=f"title = '{safe_payload}'")
|
||||||
|
|
||||||
|
# Should find 0 documents (injection is escaped, searching for literal string)
|
||||||
|
assert len(docs) == 0
|
||||||
|
|
||||||
|
# Verify the escaping works correctly
|
||||||
|
assert safe_payload == "x'' OR title LIKE ''%"
|
||||||
|
|
||||||
|
# Verify unescaped injection would have matched documents (for test validity)
|
||||||
|
# This demonstrates that the injection works without escaping
|
||||||
|
docs_unescaped = await client.list_documents(
|
||||||
|
filter=f"title = '{injection_payload}'"
|
||||||
|
)
|
||||||
|
assert len(docs_unescaped) == 2 # SQL injection succeeds without escaping
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue