{text}{meta}""" + assistant_name + " — Shared conversation
" + page_title + "
" + source + """" Security contract for server-backed conversation shares. The browser is untrusted. Share requests carry structured conversation data and an allowlisted representation id; callers never choose response MIME types or submit rendered HTML for the server to host. """ from __future__ import annotations import hashlib import hmac import html import json import math import re import secrets from typing import Any from urllib.parse import urlsplit, urlunsplit SHARE_SCHEMA_VERSION = "2.0" SHARE_FORMATS: dict[str, tuple[str, str]] = { "html": ("text/html; charset=utf-8", ".html"), "json": ("application/json; charset=utf-8", ".json"), "txt": ("text/plain; charset=utf-8", ".txt"), "yaml": ("application/yaml", ".yaml"), "toml": ("application/toml", ".toml"), } MAX_SHARE_RECORDS = 1000 MAX_SHARE_TEXT_CHARS = 200_000 MAX_SHARE_METADATA_CHARS = 2048 _SHARE_ID_RE = re.compile( r"^(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$" ) class ShareValidationError(ValueError): """Raised when an untrusted share snapshot violates the public contract.""" def _bounded_string( value: Any, *, limit: int, field: str, nullable: bool = True ) -> str | None: if value is None and nullable: return None if not isinstance(value, str): raise ShareValidationError( f"{field} must be a string" + (" or null" if nullable else "") ) if len(value) > limit: raise ShareValidationError(f"{field} is too long") return value def _bounded_int(value: Any, *, field: str, nullable: bool = True) -> int | None: if value is None and nullable: return None if isinstance(value, bool) or not isinstance(value, int): raise ShareValidationError( f"{field} must be an integer" + (" or null" if nullable else "") ) return value def _safe_scalar(value: Any, *, field: str) -> str | int | float | bool | None: if value is None or isinstance(value, (str, bool, int)): if isinstance(value, str) and len(value) > MAX_SHARE_METADATA_CHARS: raise ShareValidationError(f"{field} is too long") return value if isinstance(value, float) and math.isfinite(value): return value raise ShareValidationError(f"{field} must be a finite primitive value") def sanitize_share_page_url(value: Any) -> str: """Return an HTTP(S) source URL without credentials, query, or fragment.""" if not isinstance(value, str) or not value: return "" if len(value) > 8192: # ruff: ignore[magic-value-comparison] return "" try: parts = urlsplit(value) except ValueError: return "" if parts.scheme.lower() not in {"http", "https"} or not parts.hostname: return "" host = parts.hostname if ":" in host and not host.startswith("["): host = f"[{host}]" try: port = parts.port except ValueError: return "" if port is not None: host = f"{host}:{port}" return urlunsplit((parts.scheme.lower(), host, parts.path or "/", "", "")) def _canonical_record( raw: Any, index: int, safe_page: str, session_id: str ) -> dict[str, Any]: if not isinstance(raw, dict): raise ShareValidationError(f"records[{index}] must be an object") role = raw.get("role") if role not in {"user", "assistant", "error"}: raise ShareValidationError(f"records[{index}].role is not allowed") text = _bounded_string( raw.get("text"), limit=MAX_SHARE_TEXT_CHARS, field=f"records[{index}].text", nullable=False, ) turn_index = _bounded_int( raw.get("turn_index"), field=f"records[{index}].turn_index", nullable=False ) message_index = _bounded_int( raw.get("message_index"), field=f"records[{index}].message_index", nullable=False, ) ts = _bounded_int(raw.get("ts"), field=f"records[{index}].ts") ts_iso = _bounded_string( raw.get("ts_iso"), limit=128, field=f"records[{index}].ts_iso" ) return { "turn_index": turn_index, "message_index": message_index, "role": role, "text": text, "ts": ts, "ts_iso": ts_iso, "model_id": _bounded_string( raw.get("model_id"), limit=MAX_SHARE_METADATA_CHARS, field=f"records[{index}].model_id", ), "model_provider": _bounded_string( raw.get("model_provider"), limit=MAX_SHARE_METADATA_CHARS, field=f"records[{index}].model_provider", ), "model_name": _bounded_string( raw.get("model_name"), limit=MAX_SHARE_METADATA_CHARS, field=f"records[{index}].model_name", ), "feedback_rating_value": _safe_scalar( raw.get("feedback_rating_value"), field=f"records[{index}].feedback_rating_value", ), "feedback_rating_label": _bounded_string( raw.get("feedback_rating_label"), limit=MAX_SHARE_METADATA_CHARS, field=f"records[{index}].feedback_rating_label", ), "feedback_message": _bounded_string( raw.get("feedback_message"), limit=MAX_SHARE_TEXT_CHARS, field=f"records[{index}].feedback_message", ), # Never trust duplicated per-record identity/page claims from the client; # bind them to the canonical session values reconstructed above. "session_id": session_id, "page_url": safe_page, } def _build_turns(records: list[dict[str, Any]]) -> list[dict[str, Any]]: turns: list[dict[str, Any]] = [] current: dict[str, Any] | None = None for row in records: if row["role"] == "user": current = { "turn_index": row["turn_index"], "user": {"text": row["text"], "ts": row["ts"], "ts_iso": row["ts_iso"]}, "assistant": None, } turns.append(current) elif ( row["role"] == "assistant" and current is not None and current["assistant"] is None ): current["assistant"] = { "text": row["text"], "ts": row["ts"], "ts_iso": row["ts_iso"], "model_id": row["model_id"], "model_provider": row["model_provider"], "model_name": row["model_name"], "feedback_rating_value": row["feedback_rating_value"], "feedback_rating_label": row["feedback_rating_label"], "feedback_message": row["feedback_message"], } return turns def canonicalize_share_snapshot(raw: Any) -> dict[str, Any]: """Validate and reconstruct the allowlisted schema-v2 share snapshot.""" if not isinstance(raw, dict): raise ShareValidationError("snapshot must be an object") if raw.get("schema_version") != SHARE_SCHEMA_VERSION: raise ShareValidationError("snapshot.schema_version must be '2.0'") raw_session = raw.get("session") if not isinstance(raw_session, dict): raise ShareValidationError("snapshot.session must be an object") session_id = ( _bounded_string(raw_session.get("id"), limit=256, field="session.id") or "" ) safe_page = sanitize_share_page_url(raw_session.get("page_url")) session = { "id": session_id, "page_url": safe_page, "page_title": ( _bounded_string( raw_session.get("page_title"), limit=2048, field="session.page_title" ) or "" ), "assistant_name": ( _bounded_string( raw_session.get("assistant_name"), limit=256, field="session.assistant_name", ) or "AI Assistant" ), "exported_at": _bounded_int( raw_session.get("exported_at"), field="session.exported_at" ), "exported_at_iso": _bounded_string( raw_session.get("exported_at_iso"), limit=128, field="session.exported_at_iso", ), } raw_records = raw.get("records") if not isinstance(raw_records, list) or not raw_records: raise ShareValidationError("snapshot.records must be a non-empty array") if len(raw_records) > MAX_SHARE_RECORDS: raise ShareValidationError("snapshot.records contains too many messages") records = [ _canonical_record(row, i, safe_page, session_id) for i, row in enumerate(raw_records) ] # Never accept caller-supplied turns/unknown root data as trusted. Turns are # a derived view of validated records and are rebuilt server-side. return { "schema_version": SHARE_SCHEMA_VERSION, "session": session, "turns": _build_turns(records), "records": records, } def validate_share_format(value: Any) -> str: if not isinstance(value, str) or value not in SHARE_FORMATS: raise ShareValidationError("format must be one of: html, json, txt, yaml, toml") return value def _render_html(snapshot: dict[str, Any]) -> str: session = snapshot["session"] assistant_name = html.escape(str(session.get("assistant_name") or "AI Assistant")) page_title = html.escape(str(session.get("page_title") or "Shared conversation")) page_url = str(session.get("page_url") or "") source = "" if page_url: escaped_url = html.escape(page_url, quote=True) source = f'
Source: {escaped_url}
' messages: list[str] = [] for row in snapshot["records"]: role = row["role"] label = ( "You" if role == "user" else ("Error" if role == "error" else assistant_name) ) text = html.escape(str(row.get("text") or "")) cls = ( "user" if role == "user" else ("error" if role == "error" else "assistant") ) meta_parts: list[str] = [] if row.get("model_name"): meta_parts.append(html.escape(str(row["model_name"]))) if row.get("model_provider"): meta_parts.append(html.escape(str(row["model_provider"]))) meta = f'' if meta_parts else "" messages.append( f'{text}{meta}" + page_title + "
" + source + "Loading shared conversation…