This commit is contained in:
Timothy Jaeryang Baek 2026-10-12 19:11:22 +04:00
parent 6229405b4d
commit 2d47128242
2 changed files with 86 additions and 39 deletions

View file

@ -4,6 +4,7 @@ import time
import uuid
from urllib.parse import urlsplit
import jwt
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrant, AccessGrants
@ -81,12 +82,15 @@ async def preview_app_manifest(form_data: AppManifestForm, user=Depends(get_admi
try:
status_code, raw = await send_app_request(form_data.url)
if status_code != 200:
raise ValueError()
raise HTTPException(502, f'App manifest endpoint returned HTTP {status_code}. Check the URL or enter details manually.')
manifest = Manifest.model_validate(raw)
return manifest.model_dump()
except Exception:
except HTTPException:
raise
except (ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid or unreachable app manifest'
status_code=status.HTTP_400_BAD_REQUEST,
detail='App returned an invalid manifest. Check its app details or enter them manually.',
) from None
@ -391,22 +395,32 @@ async def connect_app_by_id(
)
await db.commit()
challenge = str(uuid.uuid4())
status_code, value = await send_app_event(
app,
{'id': str(uuid.uuid4()), 'type': 'connect', 'data': {'challenge': challenge}},
timeout=20,
)
if status_code != 200:
detail = {
401: 'App rejected the API key. Enter the API key configured on the app server.',
403: 'App refused the connection. Check the app server access rules and logs.',
400: 'App rejected the connection request. Check the WebUI origin and app server configuration.',
404: 'App event endpoint was not found. Check the URL in app settings.',
405: 'App endpoint does not accept POST requests. Use its event endpoint URL.',
503: 'App is busy or shutting down. Connect again when it is ready.',
504: 'App connection request timed out. Check the app server logs.',
}.get(status_code, f'App returned HTTP {status_code}. Check the app server logs.')
raise HTTPException(504 if status_code == 504 else 502, detail)
if not isinstance(value, dict) or not isinstance(value.get('proof'), str):
raise HTTPException(502, 'App did not return an identity proof. Check its event endpoint and SDK version.')
try:
status_code, value = await send_app_event(
app,
{'id': str(uuid.uuid4()), 'type': 'connect', 'data': {'challenge': challenge}},
timeout=20,
)
if status_code != 200 or not isinstance(value, dict):
raise ValueError()
claims = verify_app_token(app, value['proof'], 'webui-app-connect+jwt')
if claims.get('challenge') != challenge:
raise ValueError()
except Exception:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail='Could not connect. Check the app API key and installed public key.',
) from None
except jwt.InvalidSignatureError:
raise HTTPException(502, 'App public key does not match the installed key. Review the app public key in settings.') from None
except (jwt.InvalidTokenError, ValueError, TypeError, KeyError):
raise HTTPException(502, 'App identity proof is invalid or expired. Check the installed public key and server clocks.') from None
result = await db.execute(
update(App)
.where(App.id == app_id, App.updated_at == checked_at, App.api_key == api_key)

View file

@ -92,27 +92,47 @@ async def send_app_request(
timeout: float = 10,
max_response_size: int = 262144,
) -> tuple[int, Any]:
allow_private = validate_app_url(url)
async with aiohttp.ClientSession(
connector=AppTCPConnector(allow_private=allow_private),
timeout=aiohttp.ClientTimeout(total=timeout),
trust_env=False,
) as session:
async with session.request(
'POST' if body is not None else 'GET',
url,
data=body,
headers=headers,
allow_redirects=False,
) as response:
response_body = bytearray()
async for chunk in response.content.iter_chunked(16384):
response_body.extend(chunk)
if len(response_body) > max_response_size:
raise ValueError('App response is too large')
if response.status != 200:
return response.status, None
return response.status, json.loads(response_body) if response_body else None
try:
allow_private = validate_app_url(url)
async with aiohttp.ClientSession(
connector=AppTCPConnector(allow_private=allow_private),
timeout=aiohttp.ClientTimeout(total=timeout),
trust_env=False,
) as session:
async with session.request(
'POST' if body is not None else 'GET',
url,
data=body,
headers=headers,
allow_redirects=False,
) as response:
response_body = bytearray()
async for chunk in response.content.iter_chunked(16384):
response_body.extend(chunk)
if len(response_body) > max_response_size:
raise ValueError('App response is too large')
if response.status != 200:
return response.status, None
return response.status, json.loads(response_body) if response_body else None
except TimeoutError:
raise HTTPException(504, 'App request timed out. Check that the app server is running and reachable.') from None
except aiohttp.ClientSSLError:
raise HTTPException(502, 'App TLS verification failed. Check its certificate and hostname.') from None
except aiohttp.ClientConnectorDNSError:
raise HTTPException(502, 'App hostname could not be resolved. Check DNS from the WebUI server.') from None
except aiohttp.ClientConnectorError:
raise HTTPException(502, 'Could not reach the app server. Check its address, port and network access.') from None
except aiohttp.ClientError:
raise HTTPException(502, 'The app connection failed before a complete response was received.') from None
except ValueError as exc:
# Only local, fixed diagnostics may be shown; never echo arbitrary app responses.
details = {
'Invalid service URL': 'Invalid app URL. Use an HTTP(S) URL without credentials or a fragment.',
'App endpoints require HTTPS': 'App endpoints require HTTPS. For local HTTP, configure APP_ALLOWED_PRIVATE_ORIGINS.',
'App endpoint resolves to a prohibited address': 'App resolves to a private or reserved address. For local development, configure APP_ALLOWED_PRIVATE_ORIGINS.',
'App response is too large': 'App response exceeds the allowed size.',
}
raise HTTPException(502, details.get(str(exc), 'App returned an invalid response. Expected JSON.')) from None
def get_app_encryption_cipher() -> Fernet:
@ -490,10 +510,23 @@ async def generate_app_channel_reply(
await db.commit()
try:
status, raw = await send_app_event(app, envelope)
except Exception:
raise HTTPException(504, 'App response timed out; execution outcome may be unknown') from None
except HTTPException as exc:
if exc.status_code == 504:
raise HTTPException(504, 'App response timed out; execution outcome may be unknown') from None
raise
except ValueError:
raise HTTPException(502, 'App event could not be delivered. Check the WebUI server logs.') from None
if status != 200:
raise HTTPException(502, 'App rejected the interaction')
detail = {
401: 'App rejected the API key. Update the key in app settings.',
403: 'App rejected event authorization. Check the app server logs for the verification failure.',
404: 'App event endpoint was not found. Check its URL in app settings.',
405: 'App endpoint does not accept POST requests. Check its event URL.',
413: 'App rejected the event because it is too large.',
503: 'App is busy or shutting down. The event was not accepted.',
504: 'App processing timed out; execution outcome may be unknown.',
}.get(status, f'App returned HTTP {status}. Check the app server logs.')
raise HTTPException(504 if status == 504 else 502, detail)
app = await get_app_by_id(app.id, require_active=True, db=db)
if (app.api_key, app.data['manifest']['public_key']) != checked_key:
raise HTTPException(403, 'App credentials changed')