]> git.99rst.org Git - sgasser-llm-shield.git/commitdiff
Fix web security issues in dashboard, detector, and CORS (#146)
authorStefan Gasser <redacted>
Thu, 23 Jul 2026 16:47:21 +0000 (18:47 +0200)
committerGitHub <redacted>
Thu, 23 Jul 2026 16:47:21 +0000 (18:47 +0200)
- Escape the client-supplied model string before rendering it into the
  dashboard logs table, preventing stored XSS in the dashboard origin.
- Bound the variable-name run in the ENV_PASSWORD and ENV_SECRET regexes
  to {0,128} to remove quadratic backtracking (ReDoS) on long inputs.
- Exclude the same-origin dashboard routes from the wildcard CORS policy
  so its unauthenticated JSON APIs are no longer readable cross-origin,
  while keeping permissive CORS for the proxy and mask APIs.

src/index.ts
src/secrets/patterns/env-vars.ts
src/views/dashboard/page.tsx

index a2864e8c7f1bd6b07e1e467e4e4305125918dd7a..4e24bf938291542b3753186327ac9e0d421331ca 100644 (file)
@@ -31,7 +31,18 @@ const requestIdMiddleware = createMiddleware<{ Variables: Variables }>(async (c,
 
 // Middleware
 app.use("*", requestIdMiddleware);
-app.use("*", cors());
+// Permissive CORS is applied to the proxy and mask APIs so browser-based clients
+// can call them, but NOT to the dashboard. The dashboard UI and its JSON APIs
+// (/dashboard, /dashboard/api/*) are served same-origin and may be
+// unauthenticated; a wildcard Access-Control-Allow-Origin there would let any
+// website the operator visits read logged request data cross-origin. Same-origin
+// dashboard use needs no CORS headers, so excluding it changes no legitimate flow.
+const corsMiddleware = cors();
+app.use("*", (c, next) =>
+  c.req.path === "/dashboard" || c.req.path.startsWith("/dashboard/")
+    ? next()
+    : corsMiddleware(c, next),
+);
 app.use("*", logger());
 
 // Favicon
index fa1a7f4a365f6030db0706bece49163250de59ef..78f21a8a45d6cb68bc7627eb4a4805a7a4bcebdf 100644 (file)
@@ -18,16 +18,21 @@ export const envVarsDetector: PatternDetector = {
 
     // Environment variable password patterns: _PASSWORD or _PWD suffix with value (8+ chars)
     // Case-insensitive for variable name, supports = and : assignment, quoted/unquoted values
+    // The variable-name run is length-bounded ({0,128}) so a long, non-matching
+    // run of word characters cannot force quadratic backtracking (ReDoS); real
+    // env var names are far shorter than this bound.
     if (enabledTypes.has("ENV_PASSWORD")) {
       const passwordPattern =
-        /[A-Za-z_][A-Za-z0-9_]*(?:PASSWORD|_PWD)\s*[=:]\s*['"]?[^\s'"]{8,}['"]?/gi;
+        /[A-Za-z_][A-Za-z0-9_]{0,128}(?:PASSWORD|_PWD)\s*[=:]\s*['"]?[^\s'"]{8,}['"]?/gi;
       detectPattern(text, passwordPattern, "ENV_PASSWORD", matches, locations);
     }
 
     // Environment variable secret patterns: _SECRET suffix with value (8+ chars)
     // Case-insensitive for variable name, supports = and : assignment, quoted/unquoted values
+    // The variable-name run is length-bounded ({0,128}) to prevent quadratic
+    // backtracking (ReDoS) on long non-matching word-character runs.
     if (enabledTypes.has("ENV_SECRET")) {
-      const secretPattern = /[A-Za-z_][A-Za-z0-9_]*_SECRET\s*[=:]\s*['"]?[^\s'"]{8,}['"]?/gi;
+      const secretPattern = /[A-Za-z_][A-Za-z0-9_]{0,128}_SECRET\s*[=:]\s*['"]?[^\s'"]{8,}['"]?/gi;
       detectPattern(text, secretPattern, "ENV_SECRET", matches, locations);
     }
 
index 90eb5b369419e3114b4bdeab059f021285b07656..36714fad841e5bded8108ad020cefeea052e8641 100644 (file)
@@ -554,6 +554,15 @@ function renderEntityList(entities) {
   ).join('') + '</div>';
 }
 
+function escapeHtml(value) {
+  return String(value == null ? '' : value)
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&#39;');
+}
+
 function formatSourceLabel(source) {
   return source === 'browser_extension' ? 'Browser Extension' : source.toUpperCase();
 }
@@ -602,7 +611,7 @@ async function fetchLogs() {
           '</td>' +
           '<td class="text-sm px-4 py-3 border-b border-border-subtle align-middle">' + sourceBadge + '</td>' +
           '<td class="text-sm px-4 py-3 border-b border-border-subtle align-middle">' + statusBadge + '</td>' +
-          '<td class="font-mono text-[0.7rem] text-text-secondary px-4 py-3 border-b border-border-subtle align-middle">' + log.model + '</td>' +
+          '<td class="font-mono text-[0.7rem] text-text-secondary px-4 py-3 border-b border-border-subtle align-middle">' + escapeHtml(log.model) + '</td>' +
           '<td class="text-sm px-4 py-3 border-b border-border-subtle align-middle">' +
             (entities.length > 0
               ? '<div class="flex flex-wrap gap-1">' + entities.map(e => '<span class="font-mono text-[0.55rem] px-1.5 py-0.5 bg-elevated border border-border rounded-sm text-text-secondary">' + e.trim() + '</span>').join('') + '</div>'
git clone https://git.99rst.org/PROJECT