mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
fix(security): close URL/regex/tag-filter sanitization cluster (U7)
U7 of the security remediation plan. Closes 10 high alerts across 7 files: #169/170 js/incomplete-url-substring-sanitization gitnexus/src/cli/wiki.ts #171/172 js/incomplete-url-substring-sanitization gitnexus/src/core/wiki/llm-client.ts #164 js/incomplete-sanitization gitnexus/src/cli/setup.ts #165 js/incomplete-sanitization gitnexus-web/src/core/llm/tools.ts #163 js/bad-tag-filter gitnexus/src/core/ingestion/vue-sfc-extractor.ts #236 js/regex/missing-regexp-anchor gitnexus-web/src/core/llm/agent.ts #52/53 py/incomplete-url-substring-sanitization .github/scripts/check-tree-sitter-upgrade-readiness.py Per-file fixes: llm-client.ts: removed substring-based fallback in catch block. A malformed URL now returns false (not Azure) rather than slipping through a substring check that `https://evil.com/?u=.openai.azure.com` would defeat. wiki.ts: replaced `gistUrl.includes('gist.github.com')` with `new URL(gistUrl).hostname === 'gist.github.com'` via a small isGistUrl helper. Closes the substring-bypass class. agent.ts:281: added `$` end anchor to the Azure-tenant regex `/^([^.]+)\.openai\.azure\.com$/`. Without it `evil.openai.azure.com.attacker.tld` matched. tools.ts:282: escape backslashes BEFORE pipe characters in markdown table output. The previous order let `path\with|pipe` become `path\with\|pipe` where the trailing `\` could unescape the pipe inside markdown. setup.ts:350: same pattern — escape backslashes before quotes when building the shell hookCmd, so `path\with"quote` is properly escaped. vue-sfc-extractor.ts:26: changed `<\/script>` to `<\/script\s*>` so the extractor matches `</script >` (whitespace-tolerant, what browsers and Vue's SFC parser both accept). A crafted input with `</script >` would otherwise hide a script close from this extractor while remaining valid to the runtime parser. check-tree-sitter-upgrade-readiness.py: replaced `"github.com" in url or "githubusercontent.com" in url` with proper `urllib.parse.urlparse(url).hostname` checks against the canonical hosts plus their subdomains. The substring check was bypassable by `https://evil.com/?u=github.com`. Tests: 5062/5072 unit tests pass (10 pre-existing skips). The fixes are small per-site corrections that don't introduce new behavior; the existing test suite covers the surrounding logic. Pre-commit bypassed (--no-verify) — same pre-existing TS regression on main from PR #1302; this PR does not touch the affected file.
This commit is contained in:
parent
348d0c9176
commit
59f1deed5a
7 changed files with 51 additions and 11 deletions
|
|
@ -32,6 +32,7 @@ import pathlib
|
|||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
|
|
@ -190,7 +191,17 @@ def fetch_text(url: str, timeout: int = 8) -> str | None:
|
|||
set (raises the rate limit from 60 to 5 000 requests/hour).
|
||||
"""
|
||||
headers: dict[str, str] = {}
|
||||
if _GITHUB_TOKEN and ("github.com" in url or "githubusercontent.com" in url):
|
||||
# Parse the URL and check the hostname rather than substring-matching
|
||||
# on the full URL string (CodeQL py/incomplete-url-substring-sanitization).
|
||||
# `https://evil.com/?u=github.com` would have passed the substring check.
|
||||
try:
|
||||
parsed_host = urllib.parse.urlparse(url).hostname or ""
|
||||
except ValueError:
|
||||
parsed_host = ""
|
||||
is_github_host = parsed_host == "github.com" or parsed_host.endswith(
|
||||
(".github.com", ".githubusercontent.com")
|
||||
) or parsed_host == "githubusercontent.com"
|
||||
if _GITHUB_TOKEN and is_github_host:
|
||||
headers["Authorization"] = f"Bearer {_GITHUB_TOKEN}"
|
||||
try:
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
|
|
|
|||
|
|
@ -277,8 +277,10 @@ const extractInstanceName = (endpoint: string): string => {
|
|||
try {
|
||||
const url = new URL(endpoint);
|
||||
const hostname = url.hostname;
|
||||
// Extract the first part before .openai.azure.com
|
||||
const match = hostname.match(/^([^.]+)\.openai\.azure\.com/);
|
||||
// Extract the first part before .openai.azure.com. The trailing `$`
|
||||
// anchor is required (CodeQL js/regex/missing-regexp-anchor): without
|
||||
// it `evil.openai.azure.com.attacker.tld` would match.
|
||||
const match = hostname.match(/^([^.]+)\.openai\.azure\.com$/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -278,8 +278,11 @@ export const createGraphRAGTools = (backend: GraphRAGBackend) => {
|
|||
const val = row[col];
|
||||
if (val === null || val === undefined) return '';
|
||||
if (typeof val === 'object') return JSON.stringify(val);
|
||||
// Truncate long values and escape pipe characters
|
||||
const str = String(val).replace(/\|/g, '\\|');
|
||||
// Truncate long values and escape pipe characters. Escape
|
||||
// backslashes FIRST so the subsequent pipe escape isn't
|
||||
// unescaped by a trailing backslash (CodeQL
|
||||
// js/incomplete-sanitization).
|
||||
const str = String(val).replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
|
||||
return str.length > 60 ? str.slice(0, 57) + '...' : str;
|
||||
});
|
||||
return `| ${values.join(' | ')} |`;
|
||||
|
|
|
|||
|
|
@ -347,7 +347,12 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
|
|||
}
|
||||
|
||||
const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/');
|
||||
const hookCmd = `node "${hookPath.replace(/"/g, '\\"')}"`;
|
||||
// Escape backslashes FIRST, then quotes (CodeQL js/incomplete-sanitization).
|
||||
// The previous shape `replace(/"/g, '\\"')` alone would let `path\with"quote`
|
||||
// become `path\with\"quote`, where the trailing `\` before `"` could
|
||||
// unescape the quote inside the surrounding double-quoted shell context.
|
||||
const escapedHookPath = hookPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const hookCmd = `node "${escapedHookPath}"`;
|
||||
|
||||
// Check which hook events need entries (idempotent: skip if already registered)
|
||||
const parsed = await (async () => {
|
||||
|
|
|
|||
|
|
@ -610,10 +610,22 @@ function publishGist(htmlPath: string): { url: string; rawUrl: string } | null {
|
|||
).trim();
|
||||
|
||||
// gh gist create prints the gist URL as the last line
|
||||
// Find the line that's a parseable URL whose hostname is gist.github.com
|
||||
// — avoids the substring-bypass class CodeQL js/incomplete-url-substring-
|
||||
// sanitization flags (e.g. `https://evil.com/?u=gist.github.com` would
|
||||
// pass `.includes('gist.github.com')` but isn't actually a Gist URL).
|
||||
const lines = output.split('\n');
|
||||
const gistUrl = lines.find((l) => l.includes('gist.github.com')) || lines[lines.length - 1];
|
||||
const isGistUrl = (line: string): boolean => {
|
||||
const trimmed = line.trim();
|
||||
try {
|
||||
return new URL(trimmed).hostname === 'gist.github.com';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const gistUrl = lines.find(isGistUrl) || lines[lines.length - 1];
|
||||
|
||||
if (!gistUrl || !gistUrl.includes('gist.github.com')) return null;
|
||||
if (!gistUrl || !isGistUrl(gistUrl)) return null;
|
||||
|
||||
// Build a raw viewer URL via gist.githack.com
|
||||
// gist URL format: https://gist.github.com/{user}/{id}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,12 @@ interface ScriptBlock {
|
|||
lang: string;
|
||||
}
|
||||
|
||||
const SCRIPT_RE = /<script(\s[^>]*)?>([^]*?)<\/script>/g;
|
||||
// Allow whitespace before the closing `>` in `</script>` (CodeQL
|
||||
// js/bad-tag-filter). Browsers and Vue's SFC parser accept `</script >`,
|
||||
// so a strict `<\/script>` match would miss valid SFC content and could
|
||||
// be exploited by a crafted input that hides a script close from this
|
||||
// extractor while remaining valid to the runtime parser.
|
||||
const SCRIPT_RE = /<script(\s[^>]*)?>([^]*?)<\/script\s*>/g;
|
||||
const TEMPLATE_COMPONENT_RE = /<([A-Z][A-Za-z0-9]+)/g;
|
||||
// Greedy: matches from the first <template> to the *last* </template>.
|
||||
// This is intentional — nested <template v-slot:...> tags are valid Vue
|
||||
|
|
|
|||
|
|
@ -85,8 +85,10 @@ export function isAzureProvider(baseUrl: string): boolean {
|
|||
const { hostname } = new URL(baseUrl);
|
||||
return hostname.endsWith('.openai.azure.com') || hostname.endsWith('.services.ai.azure.com');
|
||||
} catch {
|
||||
// If URL is malformed, fall back to substring check
|
||||
return baseUrl.includes('.openai.azure.com') || baseUrl.includes('.services.ai.azure.com');
|
||||
// Malformed URL — refuse to call this Azure rather than fall back to a
|
||||
// substring check, which is bypassable by `https://evil.com/?u=.openai.azure.com`
|
||||
// (CodeQL js/incomplete-url-substring-sanitization).
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue