mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 06:16:37 +00:00
fix(auth): harden agy safety endpoints and config writes
- add safer error responses for settings/auth routes to avoid leaking internals - guard sensitive AGY settings endpoints to localhost when dashboard auth is off - validate start-route bodies and reject OAuth start in remote mode for consistency - preserve cliproxy.kiro_no_incognito and token_refresh during config merges - enforce AGY acknowledgement in remote auth-token run/auth command paths
This commit is contained in:
@@ -529,9 +529,23 @@ export async function execClaudeWithCLIProxy(
|
||||
log(`Using remote proxy authentication (skipping local OAuth)`);
|
||||
}
|
||||
|
||||
if (provider === 'agy' && !forceAuth && !skipLocalAuth) {
|
||||
if (provider === 'agy' && forceAuth && skipLocalAuth) {
|
||||
const acknowledged = await ensureCliAntigravityResponsibility({
|
||||
context: 'oauth',
|
||||
acceptedByFlag: acceptAgyRisk,
|
||||
});
|
||||
if (!acknowledged) {
|
||||
throw new Error(
|
||||
`Antigravity auth blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.`
|
||||
);
|
||||
}
|
||||
console.error(info('Remote proxy mode is active; local OAuth flow is skipped in --auth mode.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'agy' && !forceAuth) {
|
||||
const requiresAuthNow = providerConfig.requiresOAuth && !isAuthenticated(provider);
|
||||
if (!requiresAuthNow) {
|
||||
if (skipLocalAuth || !requiresAuthNow) {
|
||||
const acknowledged = await ensureCliAntigravityResponsibility({
|
||||
context: 'run',
|
||||
acceptedByFlag: acceptAgyRisk,
|
||||
|
||||
@@ -243,6 +243,7 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
accounts: partial.accounts ?? defaults.accounts,
|
||||
profiles: partial.profiles ?? defaults.profiles,
|
||||
cliproxy: {
|
||||
...partial.cliproxy,
|
||||
oauth_accounts: partial.cliproxy?.oauth_accounts ?? defaults.cliproxy.oauth_accounts,
|
||||
providers: defaults.cliproxy.providers, // Always use defaults for providers
|
||||
variants: partial.cliproxy?.variants ?? defaults.cliproxy.variants,
|
||||
@@ -256,8 +257,12 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
partial.cliproxy?.safety?.antigravity_ack_bypass ??
|
||||
DEFAULT_CLIPROXY_SAFETY_CONFIG.antigravity_ack_bypass,
|
||||
},
|
||||
// Kiro browser behavior setting (optional)
|
||||
kiro_no_incognito: partial.cliproxy?.kiro_no_incognito,
|
||||
// Auth config - preserve user values, no defaults (uses constants as fallback)
|
||||
auth: partial.cliproxy?.auth,
|
||||
// Background token refresh config (optional)
|
||||
token_refresh: partial.cliproxy?.token_refresh,
|
||||
// Backend selection - validate and preserve user choice (original vs plus)
|
||||
backend:
|
||||
partial.cliproxy?.backend === 'original' || partial.cliproxy?.backend === 'plus'
|
||||
|
||||
@@ -56,6 +56,24 @@ const router = Router();
|
||||
// Valid providers list - derived from canonical CLIPROXY_PROFILES
|
||||
const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES];
|
||||
|
||||
function logRouteError(context: string, error: unknown): void {
|
||||
if (error instanceof Error) {
|
||||
console.error(`[cliproxy-auth-routes] ${context}: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
console.error(`[cliproxy-auth-routes] ${context}: unknown error`);
|
||||
}
|
||||
|
||||
function respondInternalError(
|
||||
res: Response,
|
||||
error: unknown,
|
||||
fallbackMessage: string,
|
||||
statusCode = 500
|
||||
): void {
|
||||
logRouteError(fallbackMessage, error);
|
||||
res.status(statusCode).json({ error: fallbackMessage });
|
||||
}
|
||||
|
||||
function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } {
|
||||
if (raw === undefined || raw === null) {
|
||||
return { method: normalizeKiroAuthMethod(), invalid: false };
|
||||
@@ -167,12 +185,12 @@ router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
||||
const target = getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
res.status(503).json({
|
||||
error: (error as Error).message,
|
||||
error: 'Failed to fetch remote auth status',
|
||||
authStatus: [],
|
||||
source: 'remote',
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
respondInternalError(res, error, 'Failed to fetch auth status.');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -202,13 +220,12 @@ router.get('/accounts', async (_req: Request, res: Response): Promise<void> => {
|
||||
const target = getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
res.status(503).json({
|
||||
error: (error as Error).message,
|
||||
error: 'Failed to fetch remote account status',
|
||||
accounts: [],
|
||||
source: 'remote',
|
||||
});
|
||||
} else {
|
||||
const message = error instanceof Error ? error.message : 'Failed to list accounts';
|
||||
res.status(500).json({ error: message });
|
||||
respondInternalError(res, error, 'Failed to list accounts.');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -229,8 +246,7 @@ router.get('/accounts/:provider', (req: Request, res: Response): void => {
|
||||
const accounts = getProviderAccounts(provider as CLIProxyProvider);
|
||||
res.json({ provider, accounts });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get provider accounts';
|
||||
res.status(500).json({ error: message });
|
||||
respondInternalError(res, error, 'Failed to get provider accounts.');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -272,8 +288,7 @@ router.post('/accounts/:provider/default', (req: Request, res: Response): void =
|
||||
.json({ error: `Account '${accountId}' not found for provider '${provider}'` });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to set default account';
|
||||
res.status(500).json({ error: message });
|
||||
respondInternalError(res, error, 'Failed to set default account.');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -309,8 +324,7 @@ router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): v
|
||||
.json({ error: `Account '${accountId}' not found for provider '${provider}'` });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to remove account';
|
||||
res.status(500).json({ error: message });
|
||||
respondInternalError(res, error, 'Failed to remove account.');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -342,8 +356,7 @@ router.post('/accounts/:provider/:accountId/pause', (req: Request, res: Response
|
||||
.json({ error: `Account '${accountId}' not found for provider '${provider}'` });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to pause account';
|
||||
res.status(500).json({ error: message });
|
||||
respondInternalError(res, error, 'Failed to pause account.');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -374,8 +387,7 @@ router.post('/accounts/:provider/:accountId/resume', (req: Request, res: Respons
|
||||
.json({ error: `Account '${accountId}' not found for provider '${provider}'` });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to resume account';
|
||||
res.status(500).json({ error: message });
|
||||
respondInternalError(res, error, 'Failed to resume account.');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -385,14 +397,20 @@ router.post('/accounts/:provider/:accountId/resume', (req: Request, res: Respons
|
||||
*/
|
||||
router.post('/:provider/start', async (req: Request, res: Response): Promise<void> => {
|
||||
const { provider } = req.params;
|
||||
const {
|
||||
nickname: nicknameRaw,
|
||||
noIncognito: noIncognitoBody,
|
||||
kiroMethod: kiroMethodRaw,
|
||||
riskAcknowledgement,
|
||||
} = req.body;
|
||||
const requestBody =
|
||||
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {};
|
||||
const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined;
|
||||
const noIncognitoBody =
|
||||
typeof requestBody.noIncognito === 'boolean' ? requestBody.noIncognito : undefined;
|
||||
const kiroMethodRaw = requestBody.kiroMethod;
|
||||
const riskAcknowledgement = requestBody.riskAcknowledgement;
|
||||
const target = getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
res.status(501).json({ error: 'OAuth start flow not available in remote mode' });
|
||||
return;
|
||||
}
|
||||
// Trim nickname for consistency with CLI (oauth-handler.ts trims input)
|
||||
const nickname = typeof nicknameRaw === 'string' ? nicknameRaw.trim() : nicknameRaw;
|
||||
const nickname = nicknameRaw?.trim();
|
||||
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
|
||||
|
||||
// Validate provider
|
||||
@@ -488,7 +506,7 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
res.status(400).json({ error: 'Authentication failed or was cancelled' });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
respondInternalError(res, error, 'Failed to start OAuth flow.');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -601,7 +619,7 @@ router.post('/kiro/import', async (_req: Request, res: Response): Promise<void>
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
respondInternalError(res, error, 'Failed to import Kiro token.');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -700,8 +718,7 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
|
||||
method: data.method || null,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to start OAuth';
|
||||
res.status(503).json({ error: `CLIProxyAPI not reachable: ${message}` });
|
||||
respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -815,8 +832,7 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to submit callback';
|
||||
res.status(503).json({ error: `CLIProxyAPI not reachable: ${message}` });
|
||||
respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,11 @@ import {
|
||||
} from '../../cliproxy';
|
||||
import { regenerateConfig } from '../../cliproxy/config-generator';
|
||||
import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils';
|
||||
import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import {
|
||||
getDashboardAuthConfig,
|
||||
loadOrCreateUnifiedConfig,
|
||||
saveUnifiedConfig,
|
||||
} from '../../config/unified-config-loader';
|
||||
import type { Settings } from '../../types/config';
|
||||
|
||||
const router = Router();
|
||||
@@ -32,6 +36,73 @@ const MODEL_ENV_KEYS = [
|
||||
] as const;
|
||||
const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const;
|
||||
|
||||
function logRouteError(context: string, error: unknown): void {
|
||||
if (error instanceof Error) {
|
||||
console.error(`[settings-routes] ${context}: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
console.error(`[settings-routes] ${context}: unknown error`);
|
||||
}
|
||||
|
||||
function respondInternalError(
|
||||
res: Response,
|
||||
error: unknown,
|
||||
fallbackMessage: string,
|
||||
statusCode = 500
|
||||
): void {
|
||||
logRouteError(fallbackMessage, error);
|
||||
res.status(statusCode).json({ error: fallbackMessage });
|
||||
}
|
||||
|
||||
function isLoopbackAddress(value: string | undefined): boolean {
|
||||
if (!value) return false;
|
||||
const normalized = value.trim().replace(/^\[|\]$/g, '');
|
||||
return (
|
||||
normalized === '::1' ||
|
||||
normalized === '127.0.0.1' ||
|
||||
normalized.startsWith('127.') ||
|
||||
normalized === '::ffff:127.0.0.1' ||
|
||||
normalized.startsWith('::ffff:127.')
|
||||
);
|
||||
}
|
||||
|
||||
function requireSensitiveLocalAccess(req: Request, res: Response): boolean {
|
||||
const dashboardAuth = getDashboardAuthConfig();
|
||||
if (dashboardAuth.enabled) {
|
||||
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;
|
||||
|
||||
if (isLoopbackAddress(candidateAddress)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
res.status(403).json({
|
||||
error: 'Sensitive settings endpoints require localhost access when dashboard auth is disabled.',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
function classifyConfigSaveFailure(error: unknown): { statusCode: number; message: string } {
|
||||
const message = error instanceof Error ? error.message.toLowerCase() : '';
|
||||
|
||||
if (message.includes('failed to acquire config lock')) {
|
||||
return { statusCode: 409, message: 'Configuration is busy. Retry in a moment.' };
|
||||
}
|
||||
if (message.includes('eacces') || message.includes('eperm') || message.includes('permission')) {
|
||||
return { statusCode: 403, message: 'Insufficient permission to update configuration.' };
|
||||
}
|
||||
if (message.includes('enospc') || message.includes('no space left')) {
|
||||
return { statusCode: 507, message: 'Insufficient disk space to update configuration.' };
|
||||
}
|
||||
|
||||
return { statusCode: 500, message: 'Failed to update Antigravity power user mode.' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Resolve settings path for profile or variant
|
||||
* Variants have settings paths in config, regular profiles use {name}.settings.json
|
||||
@@ -145,7 +216,7 @@ router.get('/:profile', (req: Request, res: Response): void => {
|
||||
path: settingsPath,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
respondInternalError(res, error, 'Internal server error.');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -172,7 +243,7 @@ router.get('/:profile/raw', (req: Request, res: Response): void => {
|
||||
path: settingsPath,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
respondInternalError(res, error, 'Internal server error.');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -265,7 +336,7 @@ router.put('/:profile', (req: Request, res: Response): void => {
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
respondInternalError(res, error, 'Internal server error.');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -287,7 +358,7 @@ router.get('/:profile/presets', (req: Request, res: Response): void => {
|
||||
const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath));
|
||||
res.json({ presets: settings.presets || [] });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
respondInternalError(res, error, 'Internal server error.');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -346,7 +417,7 @@ router.post('/:profile/presets', (req: Request, res: Response): void => {
|
||||
|
||||
res.status(201).json({ preset });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
respondInternalError(res, error, 'Internal server error.');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -378,7 +449,7 @@ router.delete('/:profile/presets/:name', (req: Request, res: Response): void =>
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
respondInternalError(res, error, 'Internal server error.');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -387,14 +458,16 @@ router.delete('/:profile/presets/:name', (req: Request, res: Response): void =>
|
||||
/**
|
||||
* GET /api/settings/auth/antigravity-risk - Get AGY responsibility bypass setting
|
||||
*/
|
||||
router.get('/auth/antigravity-risk', (_req: Request, res: Response): void => {
|
||||
router.get('/auth/antigravity-risk', (req: Request, res: Response): void => {
|
||||
if (!requireSensitiveLocalAccess(req, res)) return;
|
||||
|
||||
try {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
res.json({
|
||||
antigravityAckBypass: config.cliproxy?.safety?.antigravity_ack_bypass === true,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
respondInternalError(res, error, 'Failed to load Antigravity power user mode.');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -402,8 +475,12 @@ router.get('/auth/antigravity-risk', (_req: Request, res: Response): void => {
|
||||
* PUT /api/settings/auth/antigravity-risk - Update AGY responsibility bypass setting
|
||||
*/
|
||||
router.put('/auth/antigravity-risk', (req: Request, res: Response): void => {
|
||||
if (!requireSensitiveLocalAccess(req, res)) return;
|
||||
|
||||
try {
|
||||
const { antigravityAckBypass } = req.body as { antigravityAckBypass?: unknown };
|
||||
const body = req.body as { antigravityAckBypass?: unknown } | null | undefined;
|
||||
const antigravityAckBypass =
|
||||
body && typeof body === 'object' ? body.antigravityAckBypass : undefined;
|
||||
|
||||
if (typeof antigravityAckBypass !== 'boolean') {
|
||||
res.status(400).json({ error: 'antigravityAckBypass must be a boolean' });
|
||||
@@ -422,7 +499,8 @@ router.put('/auth/antigravity-risk', (req: Request, res: Response): void => {
|
||||
antigravityAckBypass,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
const classified = classifyConfigSaveFailure(error);
|
||||
respondInternalError(res, error, classified.message, classified.statusCode);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -444,7 +522,7 @@ router.get('/auth/tokens', (_req: Request, res: Response): void => {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
respondInternalError(res, error, 'Internal server error.');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -452,7 +530,9 @@ router.get('/auth/tokens', (_req: Request, res: Response): void => {
|
||||
* GET /api/settings/auth/tokens/raw - Get current auth tokens unmasked
|
||||
* NOTE: Sensitive endpoint - no caching, localhost only
|
||||
*/
|
||||
router.get('/auth/tokens/raw', (_req: Request, res: Response): void => {
|
||||
router.get('/auth/tokens/raw', (req: Request, res: Response): void => {
|
||||
if (!requireSensitiveLocalAccess(req, res)) return;
|
||||
|
||||
try {
|
||||
// Prevent caching of sensitive data
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
@@ -470,7 +550,7 @@ router.get('/auth/tokens/raw', (_req: Request, res: Response): void => {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
respondInternalError(res, error, 'Failed to load raw auth tokens.');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -506,7 +586,7 @@ router.put('/auth/tokens', (req: Request, res: Response): void => {
|
||||
message: 'Restart CLIProxy to apply changes',
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
respondInternalError(res, error, 'Internal server error.');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -530,7 +610,7 @@ router.post('/auth/tokens/regenerate-secret', (_req: Request, res: Response): vo
|
||||
message: 'Restart CLIProxy to apply changes',
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
respondInternalError(res, error, 'Internal server error.');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -558,7 +638,7 @@ router.post('/auth/tokens/reset', (_req: Request, res: Response): void => {
|
||||
message: 'Tokens reset to defaults. Restart CLIProxy to apply.',
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
respondInternalError(res, error, 'Internal server error.');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user