@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.
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/backend.d.ts +133 -0
- package/dist/backend.js +455 -0
- package/dist/config.d.ts +85 -0
- package/dist/config.js +207 -0
- package/dist/heartbeat.d.ts +11 -0
- package/dist/heartbeat.js +70 -0
- package/dist/http.d.ts +44 -0
- package/dist/http.js +885 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.js +661 -0
- package/dist/log.d.ts +19 -0
- package/dist/log.js +22 -0
- package/dist/map/b2c.d.ts +139 -0
- package/dist/map/b2c.js +236 -0
- package/dist/media-duration.d.ts +33 -0
- package/dist/media-duration.js +137 -0
- package/dist/oauth/authorize.d.ts +61 -0
- package/dist/oauth/authorize.js +275 -0
- package/dist/oauth/clients.d.ts +137 -0
- package/dist/oauth/clients.js +621 -0
- package/dist/oauth/ratelimit.d.ts +92 -0
- package/dist/oauth/ratelimit.js +116 -0
- package/dist/oauth/store.d.ts +135 -0
- package/dist/oauth/store.js +277 -0
- package/dist/oauth/token.d.ts +29 -0
- package/dist/oauth/token.js +165 -0
- package/dist/schema.d.ts +147 -0
- package/dist/schema.js +629 -0
- package/dist/tools/account.d.ts +28 -0
- package/dist/tools/account.js +130 -0
- package/dist/tools/generation.d.ts +102 -0
- package/dist/tools/generation.js +1194 -0
- package/dist/tools/info.d.ts +50 -0
- package/dist/tools/info.js +95 -0
- package/dist/ui/generation-card.html +869 -0
- package/package.json +79 -0
- package/server.json +36 -0
|
@@ -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
|