fix(config): lazy-evaluate paths, fix TOCTOU, segment-boundary cloud detection

- Convert 4 module-level constants to lazy-evaluated functions to avoid
  import-time caching: openrouter-catalog, aggregator, disk-cache, auth-middleware
- Fix symlink-checks.ts to use ccsDir parameter instead of homedir/.ccs,
  remove unused homedir parameter from checkSettingsSymlinks()
- Replace TOCTOU existsSync+statSync with single statSync in try/catch
  for --config-dir validation in ccs.ts
- Switch detectCloudSyncPath from substring to path-segment-boundary matching
  to prevent false positives (e.g., megauser != MEGA, Dropbox-api != Dropbox)
- Add test for false-positive protection
This commit is contained in:
Tam Nhu Tran
2026-02-11 11:24:34 +07:00
parent 60d6bbd027
commit d5abc7d691
9 changed files with 55 additions and 36 deletions
+7 -5
View File
@@ -8,7 +8,9 @@ import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager';
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/models';
const CACHE_FILE = path.join(getCcsDir(), 'openrouter-models-cache.json');
function getCacheFile() {
return path.join(getCcsDir(), 'openrouter-models-cache.json');
}
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
export interface OpenRouterModel {
@@ -30,8 +32,8 @@ interface CacheData {
/** Check if cached data is valid */
function getCachedModels(): OpenRouterModel[] | null {
try {
if (!fs.existsSync(CACHE_FILE)) return null;
const data = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')) as CacheData;
if (!fs.existsSync(getCacheFile())) return null;
const data = JSON.parse(fs.readFileSync(getCacheFile(), 'utf8')) as CacheData;
if (Date.now() - data.fetchedAt > CACHE_TTL_MS) return null;
return data.models;
} catch {
@@ -42,10 +44,10 @@ function getCachedModels(): OpenRouterModel[] | null {
/** Save models to cache */
function setCachedModels(models: OpenRouterModel[]): void {
try {
const dir = path.dirname(CACHE_FILE);
const dir = path.dirname(getCacheFile());
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
CACHE_FILE,
getCacheFile(),
JSON.stringify({
models,
fetchedAt: Date.now(),
+8 -2
View File
@@ -286,8 +286,14 @@ async function main(): Promise<void> {
process.exit(1);
}
if (!fs.existsSync(configDirValue) || !fs.statSync(configDirValue).isDirectory()) {
console.error(fail(`Config directory not found or not a directory: ${configDirValue}`));
try {
const stat = fs.statSync(configDirValue);
if (!stat.isDirectory()) {
console.error(fail(`Not a directory: ${configDirValue}`));
process.exit(1);
}
} catch {
console.error(fail(`Config directory not found: ${configDirValue}`));
console.error(info('Create the directory first, then copy your config files into it.'));
process.exit(1);
}
+4 -3
View File
@@ -73,10 +73,11 @@ const CLOUD_SYNC_PATTERNS = [
*/
export function detectCloudSyncPath(dir: string): string | null {
const normalized = dir.replace(/\\/g, '/').toLowerCase();
const segments = normalized.split('/');
for (const pattern of CLOUD_SYNC_PATTERNS) {
if (normalized.includes(pattern.toLowerCase())) {
return pattern; // Return original casing for display
}
const patternLower = pattern.toLowerCase();
// Exact path segment match to avoid false positives (e.g., "megauser" != "MEGA")
if (segments.some((s) => s === patternLower)) return pattern;
}
return null;
}
+1 -1
View File
@@ -89,7 +89,7 @@ export async function runHealthChecks(): Promise<HealthReport> {
const healthChecks: HealthCheck[] = [];
healthChecks.push(checkPermissions(ccsDir));
healthChecks.push(checkCcsSymlinks());
healthChecks.push(checkSettingsSymlinks(homedir, ccsDir, claudeDir));
healthChecks.push(checkSettingsSymlinks(ccsDir, claudeDir));
groups.push({ id: 'system-health', name: 'System Health', icon: 'Shield', checks: healthChecks });
// Group 6: CLIProxy
+2 -6
View File
@@ -49,13 +49,9 @@ export function checkCcsSymlinks(): HealthCheck {
/**
* Check settings symlinks
*/
export function checkSettingsSymlinks(
homedir: string,
ccsDir: string,
claudeDir: string
): HealthCheck {
export function checkSettingsSymlinks(ccsDir: string, claudeDir: string): HealthCheck {
try {
const sharedDir = `${homedir}/.ccs/shared`;
const sharedDir = `${ccsDir}/shared`;
const sharedSettings = `${sharedDir}/settings.json`;
const claudeSettings = `${claudeDir}/settings.json`;
+7 -5
View File
@@ -24,7 +24,9 @@ declare module 'express-session' {
const PUBLIC_PATHS = ['/api/auth/login', '/api/auth/check', '/api/auth/setup', '/api/health'];
/** Path to persistent session secret file */
const SESSION_SECRET_PATH = path.join(getCcsDir(), '.session-secret');
function getSessionSecretPath() {
return path.join(getCcsDir(), '.session-secret');
}
/**
* Generate or retrieve persistent session secret.
@@ -38,8 +40,8 @@ function getSessionSecret(): string {
// 2. Try to read persisted secret
try {
if (fs.existsSync(SESSION_SECRET_PATH)) {
const secret = fs.readFileSync(SESSION_SECRET_PATH, 'utf-8').trim();
if (fs.existsSync(getSessionSecretPath())) {
const secret = fs.readFileSync(getSessionSecretPath(), 'utf-8').trim();
if (secret.length >= 32) {
return secret;
}
@@ -51,11 +53,11 @@ function getSessionSecret(): string {
// 3. Generate and persist new random secret
const newSecret = crypto.randomBytes(32).toString('hex');
try {
const dir = path.dirname(SESSION_SECRET_PATH);
const dir = path.dirname(getSessionSecretPath());
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(SESSION_SECRET_PATH, newSecret, { mode: 0o600 });
fs.writeFileSync(getSessionSecretPath(), newSecret, { mode: 0o600 });
} catch (err) {
// Log warning - sessions won't persist across restarts
console.warn('[!] Failed to persist session secret:', (err as Error).message);
+6 -4
View File
@@ -31,22 +31,24 @@ import { getCcsDir } from '../../utils/config-manager';
// ============================================================================
/** Path to CCS instances directory */
const CCS_INSTANCES_DIR = path.join(getCcsDir(), 'instances');
function getCcsInstancesDir() {
return path.join(getCcsDir(), 'instances');
}
/**
* Get list of CCS instance paths that have usage data
* Only returns instances with existing projects/ directory
*/
function getInstancePaths(): string[] {
if (!fs.existsSync(CCS_INSTANCES_DIR)) {
if (!fs.existsSync(getCcsInstancesDir())) {
return [];
}
try {
const entries = fs.readdirSync(CCS_INSTANCES_DIR, { withFileTypes: true });
const entries = fs.readdirSync(getCcsInstancesDir(), { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(CCS_INSTANCES_DIR, entry.name))
.map((entry) => path.join(getCcsInstancesDir(), entry.name))
.filter((instancePath) => {
// Only include instances that have a projects directory
const projectsPath = path.join(instancePath, 'projects');
+14 -10
View File
@@ -15,8 +15,12 @@ import { ok, info, warn } from '../../utils/ui';
import { getCcsDir } from '../../utils/config-manager';
// Cache configuration
const CACHE_DIR = path.join(getCcsDir(), 'cache');
const CACHE_FILE = path.join(CACHE_DIR, 'usage.json');
function getCacheDir() {
return path.join(getCcsDir(), 'cache');
}
function getCacheFile() {
return path.join(getCacheDir(), 'usage.json');
}
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const STALE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days (max age for stale data)
@@ -38,8 +42,8 @@ const CACHE_VERSION = 3;
* Ensure ~/.ccs/cache directory exists
*/
function ensureCacheDir(): void {
if (!fs.existsSync(CACHE_DIR)) {
fs.mkdirSync(CACHE_DIR, { recursive: true });
if (!fs.existsSync(getCacheDir())) {
fs.mkdirSync(getCacheDir(), { recursive: true });
}
}
@@ -50,11 +54,11 @@ function ensureCacheDir(): void {
*/
export function readDiskCache(): UsageDiskCache | null {
try {
if (!fs.existsSync(CACHE_FILE)) {
if (!fs.existsSync(getCacheFile())) {
return null;
}
const data = fs.readFileSync(CACHE_FILE, 'utf-8');
const data = fs.readFileSync(getCacheFile(), 'utf-8');
const cache: UsageDiskCache = JSON.parse(data);
// Version check - invalidate if schema changed
@@ -112,9 +116,9 @@ export function writeDiskCache(
};
// Write atomically using temp file + rename
const tempFile = CACHE_FILE + '.tmp';
const tempFile = getCacheFile() + '.tmp';
fs.writeFileSync(tempFile, JSON.stringify(cache), 'utf-8');
fs.renameSync(tempFile, CACHE_FILE);
fs.renameSync(tempFile, getCacheFile());
console.log(ok('Disk cache updated'));
} catch (err) {
@@ -144,8 +148,8 @@ export function getCacheAge(cache: UsageDiskCache | null): string {
*/
export function clearDiskCache(): void {
try {
if (fs.existsSync(CACHE_FILE)) {
fs.unlinkSync(CACHE_FILE);
if (fs.existsSync(getCacheFile())) {
fs.unlinkSync(getCacheFile());
console.log(ok('Disk cache cleared'));
}
} catch (err) {
+6
View File
@@ -153,5 +153,11 @@ describe('Config Directory Override', function () {
const { detectCloudSyncPath } = require('../../dist/utils/config-manager');
assert.strictEqual(detectCloudSyncPath(path.join(os.homedir(), '.ccs')), null);
});
it('should not false-positive on substrings (e.g., megauser, Dropbox-api)', function () {
const { detectCloudSyncPath } = require('../../dist/utils/config-manager');
assert.strictEqual(detectCloudSyncPath('/home/megauser/.ccs'), null);
assert.strictEqual(detectCloudSyncPath('/home/kai/Dropbox-api-client/ccs'), null);
});
});
});