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:
Tam Nhu Tran
2026-02-24 18:09:36 +07:00
parent 3148014c6c
commit b602ab99ab
4 changed files with 162 additions and 47 deletions
+16 -2
View File
@@ -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,
+5
View File
@@ -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'
+44 -28
View File
@@ -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);
}
});
+97 -17
View File
@@ -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.');
}
});