mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
fix(security): harden API endpoints and resolve PR #674 review findings
- Fix X-Forwarded-For header spoofing in requireSensitiveLocalAccess (use socket-level address only) - Add model ID length validation (max 256 chars) in PUT /models/:provider - Add provider name validation (alphanumeric, max 64 chars) to prevent path traversal - Add stale entry eviction for unbounded quotaRateLimits Map (>1000 entries) - Fix concurrent refresh race in usage aggregator (wait for in-flight before forced refresh) - Sanitize internal URLs/paths from OAuth failure diagnostics - Add ValidationError class for denylist violations (distinct from system errors) - Make CLIProxy sync interval configurable via CCS_CLIPROXY_SYNC_INTERVAL env var - Improve legacy continuity config error logging (unconditional warn) - Add CACHE_VERSION history comments
This commit is contained in:
@@ -52,13 +52,11 @@ function loadLegacyContinuityInheritanceMap(): Record<string, string> {
|
||||
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(
|
||||
warn(
|
||||
`Failed to parse legacy continuity mapping at "${configJsonPath}": ${(error as Error).message}`
|
||||
)
|
||||
);
|
||||
}
|
||||
console.error(
|
||||
warn(
|
||||
`Failed to parse legacy continuity mapping at "${configJsonPath}": ${(error as Error).message}`
|
||||
)
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,8 +304,12 @@ async function handleTokenNotFound(
|
||||
console.log('');
|
||||
|
||||
if (failureReason) {
|
||||
// Sanitize internal URLs/paths from failure reason to avoid leaking infrastructure details
|
||||
const sanitizedReason = failureReason
|
||||
.replace(/https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)[^\s]*/gi, '[internal-url]')
|
||||
.replace(/\/(?:root|home|opt|tmp|var)\/[^\s]*/g, '[path]');
|
||||
console.log(fail('Authentication completed but token was not persisted'));
|
||||
console.log(` ${failureReason}`);
|
||||
console.log(` ${sanitizedReason}`);
|
||||
console.log('');
|
||||
console.log('This usually means provider-side authorization was accepted,');
|
||||
console.log('but CLIProxy failed a post-auth verification or token save step.');
|
||||
|
||||
@@ -155,6 +155,20 @@ export class UserAbortError extends CCSError {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Input validation errors (model denylist, invalid format, length limits)
|
||||
* Distinguishes user-input validation failures from system errors
|
||||
*/
|
||||
export class ValidationError extends CCSError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly field?: string
|
||||
) {
|
||||
super(message, ExitCode.GENERAL_ERROR, false);
|
||||
this.name = 'ValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an error is a CCSError
|
||||
*/
|
||||
|
||||
@@ -77,6 +77,16 @@ function buildQuotaRateLimitKey(req: Request, provider: string): string {
|
||||
function isQuotaRouteRateLimited(req: Request, provider: string): boolean {
|
||||
const key = buildQuotaRateLimitKey(req, provider);
|
||||
const now = Date.now();
|
||||
|
||||
// Evict stale entries to prevent unbounded memory growth
|
||||
if (quotaRateLimits.size > 1000) {
|
||||
for (const [k, v] of quotaRateLimits) {
|
||||
if (now - v.windowStart >= QUOTA_RATE_LIMIT_WINDOW_MS * 2) {
|
||||
quotaRateLimits.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const current = quotaRateLimits.get(key);
|
||||
|
||||
if (!current || now - current.windowStart >= QUOTA_RATE_LIMIT_WINDOW_MS) {
|
||||
@@ -584,6 +594,13 @@ router.get('/auth-files/download', async (req: Request, res: Response): Promise<
|
||||
router.put('/models/:provider', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { provider } = req.params;
|
||||
|
||||
// Validate provider name to prevent path traversal via crafted provider param
|
||||
if (!provider || provider.length > 64 || !/^[a-zA-Z0-9_-]+$/.test(provider)) {
|
||||
res.status(400).json({ error: 'Invalid provider name' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { model } = req.body;
|
||||
|
||||
if (!model || typeof model !== 'string') {
|
||||
@@ -591,6 +608,11 @@ router.put('/models/:provider', async (req: Request, res: Response): Promise<voi
|
||||
return;
|
||||
}
|
||||
|
||||
if (model.length > 256) {
|
||||
res.status(400).json({ error: 'Model ID exceeds maximum length (256 characters)' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deniedReason = getDeniedModelIdReasonForProvider(model, provider);
|
||||
if (deniedReason) {
|
||||
res.status(400).json({ error: deniedReason });
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '../../cliproxy/model-id-normalizer';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import type { Config, Settings } from '../../types/config';
|
||||
import { ValidationError } from '../../errors/error-types';
|
||||
|
||||
/** Model mapping for API profiles */
|
||||
export interface ModelMapping {
|
||||
@@ -155,7 +156,7 @@ export function createSettingsFile(
|
||||
canonicalHaikuModel,
|
||||
]);
|
||||
if (deniedReason) {
|
||||
throw new Error(deniedReason);
|
||||
throw new ValidationError(deniedReason, 'model');
|
||||
}
|
||||
const droidProvider = resolveDroidProvider({
|
||||
provider,
|
||||
@@ -237,7 +238,7 @@ export function updateSettingsFile(
|
||||
: settings.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL,
|
||||
]);
|
||||
if (deniedReason) {
|
||||
throw new Error(deniedReason);
|
||||
throw new ValidationError(deniedReason, 'model');
|
||||
}
|
||||
|
||||
if (updates.baseUrl) {
|
||||
|
||||
@@ -78,10 +78,10 @@ function requireSensitiveLocalAccess(req: Request, res: Response): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
const firstForwarded =
|
||||
typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : undefined;
|
||||
const candidateAddress = firstForwarded || req.socket.remoteAddress || req.ip;
|
||||
// Use only socket-level address for security decisions.
|
||||
// X-Forwarded-For is trivially spoofable and must NOT be trusted
|
||||
// without an explicit trust proxy configuration.
|
||||
const candidateAddress = req.socket.remoteAddress;
|
||||
|
||||
if (isLoopbackAddress(candidateAddress)) {
|
||||
return true;
|
||||
|
||||
@@ -378,8 +378,17 @@ async function refreshFromSourceCoalesced(force = false): Promise<{
|
||||
monthly: MonthlyUsage[];
|
||||
session: SessionUsage[];
|
||||
}> {
|
||||
// Wait for any in-flight refresh to finish before starting a forced one
|
||||
// to prevent concurrent refreshes competing for the same resources
|
||||
if (force && pendingFullRefresh) {
|
||||
await pendingFullRefresh.catch(() => {});
|
||||
}
|
||||
|
||||
if (force) {
|
||||
return refreshFromSource();
|
||||
pendingFullRefresh = refreshFromSource().finally(() => {
|
||||
pendingFullRefresh = null;
|
||||
});
|
||||
return pendingFullRefresh;
|
||||
}
|
||||
|
||||
if (pendingFullRefresh) {
|
||||
|
||||
@@ -34,6 +34,12 @@ interface CliproxyUsageSnapshot {
|
||||
|
||||
const SNAPSHOT_VERSION = 1;
|
||||
|
||||
/** Sync interval in ms, configurable via CCS_CLIPROXY_SYNC_INTERVAL env var (default: 5 min) */
|
||||
const SYNC_INTERVAL_MS = Math.max(
|
||||
30_000,
|
||||
parseInt(process.env.CCS_CLIPROXY_SYNC_INTERVAL ?? '300000', 10) || 300_000
|
||||
);
|
||||
|
||||
// Module-level interval ID
|
||||
let syncIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
@@ -149,17 +155,15 @@ export function startCliproxySync(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(info('Starting CLIProxy usage sync (interval: 5 min)'));
|
||||
const intervalMin = Math.round(SYNC_INTERVAL_MS / 60_000);
|
||||
console.log(info(`Starting CLIProxy usage sync (interval: ${intervalMin} min)`));
|
||||
|
||||
// Fire-and-forget initial sync
|
||||
void syncCliproxyUsage();
|
||||
|
||||
syncIntervalId = setInterval(
|
||||
() => {
|
||||
void syncCliproxyUsage();
|
||||
},
|
||||
5 * 60 * 1000
|
||||
);
|
||||
syncIntervalId = setInterval(() => {
|
||||
void syncCliproxyUsage();
|
||||
}, SYNC_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface UsageDiskCache {
|
||||
}
|
||||
|
||||
// Current cache version - increment to invalidate old caches
|
||||
// v1: Initial cache format (daily, monthly, session)
|
||||
// v2: Added multi-instance aggregation
|
||||
// v3: Added hourly data to cache
|
||||
// v4: Pricing fix for Claude 4.6 models (invalidate stale costs)
|
||||
const CACHE_VERSION = 4;
|
||||
|
||||
Reference in New Issue
Block a user