feat(webui): support document attachments with ingress safeguards (#4771)

* feat: support document attachments in webui

* fix(webui): normalize document attachment MIME

* refactor(webui): move attachment policy out of channel

* fix(webui): reject oversized attachments before send

* fix(webui): align Portuguese attachment errors

* refactor(webui): separate ingress and transport limits

* fix(webui): reject malformed attachment payloads
This commit is contained in:
chengyongru
2026-07-14 14:47:42 +08:00
committed by GitHub
parent b2759e8a6b
commit b7048cf76a
36 changed files with 1398 additions and 332 deletions
+21 -118
View File
@@ -31,7 +31,6 @@ from nanobot.bus.outbound_events import (
)
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
from nanobot.security.workspace_access import (
WORKSPACE_SCOPE_METADATA_KEY,
@@ -39,10 +38,6 @@ from nanobot.security.workspace_access import (
)
from nanobot.session.goal_state import goal_state_ws_blob
from nanobot.session.webui_turns import websocket_turn_wall_started_at
from nanobot.utils.media_decode import (
FileSizeExceeded,
save_base64_data_url,
)
from nanobot.webui.cli_apps_api import normalize_cli_app_mentions
from nanobot.webui.forking import handle_webui_fork_chat
from nanobot.webui.gateway_services import GatewayServices
@@ -221,45 +216,6 @@ def _parse_envelope(raw: str) -> dict[str, Any] | None:
return data
# Per-message media limits. The server-side guard is a touch looser than the
# client's ``Worker`` normalization target (6 MB) — tolerate client slop, but
# still cap total ingress at ``_MAX_IMAGES_PER_MESSAGE * _MAX_IMAGE_BYTES``
# which fits comfortably inside ``max_message_bytes``.
_MAX_IMAGES_PER_MESSAGE = 4
_MAX_IMAGE_BYTES = 8 * 1024 * 1024
_MAX_VIDEOS_PER_MESSAGE = 1
_MAX_VIDEO_BYTES = 20 * 1024 * 1024
# Image MIME whitelist — matches the Composer's ``accept`` list. SVG is
# explicitly excluded to avoid the XSS surface inside embedded scripts.
_IMAGE_MIME_ALLOWED: frozenset[str] = frozenset({
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
})
_VIDEO_MIME_ALLOWED: frozenset[str] = frozenset({
"video/mp4",
"video/webm",
"video/quicktime",
})
_UPLOAD_MIME_ALLOWED: frozenset[str] = _IMAGE_MIME_ALLOWED | _VIDEO_MIME_ALLOWED
_DATA_URL_MIME_RE = re.compile(r"^data:([^;,]+)(?:;[^,]*)*;base64,", re.DOTALL)
def _extract_data_url_mime(url: str) -> str | None:
"""Return the MIME type of a ``data:<mime>;base64,...`` URL, else ``None``."""
if not isinstance(url, str):
return None
m = _DATA_URL_MIME_RE.match(url)
if not m:
return None
return m.group(1).strip().lower() or None
def _is_websocket_upgrade(request: WsRequest) -> bool:
"""Detect an actual WS upgrade; plain HTTP GETs to the same path should fall through."""
upgrade = request.headers.get("Upgrade") or request.headers.get("upgrade")
@@ -301,6 +257,7 @@ class WebSocketChannel(BaseChannel):
self._http_router = gateway.http
self._tokens = gateway.tokens
self._media = gateway.media
self._ingress = gateway.ingress
self._transcripts = gateway.transcripts
self._workspaces = gateway.workspaces
@@ -582,74 +539,6 @@ class WebSocketChannel(BaseChannel):
# -- Inbound WebSocket envelopes ---------------------------------------
def _save_envelope_media(
self,
media: list[Any],
) -> tuple[list[str], str | None]:
"""Decode and persist ``media`` items from a ``message`` envelope.
Returns ``(paths, None)`` on success or ``([], reason)`` on the first
failure — the caller is expected to surface ``reason`` to the client
and skip publishing so no half-formed message ever reaches the agent.
On failure, any files already written to disk earlier in the same
call are unlinked so partial ingress doesn't leak orphan files.
``reason`` is a short, stable token suitable for UI localization.
Shape: ``list[{"data_url": str, "name"?: str | None}]``.
"""
image_count = 0
video_count = 0
for item in media:
mime = _extract_data_url_mime(item.get("data_url", "")) if isinstance(item, dict) else None
if mime in _VIDEO_MIME_ALLOWED:
video_count += 1
elif mime in _IMAGE_MIME_ALLOWED:
image_count += 1
if image_count > _MAX_IMAGES_PER_MESSAGE:
return [], "too_many_images"
if video_count > _MAX_VIDEOS_PER_MESSAGE:
return [], "too_many_videos"
media_dir = get_media_dir("websocket")
paths: list[str] = []
def _abort(reason: str) -> tuple[list[str], str]:
for p in paths:
try:
Path(p).unlink(missing_ok=True)
except OSError as exc:
self.logger.warning(
"failed to unlink partial media {}: {}", p, exc
)
return [], reason
for item in media:
if not isinstance(item, dict):
return _abort("malformed")
data_url = item.get("data_url")
if not isinstance(data_url, str) or not data_url:
return _abort("malformed")
mime = _extract_data_url_mime(data_url)
if mime is None:
return _abort("decode")
if mime not in _UPLOAD_MIME_ALLOWED:
return _abort("mime")
is_video = mime in _VIDEO_MIME_ALLOWED
max_bytes = _MAX_VIDEO_BYTES if is_video else _MAX_IMAGE_BYTES
try:
saved = save_base64_data_url(
data_url, media_dir, max_bytes=max_bytes,
)
except FileSizeExceeded:
return _abort("size")
except Exception as exc:
self.logger.warning("media decode failed: {}", exc)
return _abort("decode")
if saved is None:
return _abort("decode")
paths.append(saved)
return paths, None
async def _dispatch_envelope(
self,
connection: Any,
@@ -732,25 +621,39 @@ class WebSocketChannel(BaseChannel):
if not isinstance(content, str):
await self._send_event(connection, "error", detail="missing content")
return
message_rejection = self._ingress.validate_text(content)
if message_rejection is not None:
await self._send_event(
connection,
"error",
chat_id=cid,
detail="message_rejected",
reason=message_rejection,
)
return
raw_media = envelope.get("media")
media_paths: list[str] = []
if raw_media is not None:
if not isinstance(raw_media, list):
await self._send_event(
connection, "error",
detail="image_rejected", reason="malformed",
connection,
"error",
detail="attachment_rejected",
reason="malformed",
)
return
media_paths, reason = self._save_envelope_media(raw_media)
media_paths, reason = self._media.store_inbound_attachments(raw_media)
if reason is not None:
await self._send_event(
connection, "error",
detail="image_rejected", reason=reason,
connection,
"error",
detail="attachment_rejected",
reason=reason,
)
return
# Allow image-only turns (content may be empty when media is attached).
# Allow media-only turns (content may be empty when attachments are present).
if not content.strip() and not media_paths:
await self._send_event(connection, "error", detail="missing content")
return
+24 -3
View File
@@ -32,6 +32,22 @@ _MIME_EXTENSION_OVERRIDES = {
"audio/x-wav": ".wav",
"audio/vnd.wave": ".wav",
"video/webm": ".webm",
"application/json": ".json",
"application/pdf": ".pdf",
"application/toml": ".toml",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"application/x-yaml": ".yaml",
"application/xhtml+xml": ".html",
"application/xml": ".xml",
"application/yaml": ".yaml",
"text/csv": ".csv",
"text/html": ".html",
"text/markdown": ".md",
"text/plain": ".txt",
"text/xml": ".xml",
"text/yaml": ".yaml",
}
@@ -47,6 +63,7 @@ def save_base64_data_url(
media_dir: Path,
*,
max_bytes: int | None = None,
filename: str | None = None,
) -> str | None:
"""Decode a ``data:<mime>;base64,<payload>`` URL and persist it.
@@ -59,14 +76,18 @@ def save_base64_data_url(
return None
mime_type, b64_payload = m.group(1).strip().lower(), m.group(2)
try:
raw = base64.b64decode(b64_payload)
raw = base64.b64decode(b64_payload, validate=True)
except Exception:
return None
if not raw:
return None
limit = DEFAULT_MAX_BYTES if max_bytes is None else max_bytes
if len(raw) > limit:
raise FileSizeExceeded(f"File exceeds {limit // (1024 * 1024)}MB limit")
ext = _MIME_EXTENSION_OVERRIDES.get(mime_type) or mimetypes.guess_extension(mime_type) or ".bin"
filename = f"{uuid.uuid4().hex[:12]}{ext}"
dest = media_dir / safe_filename(filename)
base = safe_filename(filename or "")
stem = Path(base).stem[:80] if base else ""
saved_name = f"{uuid.uuid4().hex[:12]}_{stem}{ext}" if stem else f"{uuid.uuid4().hex[:12]}{ext}"
dest = media_dir / safe_filename(saved_name)
dest.write_bytes(raw)
return str(dest)
+170
View File
@@ -0,0 +1,170 @@
"""Validation and persistence for inbound WebUI message attachments."""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any, Literal
from nanobot.utils.media_decode import FileSizeExceeded, save_base64_data_url
from nanobot.webui.ingress_policy import (
DEFAULT_WEBUI_INGRESS_POLICY,
AttachmentIngressLimits,
)
AttachmentRejection = Literal[
"malformed",
"too_many_images",
"too_many_videos",
"too_many_attachments",
"total_size",
"mime",
"size",
"decode",
]
AttachmentIngressResult = tuple[list[str], AttachmentRejection | None]
_MAX_VIDEOS_PER_MESSAGE = 1
_MAX_VIDEO_BYTES = 20 * 1024 * 1024
_IMAGE_MIME_ALLOWED: frozenset[str] = frozenset({
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
})
_VIDEO_MIME_ALLOWED: frozenset[str] = frozenset({
"video/mp4",
"video/webm",
"video/quicktime",
})
_DOCUMENT_MIME_ALLOWED: frozenset[str] = frozenset({
"application/json",
"application/pdf",
"application/toml",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/x-yaml",
"application/xhtml+xml",
"application/xml",
"application/yaml",
"text/csv",
"text/html",
"text/markdown",
"text/plain",
"text/xml",
"text/yaml",
})
_UPLOAD_MIME_ALLOWED: frozenset[str] = (
_IMAGE_MIME_ALLOWED | _VIDEO_MIME_ALLOWED | _DOCUMENT_MIME_ALLOWED
)
_DATA_URL_MIME_RE = re.compile(r"^data:([^;,]+)(?:;[^,]*)*;base64,", re.DOTALL)
def extract_data_url_mime(url: Any) -> str | None:
"""Return the normalized MIME from a base64 data URL, else ``None``."""
if not isinstance(url, str):
return None
match = _DATA_URL_MIME_RE.match(url)
if match is None:
return None
return match.group(1).strip().lower() or None
def store_inbound_attachments(
media: list[Any],
*,
media_dir: Path,
logger: Any,
limits: AttachmentIngressLimits = DEFAULT_WEBUI_INGRESS_POLICY.attachments,
) -> AttachmentIngressResult:
"""Validate and atomically persist one WebUI message's attachments.
The caller owns transport-level error mapping. This function owns the
WebUI upload policy and removes files already written when a later item
makes the batch invalid.
"""
image_count = 0
video_count = 0
document_count = 0
for item in media:
mime = (
extract_data_url_mime(item.get("data_url", ""))
if isinstance(item, dict)
else None
)
if mime in _VIDEO_MIME_ALLOWED:
video_count += 1
elif mime in _IMAGE_MIME_ALLOWED:
image_count += 1
elif mime in _DOCUMENT_MIME_ALLOWED:
document_count += 1
if image_count > limits.max_count:
return [], "too_many_images"
if video_count > _MAX_VIDEOS_PER_MESSAGE:
return [], "too_many_videos"
if image_count + document_count > limits.max_count:
return [], "too_many_attachments"
paths: list[str] = []
total_attachment_bytes = 0
def abort(reason: AttachmentRejection) -> AttachmentIngressResult:
for path in paths:
try:
Path(path).unlink(missing_ok=True)
except OSError as exc:
logger.warning("failed to unlink partial media {}: {}", path, exc)
return [], reason
for item in media:
if not isinstance(item, dict):
return abort("malformed")
data_url = item.get("data_url")
if not isinstance(data_url, str) or not data_url:
return abort("malformed")
mime = extract_data_url_mime(data_url)
if mime is None:
return abort("decode")
if mime not in _UPLOAD_MIME_ALLOWED:
return abort("mime")
is_video = mime in _VIDEO_MIME_ALLOWED
is_document = mime in _DOCUMENT_MIME_ALLOWED
max_bytes = (
_MAX_VIDEO_BYTES if is_video
else limits.max_file_bytes
)
name = (
item.get("name")
if is_document and isinstance(item.get("name"), str)
else None
)
try:
saved = save_base64_data_url(
data_url,
media_dir,
max_bytes=max_bytes,
filename=name,
)
except FileSizeExceeded:
return abort("size")
except Exception as exc:
logger.warning("media decode failed: {}", exc)
return abort("decode")
if saved is None:
return abort("decode")
paths.append(saved)
if not is_video:
try:
total_attachment_bytes += Path(saved).stat().st_size
except OSError as exc:
logger.warning("failed to stat inbound attachment {}: {}", saved, exc)
return abort("decode")
if total_attachment_bytes > limits.max_total_bytes:
return abort("total_size")
return paths, None
+14
View File
@@ -9,6 +9,7 @@ from typing import Any, Callable
from loguru import logger as default_logger
from nanobot.webui.gateway_tokens import GatewayTokenStore
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
from nanobot.webui.media_gateway import WebUIMediaGateway
from nanobot.webui.transcript import WebUITranscriptRecorder
from nanobot.webui.workspaces import WebUIWorkspaceController
@@ -22,6 +23,7 @@ class GatewayServices:
http: GatewayHTTPHandler
tokens: GatewayTokenStore
media: WebUIMediaGateway
ingress: WebUIIngressPolicy
transcripts: WebUITranscriptRecorder
workspaces: WebUIWorkspaceController
session_manager: Any | None
@@ -51,9 +53,19 @@ def build_gateway_services(
logger: Any = default_logger,
) -> GatewayServices:
tokens = GatewayTokenStore()
ingress = DEFAULT_WEBUI_INGRESS_POLICY
minimum_frame_bytes = ingress.minimum_full_policy_frame_bytes()
if config.max_message_bytes < minimum_frame_bytes:
logger.warning(
"WebSocket maxMessageBytes={} is below the WebUI ingress policy capacity={}; "
"policy-valid messages may still hit the transport frame guard",
config.max_message_bytes,
minimum_frame_bytes,
)
media = WebUIMediaGateway(
workspace_path=workspace_path,
logger=logger,
attachment_limits=ingress.attachments,
)
transcripts = WebUITranscriptRecorder(log=logger)
workspaces = WebUIWorkspaceController(
@@ -71,6 +83,7 @@ def build_gateway_services(
bus=bus,
tokens=tokens,
media=media,
ingress=ingress,
workspaces=workspaces,
skills_workspace_path=workspace_path,
disabled_skills=disabled_skills,
@@ -85,6 +98,7 @@ def build_gateway_services(
http=http,
tokens=tokens,
media=media,
ingress=ingress,
transcripts=transcripts,
workspaces=workspaces,
session_manager=session_manager,
+70
View File
@@ -0,0 +1,70 @@
"""Business limits for inbound WebUI messages and attachments.
The WebSocket channel owns its raw frame limit. This module owns semantic
limits inside a decoded WebUI message so transport capacity isn't mistaken
for a text or attachment policy.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from typing import Literal
MessageRejection = Literal["text_too_large"]
@dataclass(frozen=True)
class MessageIngressLimits:
max_text_bytes: int = 64 * 1024
@dataclass(frozen=True)
class AttachmentIngressLimits:
max_count: int = 4
max_file_bytes: int = 6 * 1024 * 1024
max_total_bytes: int = 24 * 1024 * 1024
@dataclass(frozen=True)
class WebUIIngressPolicy:
"""Limits applied after the channel has decoded the transport envelope."""
message: MessageIngressLimits = field(default_factory=MessageIngressLimits)
attachments: AttachmentIngressLimits = field(default_factory=AttachmentIngressLimits)
# Covers JSON keys, IDs, attachment names, MIME prefixes, mentions, and
# other non-content fields when the browser estimates whether a frame fits.
envelope_reserve_bytes: int = 64 * 1024
def validate_text(self, content: str) -> MessageRejection | None:
if len(content.encode("utf-8")) > self.message.max_text_bytes:
return "text_too_large"
return None
def bootstrap_limits(self, *, max_frame_bytes: int) -> dict[str, object]:
return {
"transport": {
"max_frame_bytes": max_frame_bytes,
"envelope_reserve_bytes": self.envelope_reserve_bytes,
},
"message": {"max_text_bytes": self.message.max_text_bytes},
"attachments": {
"max_count": self.attachments.max_count,
"max_file_bytes": self.attachments.max_file_bytes,
"max_total_bytes": self.attachments.max_total_bytes,
},
}
def minimum_full_policy_frame_bytes(self) -> int:
"""Conservative frame size needed for every policy-valid message."""
encoded_attachments = 4 * math.ceil(self.attachments.max_total_bytes / 3)
data_url_allowance = self.attachments.max_count * 128
return (
encoded_attachments
+ data_url_allowance
+ self.message.max_text_bytes
+ self.envelope_reserve_bytes
)
DEFAULT_WEBUI_INGRESS_POLICY = WebUIIngressPolicy()
+16
View File
@@ -11,6 +11,11 @@ from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.config.paths import get_media_dir
from nanobot.webui.attachment_ingress import (
AttachmentIngressResult,
store_inbound_attachments,
)
from nanobot.webui.ingress_policy import AttachmentIngressLimits
from nanobot.webui.media_api import (
attach_signed_media_urls,
serve_signed_media,
@@ -31,11 +36,22 @@ class WebUIMediaGateway:
logger: Any,
media_dir: Callable[[str | None], Path] | None = None,
secret: bytes | None = None,
attachment_limits: AttachmentIngressLimits | None = None,
) -> None:
self.workspace_path = workspace_path
self.logger = logger
self._media_dir = media_dir or (lambda channel=None: get_media_dir(channel))
self.secret = secret or secrets.token_bytes(32)
self.attachment_limits = attachment_limits or AttachmentIngressLimits()
def store_inbound_attachments(self, media: list[Any]) -> AttachmentIngressResult:
"""Validate and persist attachments from an inbound WebUI message."""
return store_inbound_attachments(
media,
media_dir=self._media_dir("websocket"),
logger=self.logger,
limits=self.attachment_limits,
)
def serve_signed_media(
self,
+6
View File
@@ -70,6 +70,7 @@ from nanobot.webui.http_utils import (
from nanobot.webui.http_utils import (
safe_host_header as _safe_host_header,
)
from nanobot.webui.ingress_policy import WebUIIngressPolicy
from nanobot.webui.media_gateway import WebUIMediaGateway
from nanobot.webui.session_automations import (
all_automations_payload,
@@ -155,6 +156,7 @@ class GatewayHTTPHandler:
bus: MessageBus,
tokens: GatewayTokenStore,
media: WebUIMediaGateway,
ingress: WebUIIngressPolicy,
workspaces: WebUIWorkspaceController,
skills_workspace_path: Path,
disabled_skills: set[str] | None = None,
@@ -172,6 +174,7 @@ class GatewayHTTPHandler:
self.bus = bus
self.tokens = tokens
self.media = media
self.ingress = ingress
self.workspaces = workspaces
self.skills_workspace_path = skills_workspace_path
self.disabled_skills = disabled_skills or set()
@@ -340,6 +343,9 @@ class GatewayHTTPHandler:
"ws_path": expected_path,
"ws_url": ws_url,
"expires_in": self.config.token_ttl_s,
"limits": self.ingress.bootstrap_limits(
max_frame_bytes=self.config.max_message_bytes,
),
"model_name": _resolve_bootstrap_model_name(self.runtime_model_name),
"runtime_surface": self._runtime_surface,
"runtime_capabilities": self._capabilities,