mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-12 16:56:02 +00:00
Merge remote-tracking branch 'origin/master' into feat/sandbox
# Conflicts: # src/langbot/pkg/api/http/controller/groups/plugins.py # src/langbot/pkg/core/app.py # src/langbot/pkg/core/stages/build_app.py # src/langbot/templates/config.yaml # uv.lock # web/src/app/home/components/home-sidebar/HomeSidebar.tsx # web/src/app/home/components/home-sidebar/SidebarDataContext.tsx # web/src/app/home/layout.tsx # web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx # web/src/i18n/locales/en-US.ts # web/src/i18n/locales/es-ES.ts # web/src/i18n/locales/ja-JP.ts # web/src/i18n/locales/th-TH.ts # web/src/i18n/locales/vi-VN.ts # web/src/i18n/locales/zh-Hans.ts # web/src/i18n/locales/zh-Hant.ts # web/src/router.tsx
This commit is contained in:
106
tests/unit_tests/pipeline/test_chat_session_limit.py
Normal file
106
tests/unit_tests/pipeline/test_chat_session_limit.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
def _preproc_module():
|
||||
# Import pipelinemgr first so pipeline stages are registered without tripping
|
||||
# the stage <-> core.app circular import during isolated test collection.
|
||||
import_module('langbot.pkg.pipeline.pipelinemgr')
|
||||
return import_module('langbot.pkg.pipeline.preproc.preproc')
|
||||
|
||||
|
||||
def _entities_module():
|
||||
return import_module('langbot.pkg.pipeline.entities')
|
||||
|
||||
|
||||
def _conversation(created_at: datetime, updated_at: datetime | None = None):
|
||||
prompt = Mock()
|
||||
prompt.messages = []
|
||||
prompt.copy = Mock(return_value=Mock(messages=[]))
|
||||
|
||||
return SimpleNamespace(
|
||||
uuid='existing-conversation-uuid',
|
||||
create_time=created_at,
|
||||
update_time=updated_at,
|
||||
prompt=prompt,
|
||||
messages=[],
|
||||
)
|
||||
|
||||
|
||||
def _prompt_preprocessing_context(default_prompt=None, prompt=None):
|
||||
ctx = Mock()
|
||||
ctx.event.default_prompt = default_prompt or []
|
||||
ctx.event.prompt = prompt or []
|
||||
return ctx
|
||||
|
||||
|
||||
async def _run_preprocessor(mock_app, sample_query, conversation):
|
||||
session = SimpleNamespace(launcher_type=sample_query.launcher_type, launcher_id=sample_query.launcher_id)
|
||||
mock_app.sess_mgr.get_session = AsyncMock(return_value=session)
|
||||
mock_app.sess_mgr.get_conversation = AsyncMock(return_value=conversation)
|
||||
mock_app.plugin_connector.emit_event = AsyncMock(return_value=_prompt_preprocessing_context())
|
||||
|
||||
sample_query.pipeline_config = {
|
||||
'ai': {
|
||||
'runner': {'runner': 'local-agent', 'expire-time': 60},
|
||||
'local-agent': {'model': {'primary': '', 'fallbacks': []}, 'prompt': []},
|
||||
},
|
||||
'trigger': {'misc': {'combine-quote-message': False}},
|
||||
'output': {'misc': {'exception-handling': 'show-hint'}},
|
||||
}
|
||||
|
||||
return await _preproc_module().PreProcessor(mock_app).process(sample_query, 'PreProcessor')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preprocessor_expires_conversation_from_last_update_time(mock_app, sample_query):
|
||||
conversation = _conversation(
|
||||
created_at=datetime.now() - timedelta(seconds=10),
|
||||
updated_at=datetime.now() - timedelta(seconds=120),
|
||||
)
|
||||
|
||||
result = await _run_preprocessor(mock_app, sample_query, conversation)
|
||||
|
||||
assert result.result_type == _entities_module().ResultType.CONTINUE
|
||||
assert conversation.uuid is None
|
||||
assert conversation.update_time > datetime.now() - timedelta(seconds=5)
|
||||
assert result.new_query.variables['conversation_id'] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preprocessor_keeps_conversation_when_last_update_is_not_expired(mock_app, sample_query):
|
||||
conversation = _conversation(
|
||||
created_at=datetime.now() - timedelta(seconds=120),
|
||||
updated_at=datetime.now() - timedelta(seconds=30),
|
||||
)
|
||||
|
||||
result = await _run_preprocessor(mock_app, sample_query, conversation)
|
||||
|
||||
assert result.result_type == _entities_module().ResultType.CONTINUE
|
||||
assert conversation.uuid == 'existing-conversation-uuid'
|
||||
assert conversation.update_time > datetime.now() - timedelta(seconds=5)
|
||||
assert result.new_query.variables['conversation_id'] == 'existing-conversation-uuid'
|
||||
|
||||
|
||||
def test_expire_time_metadata_lives_under_ai_runner_not_safety():
|
||||
metadata_dir = Path('src/langbot/templates/metadata/pipeline')
|
||||
|
||||
ai_meta = yaml.safe_load((metadata_dir / 'ai.yaml').read_text())
|
||||
safety_meta = yaml.safe_load((metadata_dir / 'safety.yaml').read_text())
|
||||
|
||||
ai_stage_names = [stage['name'] for stage in ai_meta['stages']]
|
||||
assert 'session-limit' not in ai_stage_names
|
||||
assert 'session-limit' not in [stage['name'] for stage in safety_meta['stages']]
|
||||
|
||||
runner_stage = next(stage for stage in ai_meta['stages'] if stage['name'] == 'runner')
|
||||
expire_time = next(item for item in runner_stage['config'] if item['name'] == 'expire-time')
|
||||
assert 'Conversation expire time' in expire_time['label']['en_US']
|
||||
assert 'Session validity' not in expire_time['label']['en_US']
|
||||
1
tests/unit_tests/provider/__init__.py
Normal file
1
tests/unit_tests/provider/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
173
tests/unit_tests/provider/test_model_service.py
Normal file
173
tests/unit_tests/provider/test_model_service.py
Normal file
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
|
||||
from langbot.pkg.api.http.service.model import _runtime_model_data
|
||||
from langbot.pkg.entity.persistence import model as persistence_model
|
||||
from langbot.pkg.pipeline.preproc.preproc import PreProcessor
|
||||
from langbot.pkg.provider.modelmgr import requester
|
||||
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
|
||||
from langbot.pkg.provider.runners.localagent import LocalAgentRunner
|
||||
|
||||
|
||||
def test_runtime_llm_model_data_preserves_uuid_after_update_payload_uuid_removed():
|
||||
update_payload = {
|
||||
'name': 'Qwen3.5-27B',
|
||||
'provider_uuid': 'provider-uuid',
|
||||
'abilities': [],
|
||||
'extra_args': {},
|
||||
}
|
||||
|
||||
runtime_entity = persistence_model.LLMModel(**_runtime_model_data('model-uuid', update_payload))
|
||||
|
||||
assert runtime_entity.uuid == 'model-uuid'
|
||||
assert runtime_entity.name == 'Qwen3.5-27B'
|
||||
|
||||
|
||||
def test_runtime_embedding_model_data_preserves_uuid_after_update_payload_uuid_removed():
|
||||
update_payload = {
|
||||
'name': 'embedding-model',
|
||||
'provider_uuid': 'provider-uuid',
|
||||
'extra_args': {},
|
||||
}
|
||||
|
||||
runtime_entity = persistence_model.EmbeddingModel(**_runtime_model_data('embedding-uuid', update_payload))
|
||||
|
||||
assert runtime_entity.uuid == 'embedding-uuid'
|
||||
assert runtime_entity.name == 'embedding-model'
|
||||
|
||||
|
||||
def test_runtime_rerank_model_data_preserves_uuid_after_update_payload_uuid_removed():
|
||||
update_payload = {
|
||||
'name': 'rerank-model',
|
||||
'provider_uuid': 'provider-uuid',
|
||||
'extra_args': {},
|
||||
}
|
||||
|
||||
runtime_entity = persistence_model.RerankModel(**_runtime_model_data('rerank-uuid', update_payload))
|
||||
|
||||
assert runtime_entity.uuid == 'rerank-uuid'
|
||||
assert runtime_entity.name == 'rerank-model'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline():
|
||||
from langbot.pkg.api.http.service.model import LLMModelsService
|
||||
|
||||
model_uuid = 'qwen-model-uuid'
|
||||
provider_uuid = 'ollama-provider-uuid'
|
||||
|
||||
ap = SimpleNamespace()
|
||||
ap.logger = Mock()
|
||||
ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock())
|
||||
ap.tool_mgr = SimpleNamespace(get_all_tools=AsyncMock(return_value=[]))
|
||||
ap.plugin_connector = SimpleNamespace(
|
||||
emit_event=AsyncMock(return_value=SimpleNamespace(event=SimpleNamespace(default_prompt=[], prompt=[])))
|
||||
)
|
||||
|
||||
ap.model_mgr = ModelManager(ap)
|
||||
runtime_provider = Mock()
|
||||
ap.model_mgr.provider_dict = {provider_uuid: runtime_provider}
|
||||
ap.model_mgr.llm_models = [
|
||||
requester.RuntimeLLMModel(
|
||||
model_entity=persistence_model.LLMModel(
|
||||
uuid=model_uuid,
|
||||
name='old-qwen-name',
|
||||
provider_uuid=provider_uuid,
|
||||
abilities=[],
|
||||
extra_args={},
|
||||
),
|
||||
provider=runtime_provider,
|
||||
)
|
||||
]
|
||||
|
||||
await LLMModelsService(ap).update_llm_model(
|
||||
model_uuid,
|
||||
{
|
||||
'name': 'Qwen3.5-27B',
|
||||
'provider_uuid': provider_uuid,
|
||||
'abilities': [],
|
||||
'extra_args': {},
|
||||
},
|
||||
)
|
||||
|
||||
runtime_model = await ap.model_mgr.get_model_by_uuid(model_uuid)
|
||||
assert runtime_model.model_entity.uuid == model_uuid
|
||||
assert runtime_model.model_entity.name == 'Qwen3.5-27B'
|
||||
|
||||
session = SimpleNamespace(
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
)
|
||||
conversation = SimpleNamespace(
|
||||
uuid='conversation-uuid',
|
||||
create_time=None,
|
||||
update_time=None,
|
||||
prompt=SimpleNamespace(messages=[], copy=Mock(return_value=SimpleNamespace(messages=[]))),
|
||||
messages=[],
|
||||
)
|
||||
ap.sess_mgr = SimpleNamespace(
|
||||
get_session=AsyncMock(return_value=session),
|
||||
get_conversation=AsyncMock(return_value=conversation),
|
||||
)
|
||||
|
||||
message_chain = platform_message.MessageChain([platform_message.Plain(text='hello')])
|
||||
sender = platform_entities.Friend(id=12345, nickname='Tester', remark=None)
|
||||
message_event = platform_events.FriendMessage(
|
||||
type='FriendMessage',
|
||||
sender=sender,
|
||||
message_chain=message_chain,
|
||||
time=1710000000,
|
||||
)
|
||||
pipeline_config = {
|
||||
'ai': {
|
||||
'runner': {'runner': 'local-agent'},
|
||||
'local-agent': {
|
||||
'model': {'primary': model_uuid, 'fallbacks': []},
|
||||
'prompt': [],
|
||||
'knowledge-bases': [],
|
||||
},
|
||||
},
|
||||
'trigger': {'misc': {'combine-quote-message': False}},
|
||||
'output': {'misc': {'remove-think': False}},
|
||||
}
|
||||
query = pipeline_query.Query.model_construct(
|
||||
query_id='query-id',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_chain=message_chain,
|
||||
message_event=message_event,
|
||||
adapter=AsyncMock(),
|
||||
pipeline_uuid='pipeline-uuid',
|
||||
bot_uuid='bot-uuid',
|
||||
pipeline_config=pipeline_config,
|
||||
session=None,
|
||||
prompt=None,
|
||||
messages=[],
|
||||
user_message=None,
|
||||
use_funcs=[],
|
||||
use_llm_model_uuid=None,
|
||||
variables={},
|
||||
resp_messages=[],
|
||||
resp_message_chain=None,
|
||||
current_stage_name=None,
|
||||
)
|
||||
|
||||
result = await PreProcessor(ap).process(query, 'PreProcessor')
|
||||
processed_query = result.new_query
|
||||
|
||||
assert processed_query.use_llm_model_uuid == model_uuid
|
||||
|
||||
runner = SimpleNamespace(ap=ap, pipeline_config=pipeline_config)
|
||||
candidates = await LocalAgentRunner._get_model_candidates(runner, processed_query)
|
||||
|
||||
assert [model.model_entity.uuid for model in candidates] == [model_uuid]
|
||||
181
tests/unit_tests/storage/test_localstorage_path_traversal.py
Normal file
181
tests/unit_tests/storage/test_localstorage_path_traversal.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
PoC test for CWE-22 path traversal in LocalStorageProvider.
|
||||
|
||||
The LocalStorageProvider uses os.path.join(LOCAL_STORAGE_PATH, key) without
|
||||
validating that the resulting path stays inside LOCAL_STORAGE_PATH.
|
||||
|
||||
When `key` is an absolute path (e.g. '/etc/passwd'), os.path.join discards
|
||||
all previous components and returns the absolute path directly, allowing
|
||||
arbitrary file reads, writes, and deletes.
|
||||
|
||||
This test must FAIL before the fix and PASS after.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from langbot.pkg.storage.providers.localstorage import LocalStorageProvider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage_provider(tmp_path):
|
||||
"""Create a LocalStorageProvider with a temporary storage path."""
|
||||
storage_path = str(tmp_path / "storage")
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
mock_app = Mock()
|
||||
provider = LocalStorageProvider(mock_app)
|
||||
yield provider, storage_path
|
||||
|
||||
|
||||
class TestPathTraversalPrevention:
|
||||
"""Test that LocalStorageProvider rejects path traversal attempts."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_absolute_path_save_rejected(self, storage_provider, tmp_path):
|
||||
"""Saving with an absolute path key must be blocked."""
|
||||
provider, storage_path = storage_provider
|
||||
target_file = str(tmp_path / "pwned.txt")
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
with pytest.raises((ValueError, PermissionError)):
|
||||
await provider.save(target_file, b"malicious content")
|
||||
|
||||
# The file must NOT exist outside the storage directory
|
||||
assert not os.path.exists(target_file), (
|
||||
f"Path traversal succeeded: file was written outside storage to {target_file}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_absolute_path_load_rejected(self, storage_provider, tmp_path):
|
||||
"""Loading with an absolute path key must be blocked."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
# Create a file outside the storage directory
|
||||
target_file = str(tmp_path / "secret.txt")
|
||||
with open(target_file, "wb") as f:
|
||||
f.write(b"secret data")
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
with pytest.raises((ValueError, PermissionError, FileNotFoundError)):
|
||||
data = await provider.load(target_file)
|
||||
assert data != b"secret data", (
|
||||
"Path traversal succeeded: read file outside storage"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_absolute_path_exists_rejected(self, storage_provider, tmp_path):
|
||||
"""Exists check with an absolute path key must be blocked or return False."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
target_file = str(tmp_path / "check_me.txt")
|
||||
with open(target_file, "wb") as f:
|
||||
f.write(b"data")
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
try:
|
||||
result = await provider.exists(target_file)
|
||||
assert result is False, (
|
||||
"Path traversal succeeded: exists() returned True for file outside storage"
|
||||
)
|
||||
except (ValueError, PermissionError):
|
||||
pass # Expected
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_absolute_path_delete_rejected(self, storage_provider, tmp_path):
|
||||
"""Deleting with an absolute path key must be blocked."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
target_file = str(tmp_path / "do_not_delete.txt")
|
||||
with open(target_file, "wb") as f:
|
||||
f.write(b"important data")
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
with pytest.raises((ValueError, PermissionError, FileNotFoundError)):
|
||||
await provider.delete(target_file)
|
||||
|
||||
assert os.path.exists(target_file), (
|
||||
"Path traversal succeeded: file outside storage was deleted"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_absolute_path_size_rejected(self, storage_provider, tmp_path):
|
||||
"""Size check with an absolute path key must be blocked."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
target_file = str(tmp_path / "measure_me.txt")
|
||||
with open(target_file, "wb") as f:
|
||||
f.write(b"some data")
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
with pytest.raises((ValueError, PermissionError, FileNotFoundError)):
|
||||
await provider.size(target_file)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dot_dot_path_traversal_rejected(self, storage_provider, tmp_path):
|
||||
"""Relative path traversal with '..' must be blocked."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
target_file = str(tmp_path / "above_storage.txt")
|
||||
with open(target_file, "wb") as f:
|
||||
f.write(b"above storage secret")
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
relative_key = os.path.join("..", "above_storage.txt")
|
||||
with pytest.raises((ValueError, PermissionError, FileNotFoundError)):
|
||||
data = await provider.load(relative_key)
|
||||
assert data != b"above storage secret"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_dir_recursive_traversal_rejected(self, storage_provider, tmp_path):
|
||||
"""delete_dir_recursive with traversal path must be blocked."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
outside_dir = tmp_path / "outside_dir"
|
||||
outside_dir.mkdir()
|
||||
(outside_dir / "file.txt").write_text("important")
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
with pytest.raises((ValueError, PermissionError)):
|
||||
await provider.delete_dir_recursive(str(outside_dir))
|
||||
|
||||
assert outside_dir.exists(), (
|
||||
"Path traversal succeeded: directory outside storage was deleted"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legitimate_key_works(self, storage_provider):
|
||||
"""Normal keys without traversal must still work."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
key = "test_image_abc123.png"
|
||||
content = b"PNG image data"
|
||||
|
||||
await provider.save(key, content)
|
||||
assert await provider.exists(key) is True
|
||||
loaded = await provider.load(key)
|
||||
assert loaded == content
|
||||
size = await provider.size(key)
|
||||
assert size == len(content)
|
||||
await provider.delete(key)
|
||||
assert await provider.exists(key) is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legitimate_subdirectory_key_works(self, storage_provider):
|
||||
"""Keys with legitimate subdirectories must still work."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
key = "bot_log_images/img_001.png"
|
||||
content = b"PNG image data"
|
||||
|
||||
await provider.save(key, content)
|
||||
assert await provider.exists(key) is True
|
||||
loaded = await provider.load(key)
|
||||
assert loaded == content
|
||||
await provider.delete(key)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Reference in New Issue
Block a user