Skip to content

Commit 84cdbff

Browse files
alistair3149claudetrial
authored
Close the connection when an oversized fetch is refused (#553)
* Close the connection when an oversized fetch is refused Fixes #545 A response body refused for exceeding a byte cap was left unread, and node-fetch holds the connection until the body stream is either consumed or destroyed. The declared-content-length check refuses before anything subscribes to the stream, so every over-cap source URL stranded one socket for as long as the server ran. The streamed-total check already disposed of the body, because leaving the read loop destroys the stream on the way out; only the declared-length path leaked. Measured against a loopback server: the refused connection stays open indefinitely, and destroying the body closes it at once. `readCapped` now destroys the body on any refusal, so the invariant holds at both cap checks and for both callers: the `*-from-url` upload tools and the capped SPARQL read behind `wikibase-query`. Aborting the `AbortController` that `fetchFileBytes` owns would close its own connection too, but it is neither necessary nor available to `postForm`, whose signal belongs to the caller. The regression test drives the real client against a loopback server, since the rest of the suite mocks node-fetch and cannot see a socket, and asserts that the server's end of the connection goes away. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Close two false-pass routes in the connection-disposal tests The test server answered any unrecognised path with the chunked over-cap response, so a mistyped route in the streamed test still got a refusal to assert on and passed while exercising nothing it named. Unknown paths now answer 404, which the error-type assertion rejects. The socket the assertion reads is whichever one the server last served. It is now cleared before each test, and an assertion with no socket to observe fails rather than reporting an earlier test's closed connection as its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Tighten the changelog entry for the refused-fetch fix Lead with the three tools a reader scans for, and drop the mechanism the entry opened with. The old wording also hung the wikibase-query case off MCP_UPLOAD_MAX_BYTES, which governs only the upload tools, and counted the leak per URL rather than per call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Narrow the response body by instanceof rather than asserting it node-fetch declares the body as the wider NodeJS.ReadableStream, which has no destroy(). Asserting past that means a body that is not a Node stream throws a TypeError from inside the catch, replacing the size-refusal it was reporting — and a TypeError does not rescue to wiki-side copy-upload, so a routine refusal would surface as an error. Narrowing by instanceof leaves such a body alone instead. Also narrows the changelog entry to the two released tools and to the setting that governs them. The capped SPARQL read passes a timeout signal, so its connection was freed after a minute rather than held for the life of the process, and both it and the tool that makes it arrive unreleased in this same cycle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: trial <a@b.c>
1 parent e0f56c7 commit 84cdbff

3 files changed

Lines changed: 181 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
2222
### Fixed
2323

2424
- `update-page` no longer advertises itself as idempotent: in `mode='append'` and `mode='prepend'` it never was, so a client replaying a call whose result never arrived adds the content a second time. A replace resends the same content rather than adding to it.
25+
- `upload-file-from-url` and `update-file-from-url` no longer leak a connection when they refuse a source URL whose declared size is over `MCP_UPLOAD_MAX_BYTES`. Each refused call held one connection open for as long as the server ran.
2526

2627
## [0.16.0] - 2026-07-30
2728

src/transport/httpFetch.ts

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Readable } from 'node:stream';
12
import fetch, { Response, FetchError } from 'node-fetch';
23
import { USER_AGENT } from '../runtime/constants.ts';
34
import { isErrnoException } from '../errors/isErrnoException.ts';
@@ -193,30 +194,55 @@ export async function postForm(
193194
* Reads a response body into memory under a byte cap, checked twice: the
194195
* declared content-length rejects an over-cap body before a byte is read, and
195196
* the running total catches one that under-declares or declares nothing.
197+
*
198+
* Either refusal disposes of the body first: the connection is held for as long
199+
* as the body stream is neither consumed nor destroyed, so a body left unread
200+
* strands a socket until something aborts the request — which, on the upload
201+
* path, nothing does.
196202
*/
197203
async function readCapped(
198204
response: Response,
199205
maxBytes: number,
200206
limitName?: string,
201207
): Promise<Buffer> {
202-
const declared = Number(response.headers.get('content-length'));
203-
if (Number.isFinite(declared) && declared > maxBytes) {
204-
throw new FileTooLargeError(declared, maxBytes, limitName);
205-
}
206-
const chunks: Buffer[] = [];
207-
let total = 0;
208-
if (response.body !== null) {
209-
// node-fetch v3 exposes the body as a Node Readable (async-iterable).
210-
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- node-fetch v3 body is always a Node.js Readable; narrowing to AsyncIterable<Buffer> is safe at this boundary
211-
for await (const chunk of response.body as AsyncIterable<Buffer>) {
212-
total += chunk.length;
213-
if (total > maxBytes) {
214-
throw new FileTooLargeError(total, maxBytes, limitName);
208+
try {
209+
const declared = Number(response.headers.get('content-length'));
210+
if (Number.isFinite(declared) && declared > maxBytes) {
211+
throw new FileTooLargeError(declared, maxBytes, limitName);
212+
}
213+
const chunks: Buffer[] = [];
214+
let total = 0;
215+
if (response.body !== null) {
216+
// node-fetch v3 exposes the body as a Node Readable (async-iterable).
217+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- node-fetch v3 body is always a Node.js Readable; narrowing to AsyncIterable<Buffer> is safe at this boundary
218+
for await (const chunk of response.body as AsyncIterable<Buffer>) {
219+
total += chunk.length;
220+
if (total > maxBytes) {
221+
throw new FileTooLargeError(total, maxBytes, limitName);
222+
}
223+
chunks.push(chunk);
215224
}
216-
chunks.push(chunk);
217225
}
226+
return Buffer.concat(chunks);
227+
} catch (error) {
228+
destroyBody(response);
229+
throw error;
230+
}
231+
}
232+
233+
/**
234+
* Releases the connection behind a response body that will not be read. Leaving
235+
* the read loop above destroys the stream on its way out, but a refusal that
236+
* never subscribes to it has to do this itself.
237+
*/
238+
function destroyBody(response: Response): void {
239+
// node-fetch v3 hands back a Node Readable, but declares it as the wider
240+
// NodeJS.ReadableStream, which has no destroy(). Narrowing by instanceof
241+
// rather than asserting means a body that is not a Node stream is left
242+
// alone instead of throwing over the refusal being reported.
243+
if (response.body instanceof Readable) {
244+
response.body.destroy();
218245
}
219-
return Buffer.concat(chunks);
220246
}
221247

222248
export async function fetchPageHtml(url: string): Promise<string | null> {
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
2+
3+
/**
4+
* What an over-cap refusal does to the connection is only visible against a
5+
* real server: the rest of the httpFetch suite mocks node-fetch wholesale, so
6+
* it never sees a socket. node-fetch holds the connection until the response
7+
* body is consumed or destroyed, so a refusal that leaves the body untouched
8+
* strands the socket. Only the SSRF guard is stubbed, since a loopback
9+
* destination is what a local test server is.
10+
*/
11+
vi.mock('../../src/transport/ssrfGuard.ts', async () => {
12+
const actual = await vi.importActual<typeof import('../../src/transport/ssrfGuard.ts')>(
13+
'../../src/transport/ssrfGuard.ts',
14+
);
15+
return {
16+
...actual,
17+
assertPublicDestination: vi.fn(async () => [{ address: '127.0.0.1', family: 4 }]),
18+
buildPinnedAgent: vi.fn(() => undefined),
19+
};
20+
});
21+
22+
import { createServer, type Server } from 'node:http';
23+
import type { Socket } from 'node:net';
24+
import { fetchFileBytes, postForm, FileTooLargeError } from '../../src/transport/httpFetch.ts';
25+
26+
let server: Server;
27+
let origin: string;
28+
let openSockets: Set<Socket>;
29+
let servingSocket: Socket | undefined;
30+
31+
beforeAll(async () => {
32+
openSockets = new Set();
33+
server = createServer((req, res) => {
34+
servingSocket = req.socket;
35+
if (req.url === '/small') {
36+
res.writeHead(200, { 'Content-Length': '7' });
37+
res.end('results');
38+
return;
39+
}
40+
// The over-cap routes send their headers and then stall, never ending the
41+
// response. A client that drops such a body without destroying it holds
42+
// the socket open for as long as it lives, which is what these tests
43+
// measure; one that completes normally would return the socket to the
44+
// keep-alive pool and hide the difference.
45+
if (req.url === '/declares-too-much') {
46+
res.writeHead(200, { 'Content-Length': String(50 * 1024 * 1024) });
47+
res.write('x');
48+
return;
49+
}
50+
if (req.url === '/streams-too-much') {
51+
res.writeHead(200, { 'Transfer-Encoding': 'chunked' });
52+
res.write('x'.repeat(64));
53+
return;
54+
}
55+
// Anything else is a test asking for a route that does not exist. Refusing
56+
// it keeps a mistyped path from quietly getting a different route's answer.
57+
res.writeHead(404);
58+
res.end();
59+
});
60+
// A client destroying its socket reaches the server as a reset.
61+
server.on('connection', (socket) => {
62+
openSockets.add(socket);
63+
socket.on('error', () => {});
64+
socket.on('close', () => openSockets.delete(socket));
65+
});
66+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
67+
const address = server.address();
68+
origin = `http://127.0.0.1:${typeof address === 'object' && address !== null ? address.port : 0}`;
69+
});
70+
71+
beforeEach(() => {
72+
servingSocket = undefined;
73+
});
74+
75+
afterAll(async () => {
76+
for (const socket of openSockets) {
77+
socket.destroy();
78+
}
79+
await new Promise<void>((resolve) => server.close(() => resolve()));
80+
});
81+
82+
/**
83+
* Whether the server's end of the connection goes away once the client is done
84+
* with it. No recorded socket means the request never reached the server, so
85+
* there is nothing to observe: say so, rather than read a socket an earlier test
86+
* left behind and report its closure as this one's.
87+
*/
88+
async function connectionClosed(socket: Socket | undefined, withinMs = 1000): Promise<boolean> {
89+
if (socket === undefined) {
90+
throw new Error('The server served no request, so no connection was observed.');
91+
}
92+
if (socket.destroyed) {
93+
return true;
94+
}
95+
return await new Promise<boolean>((resolve) => {
96+
const timer = setTimeout(() => resolve(false), withinMs);
97+
socket.once('close', () => {
98+
clearTimeout(timer);
99+
resolve(true);
100+
});
101+
});
102+
}
103+
104+
describe('an over-cap body refused against a real server', () => {
105+
it('closes the connection when the declared content-length is over the cap', async () => {
106+
const failure = await fetchFileBytes(`${origin}/declares-too-much`, {
107+
maxBytes: 1024,
108+
}).catch((error: unknown) => error);
109+
110+
expect(failure).toBeInstanceOf(FileTooLargeError);
111+
expect(await connectionClosed(servingSocket)).toBe(true);
112+
});
113+
114+
it('closes the connection when the streamed body passes the cap', async () => {
115+
const failure = await fetchFileBytes(`${origin}/streams-too-much`, { maxBytes: 10 }).catch(
116+
(error: unknown) => error,
117+
);
118+
119+
expect(failure).toBeInstanceOf(FileTooLargeError);
120+
expect(await connectionClosed(servingSocket)).toBe(true);
121+
});
122+
123+
it('closes the connection when a capped postForm refuses the body', async () => {
124+
const failure = await postForm(
125+
`${origin}/declares-too-much`,
126+
{ query: 'SELECT ?x WHERE {}' },
127+
{ maxBytes: 1024 },
128+
).catch((error: unknown) => error);
129+
130+
expect(failure).toBeInstanceOf(FileTooLargeError);
131+
expect(await connectionClosed(servingSocket)).toBe(true);
132+
});
133+
134+
it('still returns a body that fits the cap', async () => {
135+
const body = await postForm(`${origin}/small`, { query: 'x' }, { maxBytes: 1024 });
136+
137+
expect(body).toBe('results');
138+
});
139+
});

0 commit comments

Comments
 (0)