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