@vidofy/mcp 0.1.0

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.
@@ -0,0 +1,621 @@
1
+ /**
2
+ * Who is asking — OAuth client identification by Client ID Metadata Document.
3
+ *
4
+ * An MCP client tells us who it is by putting a URL in `client_id` and hosting
5
+ * its own OAuth metadata there (draft-ietf-oauth-client-id-metadata-document).
6
+ * We fetch that URL and read the client's name and its permitted redirect_uris.
7
+ *
8
+ * WHY ONLY THIS MECHANISM
9
+ * -----------------------
10
+ * The spec offers three (Client ID Metadata Documents, pre-registration,
11
+ * Dynamic Client Registration) and calls DCR "deprecated, retained for backwards
12
+ * compatibility". We did not take that on faith: the connector advertised BOTH
13
+ * `registration_endpoint` and `client_id_metadata_document_supported` and logged
14
+ * what real hosts chose (2026-09-12, through a cloudflared tunnel):
15
+ *
16
+ * claude.ai client_id = https://claude.ai/oauth/mcp-oauth-client-metadata
17
+ * ChatGPT client_id = https://chatgpt.com/oauth/<per-connector>/client.json
18
+ * requests to /register = ZERO, from either
19
+ *
20
+ * So DCR is not implemented and `registration_endpoint` is not advertised. When
21
+ * a client appears that needs it, the log will show the call and we will know.
22
+ *
23
+ * ⚠ THIS FETCHES A URL THE CALLER CHOSE
24
+ * -------------------------------------
25
+ * `client_id` is attacker-controlled by definition — anyone can start an
26
+ * authorization request. ChatGPT's is per-connector and unguessable, so we
27
+ * cannot allow-list known URLs; the document really is arbitrary. That makes
28
+ * this a server-side request forgery surface: point `client_id` at
29
+ * http://169.254.169.254/ or http://127.0.0.1:6379/ and the fetch becomes a
30
+ * probe of our own network.
31
+ *
32
+ * The guards below mirror the same rules the site already applies to partner
33
+ * callback URLs, rather than inventing a second policy. They are reimplemented
34
+ * rather than called because the site's copy is PHP; where the two could drift,
35
+ * the site is the reference.
36
+ */
37
+ import { lookup } from 'node:dns/promises';
38
+ import http from 'node:http';
39
+ import https from 'node:https';
40
+ import { isIP } from 'node:net';
41
+ import { log } from '../log.js';
42
+ export class ClientError extends Error {
43
+ }
44
+ /**
45
+ * A refusal whose REASON must not reach the caller.
46
+ *
47
+ * The split it marks is the whole of the oracle fix, so it is worth stating as a
48
+ * rule rather than a habit:
49
+ *
50
+ * ClientError — decided from the caller's own string, with no network
51
+ * touched: not a URL, not https, has credentials, has a
52
+ * fragment, port not allowed. The caller already knows their
53
+ * own URL, so saying why tells them nothing they did not
54
+ * have, and it is exactly what a client developer needs.
55
+ *
56
+ * OpaqueClientError — decided by what happened when we reached out to a host the
57
+ * CALLER CHOSE: the address rules, the connection, the
58
+ * status, the size, whether the body parsed, whether the
59
+ * document declared what it must.
60
+ *
61
+ * Why the second group must be silent, and why that does not cost a legitimate
62
+ * developer anything: every one of those answers is already available to whoever
63
+ * owns the host. Their access log shows our request and their own response; their
64
+ * document is in their hands. They do not need us to tell them it returned 500.
65
+ *
66
+ * An attacker probing an internal address has none of that — which is precisely
67
+ * what made our message valuable to them. `document returned 403` versus
68
+ * `is not a JSON object` versus `could not be fetched`, over a range of addresses,
69
+ * is a port scanner with our egress IP and no credential.
70
+ *
71
+ * The real reason is logged on our side, always. Nothing is lost, it just stops
72
+ * being answered to a stranger.
73
+ */
74
+ export class OpaqueClientError extends ClientError {
75
+ }
76
+ /** The single sentence every OpaqueClientError becomes on the way out. */
77
+ const OPAQUE_MESSAGE = 'The client_id document could not be used. Check that the URL serves a valid '
78
+ + 'Client ID Metadata Document over https — your own server log will show our request.';
79
+ /** Standard HTTP(S) ports only — same list as the site's own URL validator. */
80
+ const ALLOWED_PORTS = new Set([80, 443, 8080, 8443]);
81
+ /** 8 KB is generous for a document of seven fields; anything larger is not one. */
82
+ const MAX_BYTES = 8 * 1024;
83
+ /** Matches the site's token-name cap, since the site stores this value. */
84
+ const MAX_CLIENT_NAME = 60;
85
+ /**
86
+ * How long a SUCCESSFUL document is reused, and how many are kept.
87
+ *
88
+ * This is the cheapest of the three defences on this endpoint and the only one
89
+ * that makes legitimate traffic cost less rather than merely refusing abuse:
90
+ * measured on the real hosts, there are TWO client_id values in the world that
91
+ * matter — one URL for claude.ai and one per connector for ChatGPT — so every
92
+ * user of ours who connects is fetching the same document. Caching it means a
93
+ * thousand real sign-ins in five minutes cost one outbound request.
94
+ *
95
+ * Only successes are cached. Caching a failure would let one transient blip lock
96
+ * a legitimate client out for the whole TTL, turning a 500ms hiccup into five
97
+ * minutes of "client_id could not be resolved" — and it would gain nothing
98
+ * against an attacker, who is sending DIFFERENT urls rather than repeating one.
99
+ *
100
+ * In-process rather than Redis, deliberately: one process serves this, the values
101
+ * are public metadata with nothing to protect, and a Redis round trip per lookup
102
+ * would trade the network call we are avoiding for a smaller network call.
103
+ *
104
+ * The size cap is the part that matters for abuse: without it, an attacker naming
105
+ * a fresh url each time would grow this map without limit, which is a memory leak
106
+ * with a helpful name. 64 entries is far above the handful that exist for real.
107
+ */
108
+ const DOC_CACHE_TTL_MS = 5 * 60 * 1000;
109
+ const DOC_CACHE_MAX = 64;
110
+ const docCache = new Map();
111
+ /**
112
+ * How many CIMD fetches may be in flight at once, across every caller.
113
+ *
114
+ * The rate limits in ratelimit.ts are keyed on the caller's IP and on client_id,
115
+ * and each has a hole the other covers — except one: an attacker spread across
116
+ * many addresses AND rotating client_id gets a fresh bucket every time. This is
117
+ * what closes it, because it does not care who is asking. Six concurrent outbound
118
+ * requests is the ceiling regardless of how the traffic is shaped.
119
+ *
120
+ * Refused immediately rather than queued. A queue holds the sockets and the memory
121
+ * this exists to bound, and it converts a flood into latency for everyone instead
122
+ * of a clear refusal for the flood. With the cache above, a legitimate user rarely
123
+ * reaches the network at all, so being refused here means an attack is in progress
124
+ * — and their client will start the flow again.
125
+ */
126
+ const MAX_CONCURRENT_FETCHES = 6;
127
+ let inFlight = 0;
128
+ /**
129
+ * A fetch that hangs is a denial of service on the authorize endpoint.
130
+ *
131
+ * Lowered from 5s to 2s on 2026-09-13 so the timing floor below can cover it — see
132
+ * there for why the two numbers are related. 2s is generous for a document of
133
+ * seven fields: a host that cannot serve 300 bytes of JSON in two seconds is not
134
+ * one we should be waiting on while a person watches a sign-in screen.
135
+ */
136
+ const TIMEOUT_MS = 2_000;
137
+ /**
138
+ * Every OPAQUE refusal takes at least this long. The words alone were not enough.
139
+ *
140
+ * ⚠ THE MESSAGES WERE MERGED AND THE CLOCK STILL TOLD THEM APART. Measured
141
+ * 2026-09-12: a blocked address refuses in ~0 ms because no network is touched,
142
+ * while an unknown host takes ~52 ms waiting for DNS — and both answer the
143
+ * identical sentence "client_id host could not be used". A host that exists and
144
+ * answers takes a few hundred. So the four outcomes an attacker wants to
145
+ * distinguish were distinguishable by stopwatch, and merging the text moved the
146
+ * channel rather than closing it.
147
+ *
148
+ * WHY THIS NUMBER IS ABOVE TIMEOUT_MS, and not equal to it. If the floor were
149
+ * lower, a timeout would be the one refusal that takes longer than the rest and so
150
+ * the one still identifiable — "this host exists but is silent" is exactly the
151
+ * answer a port scan wants. Padding every opaque refusal past the timeout collapses
152
+ * them all, timeout included, onto one duration.
153
+ *
154
+ * NO JITTER, deliberately, although it feels like the more cautious choice. A fixed
155
+ * pad makes every refusal identical, which is unconditionally unobservable. Jitter
156
+ * would add a distribution whose MEAN still shifts with the underlying work, so
157
+ * enough samples would recover what the pad was meant to hide.
158
+ *
159
+ * WHAT THIS COSTS, stated rather than discovered later: an attacker holds a
160
+ * connection 2.5s per refused request instead of milliseconds. That is a real cost
161
+ * and it is bounded elsewhere — 30 authorize calls per minute per IP, and six
162
+ * concurrent outbound fetches, both in the layers added the same day. Without
163
+ * those, this padding would be a denial-of-service amplifier rather than a defence.
164
+ *
165
+ * The SUCCESS path is never padded. A successful fetch tells an attacker only that
166
+ * a host serves a valid document declaring their own client_id, which they must
167
+ * already control for it to be valid at all.
168
+ */
169
+ const OPAQUE_FLOOR_MS = 2_500;
170
+ /** Wait until `startedAt` is at least `floorMs` old. */
171
+ async function padUntil(startedAt, floorMs) {
172
+ const elapsed = Date.now() - startedAt;
173
+ if (elapsed >= floorMs)
174
+ return;
175
+ await new Promise((resolve) => setTimeout(resolve, floorMs - elapsed));
176
+ }
177
+ /**
178
+ * Is this address one we must never fetch?
179
+ *
180
+ * Covers the same ranges the site's own URL validator rejects: loopback,
181
+ * link-local (including the cloud metadata endpoint at
182
+ * 169.254.169.254), the three RFC 1918 blocks, carrier-grade NAT, and the IPv6
183
+ * equivalents. Written out rather than pulled from a package so the list is
184
+ * auditable here, next to the reason it exists.
185
+ */
186
+ /**
187
+ * The IPv4 address embedded in an IPv6 literal, in dotted form — or null.
188
+ *
189
+ * Recognises the three spellings that reach us:
190
+ * ::ffff:169.254.169.254 IPv4-mapped, dotted (what a human writes)
191
+ * ::ffff:a9fe:a9fe IPv4-mapped, hex (what WHATWG URL produces)
192
+ * ::a9fe:a9fe IPv4-compatible (deprecated, still routable)
193
+ */
194
+ function embeddedIPv4(v6) {
195
+ const dotted = /^::(?:ffff:)?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(v6);
196
+ if (dotted?.[1] !== undefined)
197
+ return dotted[1];
198
+ const hex = /^::(?:ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(v6);
199
+ if (hex?.[1] === undefined || hex[2] === undefined)
200
+ return null;
201
+ const hi = parseInt(hex[1], 16);
202
+ const lo = parseInt(hex[2], 16);
203
+ return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
204
+ }
205
+ function isBlockedAddress(ip) {
206
+ if (isIP(ip) === 6) {
207
+ const v6 = ip.toLowerCase().replace(/^\[|\]$/g, '');
208
+ /* An IPv4 address embedded in IPv6 must be judged by the IPv4 rules, and
209
+ finding it is subtler than it looks.
210
+ `new URL('https://[::ffff:169.254.169.254]')` reports its hostname as
211
+ `[::ffff:a9fe:a9fe]` — WHATWG normalisation rewrites the dotted quad
212
+ into hex groups. A check written against the dotted form therefore sees
213
+ nothing and waves the cloud metadata endpoint through; measured
214
+ 2026-09-12, that is exactly what happened here, and only the fetch
215
+ failing hid it. Both spellings are handled, and so is the deprecated
216
+ IPv4-compatible form (`::a9fe:a9fe`). */
217
+ const embedded = embeddedIPv4(v6);
218
+ if (embedded !== null)
219
+ return isBlockedAddress(embedded);
220
+ /* ⚠ `startsWith('fe80:')` was WRONG, and measured wrong on 2026-09-12.
221
+ * Link-local is **fe80::/10**, not fe80::/16 — the first ten bits are
222
+ * fixed, so the first hextet may be anything from fe80 to febf. `fe90::1`,
223
+ * `fea0::1`, `feb0::1` and `febf:ffff::1` all passed the old test.
224
+ *
225
+ * Honest about reach: on a normal Linux host none of these route anywhere
226
+ * without a scope id, so no internal target was actually exposed. They are
227
+ * blocked anyway — a guard whose correctness depends on the host's routing
228
+ * table is a guard nobody can reason about.
229
+ *
230
+ * Compared on the first hextet as a NUMBER rather than by string prefix,
231
+ * which is what made the original wrong. */
232
+ const firstHextet = parseInt(v6.split(':')[0] || '0', 16);
233
+ return v6 === '::1' || v6 === '::'
234
+ || (firstHextet >= 0xfe80 && firstHextet <= 0xfebf) // fe80::/10 link-local
235
+ || (firstHextet >= 0xfec0 && firstHextet <= 0xfeff) // fec0::/10 site-local (deprecated)
236
+ || (firstHextet >= 0xfc00 && firstHextet <= 0xfdff) // fc00::/7 unique-local
237
+ || (firstHextet >= 0xff00) // ff00::/8 multicast
238
+ /* Three translation prefixes that carry an IPv4 destination inside an
239
+ IPv6 address, so a blocked v4 target can be reached through them on a
240
+ network that routes them: NAT64 (real on IPv6-only cloud networks),
241
+ 6to4, and Teredo. Refused wholesale rather than decoded — we have no
242
+ business fetching a client_id document through any of them. */
243
+ || v6.startsWith('64:ff9b:') // NAT64 RFC 6052
244
+ || firstHextet === 0x2002 // 6to4 RFC 3056
245
+ || v6.startsWith('2001:0:') || v6.startsWith('2001::'); // Teredo RFC 4380
246
+ }
247
+ const p = ip.split('.').map(Number);
248
+ if (p.length !== 4 || p.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
249
+ return true;
250
+ const [a, b, c] = p;
251
+ return a === 0 // 0.0.0.0/8
252
+ || a === 10 // private
253
+ || a === 127 // loopback — the whole /8, not just .0.1
254
+ || (a === 100 && b >= 64 && b <= 127) // carrier-grade NAT
255
+ || (a === 169 && b === 254) // link-local + cloud metadata
256
+ || (a === 172 && b >= 16 && b <= 31) // private
257
+ || (a === 192 && b === 168) // private
258
+ || (a === 192 && b === 0 && c === 0) // IETF protocol assignments
259
+ || (a === 192 && b === 0 && c === 2) // TEST-NET-1
260
+ || (a === 192 && b === 88 && c === 99) // 6to4 relay anycast
261
+ || (a === 198 && (b === 18 || b === 19)) // benchmarking
262
+ || (a === 198 && b === 51 && c === 100) // TEST-NET-2
263
+ || (a === 203 && b === 0 && c === 113) // TEST-NET-3
264
+ || a >= 224; // multicast + reserved
265
+ }
266
+ /**
267
+ * Reject a client_id URL we must not fetch.
268
+ *
269
+ * @param allowPrivate Development only — mirrors the site validator's own
270
+ * local exception so the flow can be exercised against a local
271
+ * stub. Never true in production.
272
+ */
273
+ async function assertFetchable(raw, allowPrivate) {
274
+ let u;
275
+ try {
276
+ u = new URL(raw);
277
+ }
278
+ catch {
279
+ throw new ClientError('client_id must be an absolute URL.');
280
+ }
281
+ /* https only. The document carries the redirect_uris we are about to trust,
282
+ so fetching it over plaintext would let anyone on the path choose where
283
+ the authorization code is sent. The site's validator permits http; here it
284
+ must not, and the stricter rule is deliberate. */
285
+ if (u.protocol !== 'https:' && !allowPrivate) {
286
+ throw new ClientError('client_id must use https.');
287
+ }
288
+ if (u.username !== '' || u.password !== '') {
289
+ throw new ClientError('client_id must not contain credentials.');
290
+ }
291
+ if (u.hash !== '') {
292
+ throw new ClientError('client_id must not contain a fragment.');
293
+ }
294
+ const port = u.port === '' ? (u.protocol === 'http:' ? 80 : 443) : Number(u.port);
295
+ if (!allowPrivate && !ALLOWED_PORTS.has(port)) {
296
+ throw new ClientError('client_id port is not allowed.');
297
+ }
298
+ const host = u.hostname.replace(/^\[|\]$/g, '');
299
+ if (allowPrivate)
300
+ return u;
301
+ /* Resolve and check EVERY address, not just the first. A host with one
302
+ public and one private A record would otherwise pass on a lucky ordering
303
+ and reach an internal service on the next attempt.
304
+ *
305
+ * ⚠ THIS CHECK DOES NOT PROTECT THE CONNECTION, and an earlier version of
306
+ * this comment implied it did. Whoever supplies client_id owns that host's
307
+ * DNS, so they can answer this lookup with a public address and the NEXT
308
+ * lookup — the one the HTTP client makes when it actually connects — with
309
+ * 169.254.169.254, at a one-second TTL. Two independent resolutions is
310
+ * textbook DNS rebinding, and it defeats every rule above.
311
+ *
312
+ * What closes it is that the connection resolves the name ONCE, through our
313
+ * own hook, and connects to the address we vetted: see safeLookup below.
314
+ * This early check stays because refusing an obviously internal host before
315
+ * opening a socket is cheaper and clearer — but it is the cheap half. */
316
+ const addresses = isIP(host) !== 0
317
+ ? [host]
318
+ : (await lookup(host, { all: true }).catch(() => [])).map((a) => a.address);
319
+ if (addresses.length === 0) {
320
+ // Collapsed with the blocked case below on purpose: distinguishing
321
+ // "no such host" from "private host" is itself a probe.
322
+ throw new OpaqueClientError('client_id host could not be used.');
323
+ }
324
+ if (addresses.some(isBlockedAddress)) {
325
+ throw new OpaqueClientError('client_id host could not be used.');
326
+ }
327
+ return u;
328
+ }
329
+ /**
330
+ * The `lookup` hook `net.connect` calls to decide where the socket goes.
331
+ *
332
+ * For a HOSTNAME this is the only resolution on the connect path, so the address
333
+ * checked here is by construction the address connected to — which is what closes
334
+ * the rebinding window described in assertFetchable. The hostname still travels as
335
+ * SNI and in the Host header, so TLS and certificate validation are untouched;
336
+ * pinning by rewriting the URL to an IP would have broken both.
337
+ *
338
+ * ⚠ AND IT IS NEVER CALLED FOR AN IP LITERAL. Measured 2026-09-12: a request to
339
+ * `127.0.0.1` reached ECONNREFUSED without the hook being invoked once, while
340
+ * `localhost` did invoke it — `net.connect` has nothing to resolve when the host
341
+ * is already an address, so it skips the hook entirely. An earlier version of this
342
+ * comment claimed this was "the ONLY place the fetch resolves the host", full
343
+ * stop, which is false for literals.
344
+ *
345
+ * Nothing is exposed by that: a literal is refused in assertFetchable before any
346
+ * socket opens. But it means the two halves are not interchangeable — literals are
347
+ * guarded THERE and names are guarded HERE — and a test that only exercises this
348
+ * function is not testing the literal path at all.
349
+ *
350
+ * Fails CLOSED in every ambiguous case: a resolution error, an empty answer, or
351
+ * ANY blocked address among the answers refuses the whole connection rather than
352
+ * picking a surviving one. A host that answers with both a public and a private
353
+ * address has no business being a client_id.
354
+ *
355
+ * EXPORTED only so it can be tested directly, and that is not a formality: the
356
+ * check in assertFetchable cannot be reached past by a test without running a
357
+ * hostile DNS server, so the only way to prove this hook refuses what it claims
358
+ * to refuse is to call it. Nothing outside this module should use it.
359
+ */
360
+ export function safeLookup(hostname, options, callback) {
361
+ const refuse = () => {
362
+ const err = new Error('blocked address');
363
+ err.code = 'ENOTFOUND';
364
+ callback(err, '', undefined);
365
+ };
366
+ if (isIP(hostname) !== 0) {
367
+ if (isBlockedAddress(hostname))
368
+ return refuse();
369
+ const family = isIP(hostname);
370
+ if (options.all === true) {
371
+ callback(null, [{ address: hostname, family }]);
372
+ }
373
+ else {
374
+ callback(null, hostname, family);
375
+ }
376
+ return;
377
+ }
378
+ lookup(hostname, { all: true }).then((answers) => {
379
+ if (answers.length === 0 || answers.some((a) => isBlockedAddress(a.address))) {
380
+ return refuse();
381
+ }
382
+ if (options.all === true) {
383
+ callback(null, answers);
384
+ }
385
+ else {
386
+ const first = answers[0];
387
+ if (first === undefined)
388
+ return refuse();
389
+ callback(null, first.address, first.family);
390
+ }
391
+ }, () => refuse());
392
+ }
393
+ /**
394
+ * GET a URL as text, with the connection pinned to a vetted address.
395
+ *
396
+ * Written on node:http(s) rather than fetch for one reason: fetch does its own
397
+ * DNS resolution and gives no way to intervene, so there is no version of this
398
+ * built on fetch that is not rebindable. Everything fetch was doing here is
399
+ * kept — no redirect is ever followed (http.request does not follow any, so
400
+ * `manual` becomes the default rather than a flag), a hard byte cap, and a
401
+ * timeout that destroys the socket.
402
+ *
403
+ * @returns status and body. A body over MAX_BYTES aborts mid-stream rather than
404
+ * being read and measured afterwards, so a hostile host cannot make us
405
+ * buffer a gigabyte to learn it was too big.
406
+ */
407
+ async function getWithPinnedLookup(url, allowPrivate) {
408
+ const client = url.protocol === 'http:' ? http : https;
409
+ return await new Promise((resolve, reject) => {
410
+ const req = client.request(url, {
411
+ method: 'GET',
412
+ headers: { accept: 'application/json' },
413
+ // Local development talks to a private-range host, which is
414
+ // exactly what the hook exists to refuse — so it is bypassed
415
+ // there, the same
416
+ // way the address rules above are.
417
+ ...(allowPrivate ? {} : { lookup: safeLookup }),
418
+ }, (res) => {
419
+ const chunks = [];
420
+ let size = 0;
421
+ res.on('data', (chunk) => {
422
+ size += chunk.length;
423
+ if (size > MAX_BYTES) {
424
+ res.destroy();
425
+ reject(new OpaqueClientError('client_id document is too large.'));
426
+ return;
427
+ }
428
+ chunks.push(chunk);
429
+ });
430
+ res.on('end', () => {
431
+ resolve({
432
+ status: res.statusCode ?? 0,
433
+ body: Buffer.concat(chunks).toString('utf8'),
434
+ });
435
+ });
436
+ res.on('error', reject);
437
+ });
438
+ /* TWO timers, because setTimeout alone does not bound this request.
439
+ *
440
+ * `req.setTimeout` is an INACTIVITY timer: it fires only when the socket
441
+ * has been quiet for TIMEOUT_MS. Measured 2026-09-12 — a hostile server
442
+ * writing one byte every three seconds held the request open for 18,013 ms
443
+ * and never tripped it; at one byte per three seconds up to the 8 KB cap
444
+ * that is roughly eleven hours, on an endpoint anyone can call with no
445
+ * credential. The comment above said "a timeout that destroys the socket",
446
+ * which was true and insufficient.
447
+ *
448
+ * The deadline is the real bound; the inactivity timer stays because it
449
+ * frees a dead socket sooner than the deadline would. */
450
+ req.setTimeout(TIMEOUT_MS, () => {
451
+ req.destroy(new Error('timeout'));
452
+ });
453
+ const deadline = setTimeout(() => {
454
+ req.destroy(new Error('deadline'));
455
+ }, TIMEOUT_MS);
456
+ const clearDeadline = () => { clearTimeout(deadline); };
457
+ req.on('close', clearDeadline);
458
+ req.on('error', (err) => { clearDeadline(); reject(err); });
459
+ req.end();
460
+ });
461
+ }
462
+ const str = (v) => {
463
+ if (typeof v !== 'string')
464
+ return null;
465
+ const s = v.trim();
466
+ return s === '' ? null : s;
467
+ };
468
+ /**
469
+ * Fetch and validate a client's metadata document.
470
+ *
471
+ * The public entry point, and the ONLY place the opaque/verbatim policy is applied
472
+ * — so there is one answer to "what does a stranger learn from a refusal" instead
473
+ * of one per throw site. Everything below it throws the truth; this decides what
474
+ * leaves the building, and logs the truth either way.
475
+ *
476
+ * @throws ClientError whose message is safe to surface, always.
477
+ */
478
+ export async function fetchClientMetadata(clientId, opts = {}) {
479
+ const startedAt = Date.now();
480
+ try {
481
+ return await fetchClientMetadataInner(clientId, opts);
482
+ }
483
+ catch (err) {
484
+ if (err instanceof OpaqueClientError) {
485
+ /* The real reason, on our side, always — this is what keeps the change
486
+ from costing anyone anything. The client_id is included because
487
+ without it a log of these is unreadable, and it is a URL the caller
488
+ chose to send us, not a secret. */
489
+ log(`client_id refused: ${err.message} — ${clientId}`);
490
+ await padUntil(startedAt, OPAQUE_FLOOR_MS);
491
+ throw new ClientError(OPAQUE_MESSAGE);
492
+ }
493
+ /* Group A and the concurrency cap pass through untouched, and are NOT
494
+ padded: their messages already say everything, so their timing reveals
495
+ nothing further, and padding them would only make a developer's typo
496
+ take two and a half seconds to report. */
497
+ throw err;
498
+ }
499
+ }
500
+ async function fetchClientMetadataInner(clientId, opts = {}) {
501
+ /* The URL is validated BEFORE the cache is consulted, not after.
502
+ *
503
+ * It costs a parse on a cache hit, and it buys the guarantee that a value which
504
+ * would be refused today cannot be served from a cache filled yesterday — if
505
+ * the address rules ever change, or a host that was public becomes internal,
506
+ * the cache must not be the way around them. Cheap, and the alternative is a
507
+ * stale exemption nobody can see. */
508
+ const url = await assertFetchable(clientId, opts.allowPrivate === true);
509
+ const cached = docCache.get(clientId);
510
+ if (cached !== undefined && cached.expiresAt > Date.now()) {
511
+ return cached.info;
512
+ }
513
+ if (cached !== undefined)
514
+ docCache.delete(clientId);
515
+ /* The concurrency ceiling. Checked here rather than inside the fetch so the
516
+ counter cannot be leaked by an early throw between the two. */
517
+ if (inFlight >= MAX_CONCURRENT_FETCHES) {
518
+ throw new ClientError('Too many authorization requests are in progress. Try again shortly.');
519
+ }
520
+ let res;
521
+ inFlight++;
522
+ try {
523
+ res = await getWithPinnedLookup(url, opts.allowPrivate === true);
524
+ }
525
+ catch (err) {
526
+ // A ClientError already carries a message written to be shown; anything
527
+ // else is a socket-level failure and must not describe our network.
528
+ if (err instanceof ClientError)
529
+ throw err;
530
+ throw new OpaqueClientError('client_id document could not be fetched.');
531
+ }
532
+ finally {
533
+ /* finally, so a throw anywhere above cannot leave the counter raised. A
534
+ leaked count here would be permanent: six leaked and this endpoint is
535
+ closed until the process restarts, which is a worse outage than the one
536
+ the cap prevents. */
537
+ inFlight--;
538
+ }
539
+ /* A redirect is a second URL none of the guards above ever saw — the classic
540
+ bypass is a public host that 302s to 169.254. node:http follows nothing on
541
+ its own, so this is a refusal rather than a setting. */
542
+ if (res.status >= 300 && res.status < 400) {
543
+ throw new OpaqueClientError('client_id document must not redirect.');
544
+ }
545
+ if (res.status < 200 || res.status >= 300) {
546
+ throw new OpaqueClientError(`client_id document returned ${res.status}.`);
547
+ }
548
+ // The size cap is enforced mid-stream now (see getWithPinnedLookup), so by
549
+ // here the body is already known to be within it.
550
+ const body = res.body;
551
+ let doc;
552
+ try {
553
+ const parsed = JSON.parse(body);
554
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
555
+ throw new Error('not an object');
556
+ }
557
+ doc = parsed;
558
+ }
559
+ catch {
560
+ throw new OpaqueClientError('client_id document is not a JSON object.');
561
+ }
562
+ /* The document must not claim a DIFFERENT client_id from the URL we fetched it
563
+ from, or host A could serve a document declaring itself to be host B.
564
+
565
+ An ABSENT client_id is tolerated, and the previous comment ("the document
566
+ MUST claim the same client_id") overstated what the code does. Tolerating it
567
+ is safe here and not an oversight: the identity we return below is always
568
+ `clientId`, the URL we fetched — the document's own value is never adopted,
569
+ only compared. So a missing field grants nothing; it just skips a comparison
570
+ that had nothing to compare. */
571
+ const declared = str(doc['client_id']);
572
+ if (declared !== null && declared !== clientId) {
573
+ throw new OpaqueClientError('client_id document declares a different client_id.');
574
+ }
575
+ const uris = Array.isArray(doc['redirect_uris'])
576
+ ? doc['redirect_uris'].map(str).filter((s) => s !== null)
577
+ : [];
578
+ if (uris.length === 0) {
579
+ throw new OpaqueClientError('client_id document declares no redirect_uris.');
580
+ }
581
+ const info = {
582
+ clientId,
583
+ /* The name goes on a consent screen the user reads to decide. Falling back
584
+ * to the host keeps it truthful when the field is missing, rather than
585
+ * showing something friendlier than the document supports.
586
+ *
587
+ * CAPPED, and the cap is the point rather than tidiness: this string is
588
+ * wholly attacker-chosen and unbounded up to the 8 KB document limit. The
589
+ * consent screen escapes it, so there is no injection — but ~8 KB of text
590
+ * in the heading pushes the "verified as <host>" line, the one thing that
591
+ * contradicts a name like "Claude", off the visible page. A phishing client
592
+ * gets 60 characters, next to a host it cannot forge. */
593
+ clientName: (str(doc['client_name']) ?? url.host).slice(0, MAX_CLIENT_NAME),
594
+ redirectUris: uris,
595
+ clientUri: str(doc['client_uri']),
596
+ logoUri: str(doc['logo_uri']),
597
+ };
598
+ /* Cached only now, at the end, so nothing that threw above can be remembered.
599
+ Eviction is oldest-first via Map insertion order — not a true LRU, and it
600
+ does not need to be: the cap exists to bound memory against rotating urls,
601
+ and the handful of real client_ids are refreshed on every hit anyway. */
602
+ if (docCache.size >= DOC_CACHE_MAX) {
603
+ const oldest = docCache.keys().next();
604
+ if (oldest.done !== true)
605
+ docCache.delete(oldest.value);
606
+ }
607
+ docCache.set(clientId, { info, expiresAt: Date.now() + DOC_CACHE_TTL_MS });
608
+ return info;
609
+ }
610
+ /**
611
+ * Is this redirect_uri one the client declared?
612
+ *
613
+ * Exact string comparison, which is what OAuth 2.1 requires and what makes the
614
+ * check worth having: any normalisation (trailing slash, case, added query) is a
615
+ * place where "close enough" sends the authorization code somewhere the client
616
+ * never listed.
617
+ */
618
+ export function redirectUriAllowed(client, redirectUri) {
619
+ return client.redirectUris.includes(redirectUri);
620
+ }
621
+ //# sourceMappingURL=clients.js.map