From: Stefan Gasser Date: Thu, 23 Jul 2026 16:47:21 +0000 (+0200) Subject: Fix web security issues in dashboard, detector, and CORS (#146) X-Git-Tag: v0.8.1~2 X-Git-Url: http://git.99rst.org/?a=commitdiff_plain;h=14cf5497f3cbddca61783c822ee19947cc26e3b9;p=sgasser-llm-shield.git Fix web security issues in dashboard, detector, and CORS (#146) - 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. --- diff --git a/src/index.ts b/src/index.ts index a2864e8..4e24bf9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 diff --git a/src/secrets/patterns/env-vars.ts b/src/secrets/patterns/env-vars.ts index fa1a7f4..78f21a8 100644 --- a/src/secrets/patterns/env-vars.ts +++ b/src/secrets/patterns/env-vars.ts @@ -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); } diff --git a/src/views/dashboard/page.tsx b/src/views/dashboard/page.tsx index 90eb5b3..36714fa 100644 --- a/src/views/dashboard/page.tsx +++ b/src/views/dashboard/page.tsx @@ -554,6 +554,15 @@ function renderEntityList(entities) { ).join('') + ''; } +function escapeHtml(value) { + return String(value == null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + function formatSourceLabel(source) { return source === 'browser_extension' ? 'Browser Extension' : source.toUpperCase(); } @@ -602,7 +611,7 @@ async function fetchLogs() { '' + '' + sourceBadge + '' + '' + statusBadge + '' + - '' + log.model + '' + + '' + escapeHtml(log.model) + '' + '' + (entities.length > 0 ? '
' + entities.map(e => '' + e.trim() + '').join('') + '
'