""" 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'
{" · ".join(meta_parts)}
' if meta_parts else "" messages.append( f'
{label}
' f"
{text}
{meta}
" ) # No scripts and no remote resources. CSP on the HTTP response is the # primary policy; this meta tag protects downloaded/copied representations. return ( """ Shared AI conversation

""" + assistant_name + " — Shared conversation

" + page_title + "

" + source + "
" + "".join(messages) + "
" ) def _render_text(snapshot: dict[str, Any]) -> str: lines: list[str] = [] session = snapshot["session"] lines.append( f"{session.get('assistant_name') or 'AI Assistant'} — Shared conversation" ) if session.get("page_title"): lines.append(str(session["page_title"])) if session.get("page_url"): lines.append(f"Source: {session['page_url']}") lines.append("") for row in snapshot["records"]: role = row["role"] label = ( "USER" if role == "user" else ("ERROR" if role == "error" else "ASSISTANT") ) lines.extend([f"[{label}]", str(row.get("text") or ""), ""]) return "\n".join(lines).rstrip() + "\n" def _yaml_scalar(value: Any) -> str: if value is None: return "null" if isinstance(value, bool): return "true" if value else "false" if isinstance(value, (int, float)) and not isinstance(value, bool): if isinstance(value, float) and not math.isfinite(value): return "null" return str(value) return json.dumps(str(value), ensure_ascii=False) def _yaml_value(value: Any, indent: int = 0) -> str: pad = " " * indent if isinstance(value, list): if not value: return pad + "[]" rows: list[str] = [] for item in value: if isinstance(item, (dict, list)): child = _yaml_value(item, indent + 2).splitlines() rows.append(pad + "- " + child[0][indent + 2 :]) rows.extend(child[1:]) else: rows.append(pad + "- " + _yaml_scalar(item)) return "\n".join(rows) if isinstance(value, dict): if not value: return pad + "{}" rows = [] for key, item in value.items(): qkey = json.dumps(str(key), ensure_ascii=False) if isinstance(item, (dict, list)): rows.append(f"{pad}{qkey}:\n{_yaml_value(item, indent + 2)}") else: rows.append(f"{pad}{qkey}: {_yaml_scalar(item)}") return "\n".join(rows) return pad + _yaml_scalar(value) def _render_yaml(snapshot: dict[str, Any]) -> str: return _yaml_value(snapshot) + "\n" def _toml_scalar(value: Any) -> str | None: if isinstance(value, str): return json.dumps(value, ensure_ascii=False) if isinstance(value, bool): return "true" if value else "false" if isinstance(value, (int, float)) and not isinstance(value, bool): if isinstance(value, float) and not math.isfinite(value): return None return str(value) return None def _toml_fields(lines: list[str], obj: dict[str, Any]) -> None: for key, value in obj.items(): if value is None: continue rendered = _toml_scalar(value) if rendered is not None: lines.append(f"{key} = {rendered}") def _render_toml(snapshot: dict[str, Any]) -> str: lines = [ "# AI Assistant conversation export", "# schema v2 semantics: omitted optional values represent null", f"schema_version = {json.dumps(str(snapshot.get('schema_version') or '2.0'), ensure_ascii=False)}", "", "[session]", ] _toml_fields(lines, snapshot.get("session") or {}) for turn in snapshot.get("turns") or []: lines.extend(["", "[[turns]]"]) if turn.get("turn_index") is not None: lines.append(f"turn_index = {turn['turn_index']}") if isinstance(turn.get("user"), dict): lines.append("[turns.user]") _toml_fields(lines, turn["user"]) if isinstance(turn.get("assistant"), dict): lines.append("[turns.assistant]") _toml_fields(lines, turn["assistant"]) for record in snapshot.get("records") or []: lines.extend(["", "[[records]]"]) _toml_fields(lines, record) return "\n".join(lines) + "\n" def render_share(snapshot: dict[str, Any], fmt: str) -> tuple[str, str, str]: """Render a validated snapshot using a server-owned representation.""" fmt = validate_share_format(fmt) mime, ext = SHARE_FORMATS[fmt] if fmt == "html": content = _render_html(snapshot) elif fmt == "json": content = json.dumps(snapshot, ensure_ascii=False, indent=2) + "\n" elif fmt == "yaml": content = _render_yaml(snapshot) elif fmt == "toml": content = _render_toml(snapshot) else: content = _render_text(snapshot) return content, mime, ext def render_share_viewer_shell(read_path: str = "/v1/share/read") -> str: """ Return the fixed-path public Share viewer. The public read capability remains in ``location.hash`` and is sent to the fixed read endpoint only in a JSON request body. The shell renders all conversation values with DOM ``textContent`` and never injects untrusted HTML. """ read_path_json = json.dumps(str(read_path), ensure_ascii=False) template = r"""Shared AI conversation

Loading shared conversation…

""" return template.replace("__READ_PATH__", read_path_json) def generate_edit_token() -> str: return secrets.token_urlsafe(32) def hash_edit_token(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() def verify_edit_token(token: str, expected_hash: str) -> bool: if not token or not expected_hash: return False candidate = hash_edit_token(token) return hmac.compare_digest(candidate, expected_hash) def valid_share_id(value: str) -> bool: return bool(_SHARE_ID_RE.fullmatch(value or ""))