fix: address code scanning alerts — URL sanitization, workflow harden… (#298)

* 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
This commit is contained in:
Patrick Buckley
2026-04-04 16:52:46 -07:00
committed by GitHub
parent 2bfc0f2c5d
commit caf449e048
7 changed files with 38 additions and 8 deletions
+3
View File
@@ -7,6 +7,9 @@ on:
pull_request:
branches: [main, "stable/*"]
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
+3 -1
View File
@@ -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
+7 -1
View File
@@ -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)
+3 -1
View File
@@ -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];
+5 -1
View File
@@ -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"
+5 -1
View File
@@ -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"
+12 -3
View File
@@ -26,6 +26,7 @@ function inlineMarkdown(text) {
text = text.replace(/~~(.+?)~~/g, "<del>$1</del>");
// 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) {
"</span>"
);
});
// 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 (
'<a href="' +
url +
@@ -89,8 +90,16 @@ function inlineMarkdown(text) {
document.addEventListener("click", function (e) {
var ph = e.target.closest(".img-placeholder");
if (!ph) return;
var raw = ph.getAttribute("data-src") || "";
if (!/^(https?:\/\/|data:image\/)/i.test(raw)) return;
var src;
try {
src = new URL(raw).href;
} catch (_e) {
return;
}
var img = document.createElement("img");
img.src = ph.getAttribute("data-src");
img.src = src;
img.alt = ph.getAttribute("data-alt");
img.loading = "lazy";
ph.replaceWith(img);