feat: ext http request
This commit is contained in:
@@ -19,6 +19,8 @@ from .models import (
|
|||||||
CreateInvoiceRequest,
|
CreateInvoiceRequest,
|
||||||
CreateInvoiceResponse,
|
CreateInvoiceResponse,
|
||||||
EmptyRequest,
|
EmptyRequest,
|
||||||
|
HttpRequest,
|
||||||
|
HttpResponse,
|
||||||
ListUserWalletsResponse,
|
ListUserWalletsResponse,
|
||||||
LogRequest,
|
LogRequest,
|
||||||
LogResponse,
|
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(
|
@extension_api_method(
|
||||||
method_id="system.random_id",
|
method_id="system.random_id",
|
||||||
namespace="system",
|
namespace="system",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -118,6 +118,37 @@ class ListUserWalletsResponse(BaseModel):
|
|||||||
wallets: list[UserWalletSummary] = Field(default_factory=list)
|
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):
|
class RandomIdRequest(BaseModel):
|
||||||
prefix: str = Field(..., min_length=1, max_length=32)
|
prefix: str = Field(..., min_length=1, max_length=32)
|
||||||
|
|
||||||
|
|||||||
@@ -525,6 +525,10 @@ window.localisation.en = {
|
|||||||
extension_permission_ext_storage_read: 'Read extension storage',
|
extension_permission_ext_storage_read: 'Read extension storage',
|
||||||
extension_permission_ext_storage_read_public: 'Read public extension storage',
|
extension_permission_ext_storage_read_public: 'Read public extension storage',
|
||||||
extension_permission_ext_storage_write: 'Write 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_payments_watch: 'Watch payments',
|
||||||
extension_permission_wallet_create_invoice: 'Create invoices',
|
extension_permission_wallet_create_invoice: 'Create invoices',
|
||||||
extension_permission_wallet_create_invoice_public:
|
extension_permission_wallet_create_invoice_public:
|
||||||
|
|||||||
@@ -736,6 +736,12 @@ window.PageExtensions = {
|
|||||||
const description = this.$t(key)
|
const description = this.$t(key)
|
||||||
return description === key ? permission.description : description
|
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() {
|
async selectAllUpdatableExtensionss() {
|
||||||
this.updatableExtensions.forEach(e => (e.selectedForUpdate = true))
|
this.updatableExtensions.forEach(e => (e.selectedForUpdate = true))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -483,6 +483,11 @@
|
|||||||
caption
|
caption
|
||||||
v-text="permissionDescription(permission)"
|
v-text="permissionDescription(permission)"
|
||||||
></q-item-label>
|
></q-item-label>
|
||||||
|
<q-item-label
|
||||||
|
v-if="permissionPolicyDetails(permission)"
|
||||||
|
caption
|
||||||
|
v-text="permissionPolicyDetails(permission)"
|
||||||
|
></q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
|
|||||||
Reference in New Issue
Block a user