diff --git a/lnbits/core/extensions/api.py b/lnbits/core/extensions/api.py index 18cc4bffa..a3f4b397c 100644 --- a/lnbits/core/extensions/api.py +++ b/lnbits/core/extensions/api.py @@ -19,6 +19,8 @@ from .models import ( CreateInvoiceRequest, CreateInvoiceResponse, EmptyRequest, + HttpRequest, + HttpResponse, ListUserWalletsResponse, LogRequest, LogResponse, @@ -397,6 +399,22 @@ class ExtensionAPI: ] ) + @extension_api_method( + method_id="http.request", + namespace="http", + name="HTTP request", + host_name="http_request", + sdk_name="request", + description="Make an outbound HTTP request to an allowed host.", + required_permission="http.request", + require_auth=True, + ) + async def http_request(self, request: HttpRequest) -> HttpResponse: + from .http_client import send_extension_http_request + + policy = self.permission_policies.get("http.request") or {} + return await send_extension_http_request(self.extension_id, policy, request) + @extension_api_method( method_id="system.random_id", namespace="system", diff --git a/lnbits/core/extensions/http_client.py b/lnbits/core/extensions/http_client.py new file mode 100644 index 000000000..e682f1745 --- /dev/null +++ b/lnbits/core/extensions/http_client.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import ipaddress +import socket +from typing import Any +from urllib.parse import urlparse + +import httpx + +from .models import HttpRequest, HttpResponse + +HTTP_REQUEST_TIMEOUT_SECONDS = 5.0 +HTTP_MAX_RESPONSE_BYTES = 262_144 + +_FORBIDDEN_REQUEST_HEADERS = { + "connection", + "content-length", + "cookie", + "host", + "proxy-authorization", + "transfer-encoding", +} +_FORBIDDEN_RESPONSE_HEADERS = { + "connection", + "content-length", + "set-cookie", + "transfer-encoding", +} + + +async def send_extension_http_request( + extension_id: str, + policy: dict[str, Any], + request: HttpRequest, +) -> HttpResponse: + allowed_origins = _allowed_origins(policy) + origin = _request_origin(request.url) + if origin not in allowed_origins: + raise PermissionError( + f"Extension '{extension_id}' is not allowed to request '{origin}'." + ) + + await _reject_internal_host(request.url) + headers = _request_headers(request.headers) + body = request.body.encode() if request.body is not None else b"" + if len(body) > 65_536: + raise ValueError("HTTP request body is too large.") + + try: + async with httpx.AsyncClient( + follow_redirects=False, + timeout=HTTP_REQUEST_TIMEOUT_SECONDS, + trust_env=False, + ) as client: + async with client.stream( + request.method, + request.url, + headers=headers, + content=body, + ) as response: + response_body = await _read_limited_response(response) + return HttpResponse( + status_code=response.status_code, + headers=_response_headers(dict(response.headers)), + body=response_body.decode(response.encoding or "utf-8", "replace"), + ) + except httpx.RequestError as exc: + raise ValueError("HTTP request failed.") from exc + + +def _allowed_origins(policy: dict[str, Any]) -> set[str]: + hosts = policy.get("hosts") + if not isinstance(hosts, list) or not hosts: + raise PermissionError("HTTP requests require a non-empty hosts policy.") + + origins: set[str] = set() + for host in hosts: + if not isinstance(host, str) or not host: + continue + origins.add(_request_origin(host)) + if not origins: + raise PermissionError("HTTP requests require at least one valid host.") + return origins + + +def _request_origin(url: str) -> str: + parsed = urlparse(url) + if parsed.scheme != "https": + raise PermissionError("HTTP requests require https URLs.") + if parsed.username or parsed.password: + raise PermissionError("HTTP requests cannot include credentials in URLs.") + if not parsed.hostname: + raise PermissionError("HTTP requests require a hostname.") + + hostname = parsed.hostname.lower() + port = _url_port(parsed) + if port is None or port == 443: + return f"https://{hostname}" + return f"https://{hostname}:{port}" + + +def _url_port(parsed: Any) -> int | None: + try: + return parsed.port + except ValueError as exc: + raise PermissionError("HTTP request URL has an invalid port.") from exc + + +async def _reject_internal_host(url: str) -> None: + parsed = urlparse(url) + hostname = parsed.hostname + if not hostname: + raise PermissionError("HTTP requests require a hostname.") + if hostname == "localhost" or hostname.endswith(".localhost"): + raise PermissionError("HTTP requests cannot target localhost.") + + try: + address = ipaddress.ip_address(hostname) + _reject_internal_address(address) + return + except ValueError: + pass + + for address in await _resolve_host(hostname): + _reject_internal_address(address) + + +async def _resolve_host( + hostname: str, +) -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]: + import asyncio + + def resolve() -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]: + try: + infos = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM) + except socket.gaierror as exc: + raise PermissionError("HTTP request host could not be resolved.") from exc + + addresses: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = [] + for info in infos: + sockaddr = info[4] + addresses.append(ipaddress.ip_address(sockaddr[0])) + return addresses + + return await asyncio.to_thread(resolve) + + +def _reject_internal_address( + address: ipaddress.IPv4Address | ipaddress.IPv6Address, +) -> None: + if not address.is_global: + raise PermissionError("HTTP requests cannot target internal network addresses.") + + +def _request_headers(headers: dict[str, str]) -> dict[str, str]: + clean: dict[str, str] = {} + for key, value in headers.items(): + header = key.strip() + if not header: + continue + if header.lower() in _FORBIDDEN_REQUEST_HEADERS: + continue + clean[header] = value + return clean + + +async def _read_limited_response(response: httpx.Response) -> bytes: + chunks: list[bytes] = [] + size = 0 + async for chunk in response.aiter_bytes(): + size += len(chunk) + if size > HTTP_MAX_RESPONSE_BYTES: + raise ValueError("HTTP response is too large.") + chunks.append(chunk) + return b"".join(chunks) + + +def _response_headers(headers: dict[str, str]) -> dict[str, str]: + return { + key: value + for key, value in headers.items() + if key.lower() not in _FORBIDDEN_RESPONSE_HEADERS + } diff --git a/lnbits/core/extensions/models.py b/lnbits/core/extensions/models.py index 867ed95fd..38a40a222 100644 --- a/lnbits/core/extensions/models.py +++ b/lnbits/core/extensions/models.py @@ -118,6 +118,37 @@ class ListUserWalletsResponse(BaseModel): wallets: list[UserWalletSummary] = Field(default_factory=list) +class HttpRequest(BaseModel): + method: Literal["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"] = "GET" + url: str = Field(..., min_length=1, max_length=2048) + headers: dict[str, str] = Field(default_factory=dict) + body: str | None = Field(None, max_length=65536) + + @root_validator(pre=True) + def normalize_method(cls, values: dict[str, Any]) -> dict[str, Any]: + method = values.get("method") + if isinstance(method, str): + values["method"] = method.upper() + return values + + @root_validator + def validate_headers_size(cls, values: dict[str, Any]) -> dict[str, Any]: + headers = values.get("headers") or {} + if len(headers) > 32: + raise ValueError("headers must not contain more than 32 entries.") + for key, value in headers.items(): + if len(key) > 128 or len(value) > 4096: + raise ValueError("headers are too large.") + values["headers"] = headers + return values + + +class HttpResponse(BaseModel): + status_code: int + headers: dict[str, str] = Field(default_factory=dict) + body: str = "" + + class RandomIdRequest(BaseModel): prefix: str = Field(..., min_length=1, max_length=32) diff --git a/lnbits/static/i18n/en.js b/lnbits/static/i18n/en.js index 1c9fa068e..337aca82b 100644 --- a/lnbits/static/i18n/en.js +++ b/lnbits/static/i18n/en.js @@ -525,6 +525,10 @@ window.localisation.en = { extension_permission_ext_storage_read: 'Read extension storage', extension_permission_ext_storage_read_public: 'Read public extension storage', extension_permission_ext_storage_write: 'Write extension storage', + extension_permission_http_request: 'Connect to external websites', + extension_permission_http_request_desc: + 'Make HTTP requests to approved external hosts.', + extension_permission_http_request_hosts: 'Allowed hosts', extension_permission_payments_watch: 'Watch payments', extension_permission_wallet_create_invoice: 'Create invoices', extension_permission_wallet_create_invoice_public: diff --git a/lnbits/static/js/pages/extensions.js b/lnbits/static/js/pages/extensions.js index 572b4fa5a..bbe02ce42 100644 --- a/lnbits/static/js/pages/extensions.js +++ b/lnbits/static/js/pages/extensions.js @@ -736,6 +736,12 @@ window.PageExtensions = { const description = this.$t(key) return description === key ? permission.description : description }, + permissionPolicyDetails(permission) { + if (permission.id !== 'http.request') return '' + const hosts = permission.policy?.hosts + if (!Array.isArray(hosts) || !hosts.length) return '' + return `${this.$t('extension_permission_http_request_hosts')}: ${hosts.join(', ')}` + }, async selectAllUpdatableExtensionss() { this.updatableExtensions.forEach(e => (e.selectedForUpdate = true)) }, diff --git a/lnbits/templates/pages/extensions.vue b/lnbits/templates/pages/extensions.vue index 546e7e97a..5201f8139 100644 --- a/lnbits/templates/pages/extensions.vue +++ b/lnbits/templates/pages/extensions.vue @@ -483,6 +483,11 @@ caption v-text="permissionDescription(permission)" > +