mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-20 19:36:08 +00:00
175 lines
5.7 KiB
Python
175 lines
5.7 KiB
Python
from __future__ import annotations
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from langbot.pkg.plugin import connector as connector_module
|
|
|
|
from .test_connector_methods import create_mock_connector
|
|
|
|
|
|
TRUSTED_LEGACY_REQUEST = {
|
|
'owner': 'langbot-app',
|
|
'repo': 'demo-plugin',
|
|
'release_tag': 'v1.0.0',
|
|
'asset_url': 'https://github.com/langbot-app/demo-plugin/releases/download/v1.0.0/demo.lbpkg',
|
|
}
|
|
|
|
|
|
def _patch_client(monkeypatch, handler):
|
|
real_async_client = httpx.AsyncClient
|
|
observed: list[dict] = []
|
|
|
|
def client_factory(*args, **kwargs):
|
|
observed.append(dict(kwargs))
|
|
return real_async_client(
|
|
transport=httpx.MockTransport(handler),
|
|
follow_redirects=kwargs.get('follow_redirects', False),
|
|
trust_env=kwargs.get('trust_env', True),
|
|
timeout=kwargs.get('timeout'),
|
|
)
|
|
|
|
monkeypatch.setattr(connector_module.httpx, 'AsyncClient', client_factory)
|
|
return observed
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'asset_url',
|
|
[
|
|
'http://127.0.0.1/internal.lbpkg',
|
|
'https://169.254.169.254/latest/meta-data',
|
|
'https://github.com@127.0.0.1/internal.lbpkg',
|
|
'https://evil.example/langbot-app/demo-plugin/releases/download/v1.0.0/demo.lbpkg',
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_github_install_rejects_internal_or_untrusted_asset_url_before_network(
|
|
monkeypatch,
|
|
asset_url,
|
|
):
|
|
connector = create_mock_connector()
|
|
monkeypatch.setattr(
|
|
connector_module.httpx,
|
|
'AsyncClient',
|
|
lambda *_args, **_kwargs: pytest.fail('network client must not be created'),
|
|
)
|
|
|
|
with pytest.raises(ValueError, match='GitHub release asset URL'):
|
|
await connector._download_github_package(
|
|
{**TRUSTED_LEGACY_REQUEST, 'asset_url': asset_url},
|
|
None,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_github_asset_id_is_resolved_server_side_and_redirect_escape_is_rejected(
|
|
monkeypatch,
|
|
):
|
|
connector = create_mock_connector()
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
if request.url.path.endswith('/releases/42'):
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
'id': 42,
|
|
'tag_name': 'v1.0.0',
|
|
'assets': [{'id': 99, 'size': 128, 'state': 'uploaded'}],
|
|
},
|
|
)
|
|
if request.url.path.endswith('/releases/assets/99'):
|
|
return httpx.Response(
|
|
302,
|
|
headers={'location': 'http://169.254.169.254/latest/meta-data'},
|
|
)
|
|
raise AssertionError(f'unexpected request: {request.url}')
|
|
|
|
observed = _patch_client(monkeypatch, handler)
|
|
with pytest.raises(ValueError, match='untrusted host'):
|
|
await connector._download_github_package(
|
|
{
|
|
'owner': 'langbot-app',
|
|
'repo': 'demo-plugin',
|
|
'release_tag': 'v1.0.0',
|
|
'release_id': 42,
|
|
'asset_id': 99,
|
|
'asset_url': 'https://attacker.invalid/ignored',
|
|
},
|
|
None,
|
|
)
|
|
|
|
assert len(observed) == 1
|
|
assert observed[0]['trust_env'] is False
|
|
assert observed[0]['follow_redirects'] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_github_download_rejects_oversized_content_length(monkeypatch):
|
|
connector = create_mock_connector()
|
|
monkeypatch.setattr(connector_module, '_GITHUB_PLUGIN_DOWNLOAD_MAX_BYTES', 8)
|
|
|
|
def handler(_request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(200, headers={'content-length': '9'}, content=b'')
|
|
|
|
_patch_client(monkeypatch, handler)
|
|
with pytest.raises(ValueError, match='10 MiB download limit'):
|
|
await connector._download_github_package(TRUSTED_LEGACY_REQUEST, None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_github_download_counts_chunked_stream_bytes(monkeypatch):
|
|
connector = create_mock_connector()
|
|
monkeypatch.setattr(connector_module, '_GITHUB_PLUGIN_DOWNLOAD_MAX_BYTES', 8)
|
|
|
|
class ChunkedBody(httpx.AsyncByteStream):
|
|
async def __aiter__(self):
|
|
yield b'1234'
|
|
yield b'56789'
|
|
|
|
def handler(_request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(200, stream=ChunkedBody())
|
|
|
|
_patch_client(monkeypatch, handler)
|
|
with pytest.raises(ValueError, match='10 MiB download limit'):
|
|
await connector._download_github_package(TRUSTED_LEGACY_REQUEST, None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_github_asset_id_download_allows_github_object_redirect(monkeypatch):
|
|
connector = create_mock_connector()
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
if request.url.path.endswith('/releases/42'):
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
'id': 42,
|
|
'tag_name': 'v1.0.0',
|
|
'assets': [{'id': 99, 'size': 7, 'state': 'uploaded'}],
|
|
},
|
|
)
|
|
if request.url.path.endswith('/releases/assets/99'):
|
|
return httpx.Response(
|
|
302,
|
|
headers={
|
|
'location': 'https://release-assets.githubusercontent.com/github-production-release-asset/demo'
|
|
},
|
|
)
|
|
if request.url.host == 'release-assets.githubusercontent.com':
|
|
return httpx.Response(200, content=b'package')
|
|
raise AssertionError(f'unexpected request: {request.url}')
|
|
|
|
_patch_client(monkeypatch, handler)
|
|
package = await connector._download_github_package(
|
|
{
|
|
'owner': 'langbot-app',
|
|
'repo': 'demo-plugin',
|
|
'release_tag': 'v1.0.0',
|
|
'release_id': 42,
|
|
'asset_id': 99,
|
|
},
|
|
None,
|
|
)
|
|
|
|
assert package == b'package'
|