@askalf/dario 4.8.119 → 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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "4.8.119",
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": {