@askalf/dario 4.8.118 → 4.8.120

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,4 +41,22 @@ export interface PoolDerivedStatus {
41
41
  accounts: number;
42
42
  }
43
43
  export declare function derivePoolStatus(accounts: readonly PoolAccountStatusLike[], now: number, adminEnabled: boolean): PoolDerivedStatus;
44
- export declare function buildHealthResponse(s: HealthStatusLike, requestCount: number, viaPublicTunnel: boolean): HealthResponse;
44
+ export declare function buildHealthResponse(s: HealthStatusLike, requestCount: number, includeInternal: boolean): HealthResponse;
45
+ /**
46
+ * Decide whether a /health caller may see the OAuth internals (#642).
47
+ *
48
+ * /health is intentionally auth-free (docker healthchecks need it before a
49
+ * key is configured), so we cannot simply gate on the API key. Trust model:
50
+ * - authenticated (valid DARIO_API_KEY) -> internal (an internal caller)
51
+ * - came via the Cloudflare tunnel (cf-ray) -> public (world-reachable)
52
+ * - otherwise bare loopback -> internal (docker HC / doctor)
53
+ * - otherwise (LAN, other container, WAN) -> public
54
+ * The cf-ray check is now only ever used to DENY (force public), never to
55
+ * grant, so spoofing it cannot widen disclosure — the previous fail-open
56
+ * direction is closed.
57
+ */
58
+ export declare function shouldDiscloseHealthInternals(opts: {
59
+ authenticated: boolean;
60
+ loopback: boolean;
61
+ viaCfRay: boolean;
62
+ }): boolean;
@@ -59,24 +59,50 @@ export function derivePoolStatus(accounts, now, adminEnabled) {
59
59
  expiresIn: formatMsLeft(earliest - now),
60
60
  };
61
61
  }
62
- export function buildHealthResponse(s, requestCount, viaPublicTunnel) {
62
+ export function buildHealthResponse(s, requestCount, includeInternal) {
63
63
  const dead = s.status === 'broken' ||
64
64
  s.status === 'none' ||
65
65
  (s.status === 'expired' && s.canRefresh === false);
66
66
  const httpStatus = dead ? 503 : 200;
67
67
  const liveness = { status: dead ? 'degraded' : 'ok' };
68
- const body = viaPublicTunnel
69
- ? liveness
70
- : {
68
+ // Only trusted callers (authenticated, or bare loopback not via the CF
69
+ // tunnel — see shouldDiscloseHealthInternals) get the OAuth internals.
70
+ // Everyone else (LAN, public tunnel) gets the liveness verdict only; the
71
+ // HTTP status is identical either way so uptime checks still work. #642:
72
+ // this used to key on the presence of the client-suppliable `cf-ray`
73
+ // header, which failed OPEN — a direct non-tunnel caller omits it and got
74
+ // the full internal view. `lastRefreshError` is no longer exposed here at
75
+ // all (it can carry a raw upstream error string); it remains on the
76
+ // key-gated /status.
77
+ const body = includeInternal
78
+ ? {
71
79
  ...liveness,
72
- // Version for internal callers only — an update-check signal (#640),
73
- // withheld from the public-tunnel view alongside the OAuth internals.
74
80
  ...(s.version ? { version: s.version } : {}),
75
81
  oauth: s.status,
76
82
  expiresIn: s.expiresIn,
77
83
  requests: requestCount,
78
84
  ...(s.refreshFailures ? { refreshFailures: s.refreshFailures } : {}),
79
- ...(s.lastRefreshError ? { lastRefreshError: s.lastRefreshError } : {}),
80
- };
85
+ }
86
+ : liveness;
81
87
  return { httpStatus, body };
82
88
  }
89
+ /**
90
+ * Decide whether a /health caller may see the OAuth internals (#642).
91
+ *
92
+ * /health is intentionally auth-free (docker healthchecks need it before a
93
+ * key is configured), so we cannot simply gate on the API key. Trust model:
94
+ * - authenticated (valid DARIO_API_KEY) -> internal (an internal caller)
95
+ * - came via the Cloudflare tunnel (cf-ray) -> public (world-reachable)
96
+ * - otherwise bare loopback -> internal (docker HC / doctor)
97
+ * - otherwise (LAN, other container, WAN) -> public
98
+ * The cf-ray check is now only ever used to DENY (force public), never to
99
+ * grant, so spoofing it cannot widen disclosure — the previous fail-open
100
+ * direction is closed.
101
+ */
102
+ export function shouldDiscloseHealthInternals(opts) {
103
+ if (opts.authenticated)
104
+ return true;
105
+ if (opts.viaCfRay)
106
+ return false;
107
+ return opts.loopback;
108
+ }
package/dist/proxy.js CHANGED
@@ -7,7 +7,7 @@ import { homedir } from 'node:os';
7
7
  import { setDefaultResultOrder } from 'node:dns';
8
8
  import { arch, platform } from 'node:process';
9
9
  import { getAccessToken, getStatus } from './oauth.js';
10
- import { buildHealthResponse, derivePoolStatus } from './health-response.js';
10
+ import { buildHealthResponse, derivePoolStatus, shouldDiscloseHealthInternals } from './health-response.js';
11
11
  import { darioVersion } from './version.js';
12
12
  import { buildCCRequest, applyCcPromptCaching, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, CC_TEMPLATE } from './cc-template.js';
13
13
  import { stampCch } from './cch.js';
@@ -38,6 +38,18 @@ function isLoopbackHost(host) {
38
38
  return true;
39
39
  return host.startsWith('127.');
40
40
  }
41
+ // A socket peer address is loopback if it is 127.0.0.0/8 or ::1, including the
42
+ // IPv4-mapped-IPv6 form Node reports on dual-stack sockets (`::ffff:127.0.0.1`).
43
+ // Distinct from isLoopbackHost (which classifies a bind-address string): this
44
+ // classifies an inbound connection's peer for the /health disclosure gate (#642).
45
+ function isLoopbackAddr(addr) {
46
+ if (!addr)
47
+ return false;
48
+ if (addr === '::1')
49
+ return true;
50
+ const v4 = addr.startsWith('::ffff:') ? addr.slice(7) : addr;
51
+ return v4 === '127.0.0.1' || v4.startsWith('127.');
52
+ }
41
53
  // Concurrency control: see src/request-queue.ts for the bounded queue
42
54
  // (replaced the v3.30.x-and-earlier simple unbounded semaphore in dario#80).
43
55
  // Billing tag hash seed — matches Claude Code's value
@@ -1169,6 +1181,14 @@ export async function startProxy(opts = {}) {
1169
1181
  const adminTokenBuf = adminToken ? Buffer.from(adminToken) : null;
1170
1182
  if (adminEnabled) {
1171
1183
  console.log(`[dario] admin API enabled at /admin/* (token ${adminTokenBuf ? 'configured' : 'MISSING — endpoints return 403 until DARIO_ADMIN_TOKEN is set'})`);
1184
+ // Hardening (#642): the admin API adds/removes OAuth accounts. When it
1185
+ // shares DARIO_API_KEY (which is embedded in every client config) instead
1186
+ // of a distinct DARIO_ADMIN_TOKEN, every client that can proxy can also
1187
+ // control the account pool. Non-fatal for back-compat, but warn loudly.
1188
+ if (adminTokenBuf && !process.env.DARIO_ADMIN_TOKEN) {
1189
+ console.warn('[dario] WARNING: admin API is using DARIO_API_KEY as its token (no DARIO_ADMIN_TOKEN set).');
1190
+ console.warn('[dario] every client holding the proxy key can add/remove accounts. Set a distinct DARIO_ADMIN_TOKEN.');
1191
+ }
1172
1192
  }
1173
1193
  // Admin rate limiting (#620). Two token buckets, created once per proxy:
1174
1194
  // failed auth (brute-force / broken-client throttle) and mutations. Global,
@@ -1269,10 +1289,16 @@ export async function startProxy(opts = {}) {
1269
1289
  // react instead of cheerfully passing while every /v1/messages 401s.
1270
1290
  if (urlPath === '/health' || urlPath === '/') {
1271
1291
  const s = await currentStatus();
1272
- // Public requests arrive through the Cloudflare tunnel (the edge stamps
1273
- // `cf-ray`); they get only the liveness verdict, never the OAuth internals.
1274
- // See buildHealthResponse for the full rationale.
1275
- const { httpStatus, body } = buildHealthResponse({ ...s, version: darioVersion() }, requestCount, req.headers['cf-ray'] !== undefined);
1292
+ // Disclose OAuth internals only to trusted callers (#642). This used to
1293
+ // key on the presence of the client-suppliable `cf-ray` header and failed
1294
+ // OPEN a direct non-tunnel caller omits it and got the full internal
1295
+ // view. Now: authenticated, OR bare loopback that did not arrive via CF.
1296
+ const includeInternal = shouldDiscloseHealthInternals({
1297
+ authenticated: authenticateRequest(req.headers, apiKeyBuf),
1298
+ loopback: isLoopbackAddr(req.socket?.remoteAddress),
1299
+ viaCfRay: req.headers['cf-ray'] !== undefined,
1300
+ });
1301
+ const { httpStatus, body } = buildHealthResponse({ ...s, version: darioVersion() }, requestCount, includeInternal);
1276
1302
  res.writeHead(httpStatus, JSON_HEADERS);
1277
1303
  res.end(JSON.stringify(body));
1278
1304
  return;
@@ -3173,7 +3199,17 @@ export async function startProxy(opts = {}) {
3173
3199
  return;
3174
3200
  lastPresencePulse = now;
3175
3201
  try {
3176
- const token = await getAccessToken();
3202
+ // In pool mode the pool refresh loop (above) is the SOLE refresher of
3203
+ // every account's token lineage. credentials.json shares its refresh-
3204
+ // token family with accounts/login.json after a login->pool migration
3205
+ // (ensureLoginCredentialsInPool), so refreshing credentials.json here
3206
+ // via getAccessToken() races the pool refreshing login.json and trips
3207
+ // Anthropic's refresh-token reuse-detection (the 2026-06-23 fleet
3208
+ // outage, dario#641-audit). Pulse with a token the pool already keeps
3209
+ // fresh; never refresh from this side in pool mode.
3210
+ const token = pool ? (pool.all()[0]?.accessToken ?? '') : await getAccessToken();
3211
+ if (!token)
3212
+ return;
3177
3213
  const presenceUrl = `${ANTHROPIC_API}/v1/code/sessions/${SESSION_ID}/client/presence`;
3178
3214
  await fetch(presenceUrl, {
3179
3215
  method: 'POST',
@@ -3191,6 +3227,12 @@ export async function startProxy(opts = {}) {
3191
3227
  }, 5000);
3192
3228
  // Periodic token refresh (every 15 minutes)
3193
3229
  const refreshInterval = setInterval(async () => {
3230
+ // Pool mode: the pool's own 15-min refresh loop (above) owns token refresh
3231
+ // for every account. Refreshing credentials.json here too would double-
3232
+ // refresh a shared token lineage (see the presence-loop note) -> reuse-
3233
+ // detection. Skip; the pool is the sole refresher.
3234
+ if (pool)
3235
+ return;
3194
3236
  try {
3195
3237
  const s = await getStatus();
3196
3238
  if (s.status === 'expiring' || s.status === 'expired') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "4.8.118",
3
+ "version": "4.8.120",
4
4
  "description": "Use your Claude Pro/Max subscription in any tool — Cursor, Cline, Aider, the Agent SDK, your scripts — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint.",
5
5
  "type": "module",
6
6
  "bin": {