diff --git a/.gitignore b/.gitignore index 6f8f1dc..f824768 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,8 @@ dist-ssr *.module.css.d.ts # Packed files -*.tgz \ No newline at end of file +*.tgz + +codeql-results.sarif +codeql-db +codeql-results-dist.sarif diff --git a/codeql-config.yml b/codeql-config.yml new file mode 100644 index 0000000..87c925f --- /dev/null +++ b/codeql-config.yml @@ -0,0 +1,93 @@ +# CodeQL configuration for local scanning of a TypeScript + Vite library. +# Includes application sources in src plus third-party code in node_modules +# while aggressively excluding low-value / very large subtrees to keep the +# database size and analysis time manageable. +# +# Usage with CodeQL CLI: +# codeql database create codeql-db \ +# --language=javascript \ +# --source-root=. \ +# --codescanning-config=codeql-config.yml \ +# --command="npm run build" +# +# (If you only need type info and no emitted JS, you can use --command="true" +# because Vite + TS compilation output will already exist in dist if built.) +# +# If analysis becomes too slow, first remove node_modules entirely and only +# re-enable a curated subset (e.g. crypto libs) by copying them to a vendor/ dir. + +name: "Chat Components local scan (src + prod node_modules)" + +paths: + - src + - node_modules + +# Exclusions are deliberately verbose; prune or uncomment as needed. +paths-ignore: + # Build outputs / bundles + - dist/** + - node_modules/**/dist/** + - node_modules/**/build/** + - node_modules/**/lib/** # Many packages ship transpiled output here + - node_modules/**/coverage/** + - node_modules/**/.cache/** + + # Tests, fixtures, examples + - node_modules/**/test/** + - node_modules/**/tests/** + - node_modules/**/__tests__/** + - node_modules/**/testing/** + - node_modules/**/benchmark/** + - node_modules/**/bench/** + - node_modules/**/example/** + - node_modules/**/examples/** + - node_modules/**/fixtures/** + - node_modules/**/mocks/** + - node_modules/**/spec/** + - node_modules/**/__mocks__/** + + # Documentation & metadata + - node_modules/**/docs/** + - node_modules/**/doc/** + - node_modules/**/documentation/** + - node_modules/**/.github/** + - node_modules/**/scripts/** # Build / maintenance scripts seldom security-relevant + + # Front-end demo tooling in dependencies + - node_modules/**/cypress/** + - node_modules/**/storybook/** + - node_modules/**/playwright/** + - node_modules/**/wdio/** + - node_modules/**/selenium/** + + # IDE/project artifacts + - node_modules/**/.vscode/** + - node_modules/**/.idea/** + + # Declaration files (optional exclusion — they are not executable code) + - node_modules/**/*.d.ts + + # Large, frequently safe-to-ignore frameworks (uncomment if size is too big): + - node_modules/react/** + - node_modules/react-dom/** + - node_modules/typescript/** # Compiler sources rarely help app vuln discovery + - node_modules/@types/** # Pure type declarations + - node_modules/babel-*/** # Tooling + - node_modules/eslint/** # Tooling + - node_modules/@eslint/** # Tooling +# No queries specified; choose suites explicitly at analyze time +# e.g. javascript-code-scanning.qls, javascript-security-extended.qls, javascript-security-and-quality.qls +# Advanced options (leave commented unless needed): +# packs: +# - codeql/javascript-experimental@@latest +# query-filters: +# - exclude: +# id: js/useless-equality-check +# +# For very large dependency trees consider a two-tier approach: +# 1. Daily / PR scans with only `src` +# 2. Weekly deep scan enabling node_modules (this config) +# +# To trim size further, run a pre-step to remove redundant folders: +# find node_modules -type d -name dist -prune -exec rm -rf {} + +# find node_modules -type d -name coverage -prune -exec rm -rf {} + diff --git a/package-lock.json b/package-lock.json index 23f16f0..85a0ca0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "prettier": "3.6.2", "react": "^18.3.1", "react-dom": "^18.3.1", + "rimraf": "^6.1.2", "typescript": "^5.9.3", "vite": "^7.1.11", "vite-plugin-css-injected-by-js": "^3.3.0", @@ -10534,6 +10535,87 @@ "dev": true, "license": "MIT" }, + "node_modules/rimraf": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", + "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.0", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "path-scurry": "^2.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rollup": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", diff --git a/package.json b/package.json index 2159b6c..68977a1 100644 --- a/package.json +++ b/package.json @@ -15,13 +15,15 @@ ], "scripts": { "dev": "vite --host", - "build": "tsc && vite build", + "build": "tsc && vite build && node scripts/postbuild-secure-patch.mjs", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives", "preview": "vite preview", "prepack": "npm run build", "test": "vitest run", "test:web-ui": "vitest --ui", - "test:watch": "vitest" + "test:watch": "vitest", + "codeql:scan": "rimraf node_modules && npm ci --omit=dev && codeql database create --overwrite codeql-db --language=typescript-javascript --source-root=. --codescanning-config=codeql-config.yml && codeql database analyze codeql-db codeql/javascript-queries --format=sarifv2.1.0 --output=codeql-results.sarif --threads=0", + "codeql:scan:dist": "npm ci && npm run build && rimraf node_modules && codeql database create --overwrite codeql-db --language=javascript --source-root=dist && codeql database analyze codeql-db codeql/javascript-queries --format=sarifv2.1.0 --output=codeql-results-dist.sarif --threads=0" }, "devDependencies": { "@cognigy/socket-client": "5.0.0-beta.20", @@ -42,6 +44,7 @@ "prettier": "3.6.2", "react": "^18.3.1", "react-dom": "^18.3.1", + "rimraf": "^6.1.2", "typescript": "^5.9.3", "vite": "^7.1.11", "vite-plugin-css-injected-by-js": "^3.3.0", diff --git a/scripts/postbuild-secure-patch.mjs b/scripts/postbuild-secure-patch.mjs new file mode 100644 index 0000000..c29e1cf --- /dev/null +++ b/scripts/postbuild-secure-patch.mjs @@ -0,0 +1,192 @@ +/* eslint-env node */ +/** + * Post-build secure patch script. + * + * Goal: + * Apply targeted string replacements to the compiled bundle(s) in the `dist` directory + * to harden against CodeQL issues we selected. + * + * This script is intentionally conservative: it only performs textual rewrites that + * are: + * - Idempotent (safe to run multiple times) + * - Narrowly scoped to patterns indicated by the scan results + * - Logged, with a summary of applied changes + * + * Targeted patches applied: + * + * 1. js/incomplete-sanitization: + * - Promote single-occurrence < / > replacements to global form + * - Promote single-occurrence backslash replacements to global form + * - Ensure class selector utility additionally globally escapes backslashes + * + * 2. js/overly-large-range: + * - Replace suspicious character class `[!#$&-;=?-Z_a-z~]` with explicit enumeration `[!#$&'()*+,\\-./0-9:;=?@A-Z_a-z~]` + * + * 3. js/incomplete-hostname-regexp: + * - Escape literal dots in YouTube URL regex (`(?:www.)?youtube.com` => `(?:www\\.)?youtube\\.com`) + * + * NOTE: If future CodeQL results add more patterns, extend PATCH_RULES below. + */ + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import console from "node:console"; + +const DIST_DIR = path.resolve(process.cwd(), "dist"); + +async function listDistFiles() { + let entries; + try { + entries = await fs.readdir(DIST_DIR, { withFileTypes: true }); + } catch (e) { + console.error(`[secure-patch] Unable to read dist directory: ${DIST_DIR}`, e); + process.exitCode = 1; + return []; + } + return entries + .filter(d => d.isFile() && /\.(m?js|cjs|jsbundle)$/.test(d.name)) + .map(d => path.join(DIST_DIR, d.name)); +} + +const PATCH_RULES = [ + { + id: "sanitize-angle-brackets", + description: "Promote single-occurrence < or > replacements to global form", + apply: content => ({ + updated: content + .replace(/\.replace\('<','<'\)/g, '.replace(/','>'\)/g, '.replace(/>/g,">")') + .replace(/\.replace\(">", ">"\)/g, '.replace(/>/g,">")'), + }), + }, + { + id: "sanitize-backslash-single", + description: "Promote single-occurrence backslash replacement to global form", + apply: content => ({ + updated: content.replace( + /\.replace\(['"]\\\\['"],\s*(['"])([^'"]+?)\1\)/g, + (_match, quote, inner) => `.replace(/\\\\/g,${quote}${inner}${quote})`, + ), + }), + }, + { + id: "classes-to-selector-backslash-escape", + description: "Ensure classes-to-selector escapes backslashes globally", + apply: content => { + const selectorPattern = + /\.trim\(\)\.replace\(\(\/\(\[\\.:!\+\/()\[\]\]\)\/g,'\\\$1'\)\.replace\(\/ \/g,'\.'\)/; + if ( + selectorPattern.test(content) && + !/\.replace\(\/\\\\\/g,'\\\\\\\\'\)/.test(content) + ) { + return { + updated: content.replace( + selectorPattern, + m => m + ".replace(/\\\\/g,'\\\\\\\\')", + ), + }; + } + return { updated: content }; + }, + }, + { + id: "micromark-overly-large-range", + description: "Replace overly large character class range with explicit enumeration", + apply: content => ({ + updated: content.replace( + /\[!#\$&-;=\?-Z_a-z~\]/g, + () => "[!#$&'()*+,\\-./0-9:;=?@A-Z_a-z~]", + ), + }), + }, + { + id: "youtube-host-regex", + description: "Escape dot before youtube.com inside URL regex patterns", + apply: content => ({ + updated: content.replace(/\(\?:www\.\)\?youtube\.com/g, "(?:www\\.)?youtube\\.com"), + }), + }, +]; + +async function applyPatchesToFile(filePath) { + let original; + try { + original = await fs.readFile(filePath, "utf8"); + } catch (e) { + console.error(`[secure-patch] Failed to read ${filePath}`, e); + return { filePath, changed: false, applied: [], error: e }; + } + let content = original; + const applied = []; + for (const rule of PATCH_RULES) { + const before = content; + const { updated } = rule.apply(content); + if (updated !== before) { + content = updated; + applied.push(rule.id); + } + } + if (applied.length) { + try { + await fs.writeFile(filePath, content, "utf8"); + } catch (e) { + console.error(`[secure-patch] Failed to write ${filePath}`, e); + return { filePath, changed: false, applied: [], error: e }; + } + return { filePath, changed: true, applied }; + } + return { filePath, changed: false, applied: [] }; +} + +async function main() { + console.log("[secure-patch] Starting post-build security patching..."); + const files = await listDistFiles(); + if (!files.length) { + console.warn("[secure-patch] No distributable JS files found; nothing to patch."); + return; + } + const results = []; + for (const f of files) { + const res = await applyPatchesToFile(f); + results.push(res); + } + + const summary = { + totalFiles: files.length, + changedFiles: results.filter(r => r.changed).length, + unchangedFiles: results.filter(r => !r.changed).length, + ruleUsage: {}, + }; + for (const r of results) { + for (const id of r.applied) { + summary.ruleUsage[id] = (summary.ruleUsage[id] || 0) + 1; + } + } + + console.log("[secure-patch] Patch summary:"); + console.log(JSON.stringify(summary, null, 2)); + + for (const r of results) { + if (r.changed) { + console.log(` [+] ${r.filePath} => applied: ${r.applied.join(", ")}`); + } else { + console.log(` [=] ${r.filePath} (no changes)`); + } + } + + // Non-zero exit code only on IO errors. + if (results.some(r => r.error)) { + process.exitCode = 1; + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch(err => { + console.error("[secure-patch] Uncaught error:", err); + process.exitCode = 1; + }); +} + +export {}; // Ensure this remains an ES module. diff --git a/src/sanitize.ts b/src/sanitize.ts index 6f9e505..be1af20 100644 --- a/src/sanitize.ts +++ b/src/sanitize.ts @@ -262,7 +262,10 @@ export const sanitizeHTMLWithConfig = ( // Some texts from Agentic AI starts with a ", ">"); + if (text?.startsWith("/g, ">"); + } const configToUse = customAllowedHtmlTags ? { ...config, ALLOWED_TAGS: customAllowedHtmlTags } diff --git a/src/utils.ts b/src/utils.ts index 5baf340..09f76c6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -132,12 +132,31 @@ export const isEventMessage = (message: IMessage) => { export const getBackgroundImage = (url: string) => { if (!url) return undefined; - const escapedUrl = url - .replace(/\n/g, "") - .replace(/\r/g, "") - .replace(/"\\/g, char => `\`${char}`); + // Remove control characters that could break CSS parsing + let sanitized = url.replace(/[\r\n\f]/g, ""); + + // If the string looks like an absolute URL (has a scheme), validate allowed protocols (http/https). + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(sanitized)) { + try { + const parsed = new URL(sanitized); + if (!/^https?:$/i.test(parsed.protocol)) { + return undefined; + } + // Normalize absolute URLs + sanitized = parsed.href; + } catch { + // URL constructor failed (invalid absolute URL). Reject. + return undefined; + } + } + + // Escape characters that could terminate or escape the quoted url("...") context. + sanitized = sanitized + .replace(/\\/g, "\\\\") // Escape backslashes first + .replace(/"/g, '\\"') // Escape double quotes + .replace(/\)/g, "\\)"); // Escape closing parenthesis - return `url("${escapedUrl}")`; + return `url("${sanitized}")`; }; export const getRandomId = (prefix = "") => {