mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
164 lines
7.1 KiB
Python
164 lines
7.1 KiB
Python
"""HTML data-injection + relative-URL token rewriting for served outputs.
|
|
|
|
The token rewrite (`inject_token_into_relative_urls`) is a security boundary:
|
|
iframe sub-resource fetches drop the parent's ?token= query, so the serve
|
|
routes re-stamp it onto every relative href/src or they 401. Keep it wired to
|
|
the serve routes."""
|
|
|
|
import base64
|
|
import json
|
|
import logging
|
|
import re
|
|
|
|
from jsonschema import validate as schema_validate, ValidationError as SchemaValidationError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
def get_anthropic_client(api_model: str | None = None):
|
|
"""Create an AsyncAnthropic client using the API key from app settings.
|
|
|
|
When `api_model` is provided and carries a 9Router prefix (cc/, cx/, gc/),
|
|
the client is pointed at 9Router so non-Anthropic aux calls don't 400 on
|
|
api.anthropic.com. Without an api_model we fall back to the default
|
|
connection-mode-driven client.
|
|
"""
|
|
from backend.apps.settings.credentials import (
|
|
get_anthropic_client,
|
|
get_anthropic_client_for_model,
|
|
)
|
|
from backend.apps.settings.settings import load_settings
|
|
settings = load_settings()
|
|
if api_model:
|
|
return get_anthropic_client_for_model(settings, api_model)
|
|
return get_anthropic_client(settings)
|
|
|
|
|
|
def validate_against_schema(data: dict, schema: dict) -> str | None:
|
|
"""Validate *data* against *schema*. Return an error string or None."""
|
|
try:
|
|
schema_validate(instance=data, schema=schema)
|
|
return None
|
|
except SchemaValidationError as exc:
|
|
path = " -> ".join(str(p) for p in exc.absolute_path) if exc.absolute_path else "(root)"
|
|
return f"Schema validation failed at {path}: {exc.message}"
|
|
|
|
|
|
def p_runtime_helpers_js() -> str:
|
|
"""OUTPUT_COMPUTE / OUTPUT_LLM only run for real on the published edge, where they
|
|
are same-origin and carry NO credentials. In the App Builder preview we
|
|
deliberately do NOT wire them to the authenticated backend: doing so would embed
|
|
this install's token into the app's own JS (the exact exposure SECURITY.md item A
|
|
is about). Preview defines readable stubs instead, the app degrades with a clear
|
|
message rather than crashing or leaking a credential."""
|
|
return (
|
|
" window.OUTPUT_COMPUTE = async function () { throw new Error('OUTPUT_COMPUTE runs once this app is published.'); };\n"
|
|
" window.OUTPUT_LLM = async function () { throw new Error('OUTPUT_LLM runs once this app is published.'); };\n"
|
|
)
|
|
|
|
|
|
def build_data_injection(input_json: str, result_json: str, backend_url_json: str = "null", with_runtime: bool = False) -> str:
|
|
"""Build a <script> tag that sets OUTPUT_INPUT / OUTPUT_BACKEND_RESULT /
|
|
OUTPUT_BACKEND_URL, optionally wires OUTPUT_COMPUTE / OUTPUT_LLM, and listens
|
|
for postMessage updates.
|
|
|
|
OUTPUT_BACKEND_URL is `null` when the app has no live `backend.py`
|
|
process; otherwise it's `http://localhost:<port>` and app code can
|
|
`fetch(window.OUTPUT_BACKEND_URL + '/route')` to hit the persistent
|
|
backend's endpoints."""
|
|
helpers = p_runtime_helpers_js() if with_runtime else ""
|
|
return (
|
|
"<script>\n"
|
|
"(function() {\n"
|
|
" window.OUTPUT_INPUT = " + input_json + ";\n"
|
|
" window.OUTPUT_BACKEND_RESULT = " + result_json + ";\n"
|
|
" window.OUTPUT_BACKEND_URL = " + backend_url_json + ";\n"
|
|
+ helpers +
|
|
" window.addEventListener('message', function(e) {\n"
|
|
" if (e.data && e.data.type === 'OUTPUT_DATA') {\n"
|
|
" window.OUTPUT_INPUT = e.data.input || {};\n"
|
|
" window.OUTPUT_BACKEND_RESULT = e.data.backendResult || null;\n"
|
|
" if (e.data.backendUrl !== undefined) window.OUTPUT_BACKEND_URL = e.data.backendUrl;\n"
|
|
" window.dispatchEvent(new CustomEvent('output-data-ready'));\n"
|
|
" }\n"
|
|
" });\n"
|
|
"})();\n"
|
|
"</script>"
|
|
)
|
|
|
|
|
|
def inject_data_into_html(html: str, input_json: str = "{}", result_json: str = "null", backend_url_json: str = "null", with_runtime: bool = False) -> str:
|
|
injection = build_data_injection(input_json, result_json, backend_url_json, with_runtime)
|
|
if "</head>" in html:
|
|
return html.replace("</head>", f"{injection}\n</head>", 1)
|
|
if "<body" in html:
|
|
return html.replace("<body", f"{injection}\n<body", 1)
|
|
return f"{injection}\n{html}"
|
|
|
|
|
|
def backend_url_for_workspace(workspace_id: str) -> str:
|
|
"""Return the JSON-encoded backend URL for the given workspace, or
|
|
"null" if no runtime is active. Cheap inline lookup so serve_workspace_file
|
|
doesn't have to think about it."""
|
|
try:
|
|
from backend.apps.outputs.runtime import manager as runtime_manager
|
|
rt = runtime_manager.get(workspace_id)
|
|
if rt and rt.running and rt.port:
|
|
return json.dumps(f"http://127.0.0.1:{rt.port}")
|
|
except Exception:
|
|
logger.exception("backend url lookup failed for %s", workspace_id)
|
|
return "null"
|
|
|
|
|
|
# URL schemes / prefixes that must NOT have ?token= appended. These are either external (CDNs, mailto) or non-network references that the auth middleware never sees. Anything else is treated as a same-origin relative URL pointing at our /api/outputs/.../serve/ subtree, which DOES need the token.
|
|
P_ABSOLUTE_URL_PREFIXES = (
|
|
"http://", "https://", "//", "data:", "blob:",
|
|
"mailto:", "tel:", "javascript:", "about:", "#",
|
|
)
|
|
|
|
P_HREF_SRC_ATTR_RE = re.compile(
|
|
r"""(\s(?:href|src))\s*=\s*(["'])([^"']+)\2""",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def inject_token_into_relative_urls(html: str, token: str) -> str:
|
|
"""Append `?token=<t>` to every relative href/src in the served HTML.
|
|
|
|
Browsers strip the parent iframe URL's query string before resolving
|
|
relative `<link href="styles.css">` / `<script src="x.js">`, so without
|
|
this rewrite the sub-resource fetch lands at the auth middleware with no
|
|
credentials and gets a 401. Idempotent: skips URLs that already carry a
|
|
`token=` param. Skips absolute URLs (CDN, data:, etc.); see prefix list.
|
|
"""
|
|
if not token:
|
|
return html
|
|
|
|
def p_patch(match: re.Match) -> str:
|
|
attr, quote, url = match.group(1), match.group(2), match.group(3)
|
|
lowered = url.lower().lstrip()
|
|
if lowered.startswith(P_ABSOLUTE_URL_PREFIXES):
|
|
return match.group(0)
|
|
if "token=" in url:
|
|
return match.group(0)
|
|
# Split off any hash fragment so `?token=` lands in the query, not in the fragment: `page.html?v=1#sec` → `page.html?v=1&token=X#sec`.
|
|
hash_idx = url.find("#")
|
|
if hash_idx >= 0:
|
|
base, frag = url[:hash_idx], url[hash_idx:]
|
|
else:
|
|
base, frag = url, ""
|
|
sep = "&" if "?" in base else "?"
|
|
return f'{attr}={quote}{base}{sep}token={token}{frag}{quote}'
|
|
|
|
return P_HREF_SRC_ATTR_RE.sub(p_patch, html)
|
|
|
|
|
|
def decode_data_param(d: str) -> tuple[str, str]:
|
|
"""Decode the base64-encoded _d query param into (input_json, result_json)."""
|
|
try:
|
|
decoded = json.loads(base64.b64decode(d))
|
|
input_json = json.dumps(decoded.get("i", {}))
|
|
result_json = json.dumps(decoded.get("r", None))
|
|
return input_json, result_json
|
|
except Exception:
|
|
return "{}", "null"
|