From caf449e04833db0d2ad5fac8a8bd32a9b47b7a2a Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Sat, 4 Apr 2026 16:52:46 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20address=20code=20scanning=20alerts=20?= =?UTF-8?q?=E2=80=94=20URL=20sanitization,=20workflow=20harden=E2=80=A6=20?= =?UTF-8?q?(#298)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: address code scanning alerts — URL sanitization, workflow hardening, XSS - CI workflow: add top-level permissions (contents: read) - Docker publish: gate on head_repository == self to block fork-based pwn - URL checks: replace substring matching with proper hostname parsing (eval.py, model_registry.py, console/server.py) - renderer.js: allowlist URL schemes (http/https) for images and links - app.js: escape backslashes before quotes in CSS selector construction * fix: break CodeQL taint chain — normalize image URL via URL constructor * fix: address review — scheme-less URL handling, protocol-relative rejection, data:image allowlist - Normalize scheme-less base URLs before hostname parsing (eval, model_registry, console/server) so api.openai.com without https:// still matches - Reject protocol-relative URLs (//host) in image and link allowlists - Allow data:image/ URIs for inline MCP resource images - Tighten image source to https:// only (no relative paths) * fix: route data: URIs through URL constructor to break CodeQL taint chain --- .github/workflows/ci.yml | 3 +++ .github/workflows/docker-publish.yml | 4 +++- turnstone/console/server.py | 8 +++++++- turnstone/console/static/app.js | 4 +++- turnstone/core/model_registry.py | 6 +++++- turnstone/eval.py | 6 +++++- turnstone/ui/static/renderer.js | 15 ++++++++++++--- 7 files changed, 38 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef45bc9a..dfa69288 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,9 @@ on: pull_request: branches: [main, "stable/*"] +permissions: + contents: read + jobs: lint: runs-on: ubuntu-latest diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index c2cf2412..3c381c08 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -19,7 +19,9 @@ env: jobs: docker: - if: github.event.workflow_run.conclusion == 'success' + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_repository.full_name == github.repository runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 diff --git a/turnstone/console/server.py b/turnstone/console/server.py index f0e9786b..37b32896 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -5485,8 +5485,14 @@ async def admin_detect_model(request: Request) -> JSONResponse: base_url = row.get("base_url", "") # For commercial endpoints an api_key is required + _normalized = (base_url if "://" in base_url else f"https://{base_url}") if base_url else "" + _hostname = (urllib.parse.urlparse(_normalized).hostname or "") if _normalized else "" if not api_key and ( - not base_url or "api.openai.com" in base_url or "api.anthropic.com" in base_url + not base_url + or _hostname == "api.openai.com" + or _hostname.endswith(".openai.com") + or _hostname == "api.anthropic.com" + or _hostname.endswith(".anthropic.com") ): return JSONResponse({"error": "api_key is required"}, status_code=400) diff --git a/turnstone/console/static/app.js b/turnstone/console/static/app.js index 9ed63c9c..76758485 100644 --- a/turnstone/console/static/app.js +++ b/turnstone/console/static/app.js @@ -720,7 +720,9 @@ function buildNodeRow(node) { function toggleGroup(prefix) { expandedGroups[prefix] = !expandedGroups[prefix]; var body = document.querySelector( - '.node-group-body[data-prefix="' + prefix.replace(/"/g, '\\"') + '"]', + '.node-group-body[data-prefix="' + + prefix.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + + '"]', ); if (!body) return; var isExpanded = expandedGroups[prefix]; diff --git a/turnstone/core/model_registry.py b/turnstone/core/model_registry.py index a113eac3..95a24dd3 100644 --- a/turnstone/core/model_registry.py +++ b/turnstone/core/model_registry.py @@ -546,7 +546,11 @@ def _detect_openai_compat( result["context_window"] = known["context_window"] # Server type heuristics - if base_url and "api.openai.com" in base_url: + from urllib.parse import urlparse + + _normalized = (base_url if "://" in base_url else f"https://{base_url}") if base_url else "" + _hostname = urlparse(_normalized).hostname or "" if _normalized else "" + if base_url and (_hostname == "api.openai.com" or _hostname.endswith(".openai.com")): result["server_type"] = "openai" elif meta is not None and "n_ctx_train" in meta: result["server_type"] = "llama.cpp" diff --git a/turnstone/eval.py b/turnstone/eval.py index fc9bad60..40f6399b 100644 --- a/turnstone/eval.py +++ b/turnstone/eval.py @@ -45,7 +45,11 @@ _MCP_ONLY_TOOLS = frozenset({"read_resource", "use_prompt"}) def _detect_provider(base_url: str) -> str: """Infer provider name from a base URL.""" - if "anthropic.com" in base_url: + from urllib.parse import urlparse + + normalized = base_url if "://" in base_url else f"https://{base_url}" + hostname = urlparse(normalized).hostname or "" + if hostname == "anthropic.com" or hostname.endswith(".anthropic.com"): return "anthropic" return "openai" diff --git a/turnstone/ui/static/renderer.js b/turnstone/ui/static/renderer.js index 8ed07e0e..76de8920 100644 --- a/turnstone/ui/static/renderer.js +++ b/turnstone/ui/static/renderer.js @@ -26,6 +26,7 @@ function inlineMarkdown(text) { text = text.replace(/~~(.+?)~~/g, "$1"); // Images (must come before links — render as click-to-load placeholder) text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (m, alt, url) { + if (!/^\s*(https?:\/\/|data:image\/)/i.test(url)) return m; var safeAlt = alt || "Image"; var domain = ""; try { @@ -53,9 +54,9 @@ function inlineMarkdown(text) { "" ); }); - // Links (block javascript: scheme) + // Links (allow http, https, and same-origin relative URLs only) text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (m, label, url) { - if (/^\s*javascript:/i.test(url)) return m; + if (!/^\s*(https?:\/\/|\/(?!\/))/i.test(url)) return m; return ( '