feat: Extensions for all (#4021)
Co-authored-by: alan <alan@lnbits.com> Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
This commit is contained in:
co-authored by
alan
Tiago Vasconcelos
parent
43900dd6da
commit
61ed636df0
@@ -0,0 +1,214 @@
|
||||
import ipaddress
|
||||
from typing import cast
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pytest_mock.plugin import MockerFixture
|
||||
|
||||
from lnbits.core.wasm_ext.api.models import HttpRequest
|
||||
from lnbits.core.wasm_ext.client import http as wasm_http
|
||||
|
||||
|
||||
def test_wasm_http_origin_normalization_and_url_rejections():
|
||||
assert (
|
||||
wasm_http._request_origin("https://EXAMPLE.com/path") == "https://example.com"
|
||||
)
|
||||
assert (
|
||||
wasm_http._request_origin("https://example.com:8443/path")
|
||||
== "https://example.com:8443"
|
||||
)
|
||||
|
||||
for url, message in [
|
||||
("http://example.com", "https URLs"),
|
||||
("https://user:pass@example.com", "credentials"),
|
||||
("https:///missing-host", "hostname"),
|
||||
("https://example.com:bad", "invalid port"),
|
||||
]:
|
||||
with pytest.raises(PermissionError, match=message):
|
||||
wasm_http._request_origin(url)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_http_rejects_internal_hosts(mocker: MockerFixture):
|
||||
with pytest.raises(PermissionError, match="localhost"):
|
||||
await wasm_http._reject_internal_host("https://localhost/api")
|
||||
|
||||
with pytest.raises(PermissionError, match="internal network"):
|
||||
await wasm_http._reject_internal_host("https://127.0.0.1/api")
|
||||
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.client.http._resolve_host",
|
||||
mocker.AsyncMock(return_value=[ipaddress.ip_address("10.0.0.1")]),
|
||||
)
|
||||
with pytest.raises(PermissionError, match="internal network"):
|
||||
await wasm_http._reject_internal_host("https://api.example.com")
|
||||
|
||||
|
||||
def test_wasm_http_strips_forbidden_headers_and_normalizes_timeout():
|
||||
assert wasm_http._request_headers(
|
||||
{
|
||||
" Host ": "evil.example",
|
||||
"Content-Length": "10",
|
||||
"X-Trace": "ok",
|
||||
"Cookie": "session=secret",
|
||||
}
|
||||
) == {"X-Trace": "ok"}
|
||||
assert wasm_http._response_headers(
|
||||
{
|
||||
"content-length": "10",
|
||||
"set-cookie": "session=secret",
|
||||
"x-safe": "ok",
|
||||
}
|
||||
) == {"x-safe": "ok"}
|
||||
assert wasm_http._timeout_seconds(None, 10.0) == 10.0
|
||||
assert wasm_http._timeout_seconds(2500, 10.0) == 2.5
|
||||
assert wasm_http._timeout_seconds(0, 10.0) is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_http_request_enforces_policies_and_response_bounds(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.client.http._reject_internal_host",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
client = _FakeAsyncClient(
|
||||
_FakeStreamResponse(
|
||||
status_code=201,
|
||||
headers={
|
||||
"content-type": "application/json",
|
||||
"set-cookie": "session=secret",
|
||||
},
|
||||
chunks=[b'{"ok":true}'],
|
||||
)
|
||||
)
|
||||
mocker.patch("lnbits.core.wasm_ext.client.http.httpx.AsyncClient", client.factory)
|
||||
|
||||
response = await wasm_http.send_extension_http_request(
|
||||
"demoext",
|
||||
[{"host": "https://api.example.com"}],
|
||||
HttpRequest(
|
||||
method="POST",
|
||||
url="https://api.example.com/path",
|
||||
headers={"Host": "evil.example", "X-Test": "yes"},
|
||||
body="{}",
|
||||
),
|
||||
timeout_ms=500,
|
||||
max_response_bytes=100,
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.body == '{"ok":true}'
|
||||
assert response.headers == {"content-type": "application/json"}
|
||||
assert client.kwargs["follow_redirects"] is False
|
||||
assert client.kwargs["trust_env"] is False
|
||||
assert client.kwargs["timeout"] == 0.5
|
||||
assert client.stream_kwargs["headers"] == {"X-Test": "yes"}
|
||||
|
||||
with pytest.raises(PermissionError, match="not allowed"):
|
||||
await wasm_http.send_extension_http_request(
|
||||
"demoext",
|
||||
[{"host": "https://api.example.com"}],
|
||||
HttpRequest(url="https://other.example.com/path", body=None),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_http_request_rejects_oversized_bodies_and_responses(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.client.http._reject_internal_host",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
with pytest.raises(ValueError, match="body is too large"):
|
||||
await wasm_http.send_extension_http_request(
|
||||
"demoext",
|
||||
[{"host": "https://api.example.com"}],
|
||||
HttpRequest.construct(
|
||||
method="GET",
|
||||
url="https://api.example.com/path",
|
||||
headers={},
|
||||
body="x" * 65_537,
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="response is too large"):
|
||||
await wasm_http._read_limited_response(
|
||||
cast(httpx.Response, _FakeStreamResponse(chunks=[b"12345", b"67890"])),
|
||||
max_response_bytes=8,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_http_request_hides_transport_errors(mocker: MockerFixture):
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.client.http._reject_internal_host",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
client = _FakeAsyncClient(_FakeStreamError())
|
||||
mocker.patch("lnbits.core.wasm_ext.client.http.httpx.AsyncClient", client.factory)
|
||||
|
||||
with pytest.raises(ValueError, match="HTTP request failed"):
|
||||
await wasm_http.send_extension_http_request(
|
||||
"demoext",
|
||||
[{"host": "https://api.example.com"}],
|
||||
HttpRequest(url="https://api.example.com/path", body=None),
|
||||
)
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
def __init__(self, response):
|
||||
self.response = response
|
||||
self.kwargs = {}
|
||||
self.stream_kwargs = {}
|
||||
|
||||
def factory(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
return self
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return False
|
||||
|
||||
def stream(self, method, url, **kwargs):
|
||||
self.stream_kwargs = {"method": method, "url": url, **kwargs}
|
||||
return self.response
|
||||
|
||||
|
||||
class _FakeStreamResponse:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
status_code: int = 200,
|
||||
headers: dict[str, str] | None = None,
|
||||
chunks: list[bytes] | None = None,
|
||||
):
|
||||
self.status_code = status_code
|
||||
self.headers = headers or {}
|
||||
self.encoding = "utf-8"
|
||||
self._chunks = chunks or []
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return False
|
||||
|
||||
async def aiter_bytes(self):
|
||||
for chunk in self._chunks:
|
||||
yield chunk
|
||||
|
||||
|
||||
class _FakeStreamError:
|
||||
async def __aenter__(self):
|
||||
raise httpx.RequestError(
|
||||
"network failed",
|
||||
request=httpx.Request("GET", "https://api.example.com"),
|
||||
)
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return False
|
||||
Reference in New Issue
Block a user