Skip to content

Commit 5097204

Browse files
committed
fix: add untrusted data wrapper to the export resource
1 parent d6b84c7 commit 5097204

File tree

3 files changed

+68
-45
lines changed

3 files changed

+68
-45
lines changed

src/common/exportsManager.ts

Lines changed: 42 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ interface CommonExportData {
2727
interface ReadyExport extends CommonExportData {
2828
exportStatus: "ready";
2929
exportCreatedAt: number;
30+
docsTransformed: number;
3031
}
3132

3233
interface InProgressExport extends CommonExportData {
@@ -124,7 +125,7 @@ export class ExportsManager extends EventEmitter<ExportsManagerEvents> {
124125
}
125126
}
126127

127-
public async readExport(exportName: string): Promise<string> {
128+
public async readExport(exportName: string): Promise<{ content: string; docsTransformed: number }> {
128129
try {
129130
this.assertIsNotShuttingDown();
130131
exportName = decodeAndNormalize(exportName);
@@ -137,9 +138,12 @@ export class ExportsManager extends EventEmitter<ExportsManagerEvents> {
137138
throw new Error("Requested export is still being generated. Try again later.");
138139
}
139140

140-
const { exportPath } = exportHandle;
141+
const { exportPath, docsTransformed } = exportHandle;
141142

142-
return fs.readFile(exportPath, { encoding: "utf8", signal: this.shutdownController.signal });
143+
return {
144+
content: await fs.readFile(exportPath, { encoding: "utf8", signal: this.shutdownController.signal }),
145+
docsTransformed,
146+
};
143147
} catch (error) {
144148
this.logger.error({
145149
id: LogId.exportReadError,
@@ -202,17 +206,15 @@ export class ExportsManager extends EventEmitter<ExportsManagerEvents> {
202206
}): Promise<void> {
203207
try {
204208
let pipeSuccessful = false;
209+
let docsTransformed = 0;
205210
try {
206211
await fs.mkdir(this.exportsDirectoryPath, { recursive: true });
207212
const outputStream = createWriteStream(inProgressExport.exportPath);
208-
await pipeline(
209-
[
210-
input.stream(),
211-
this.docToEJSONStream(this.getEJSONOptionsForFormat(jsonExportFormat)),
212-
outputStream,
213-
],
214-
{ signal: this.shutdownController.signal }
215-
);
213+
const ejsonTransofrm = this.docToEJSONStream(this.getEJSONOptionsForFormat(jsonExportFormat));
214+
await pipeline([input.stream(), ejsonTransofrm, outputStream], {
215+
signal: this.shutdownController.signal,
216+
});
217+
docsTransformed = ejsonTransofrm.docsTransformed;
216218
pipeSuccessful = true;
217219
} catch (error) {
218220
// If the pipeline errors out then we might end up with
@@ -231,6 +233,7 @@ export class ExportsManager extends EventEmitter<ExportsManagerEvents> {
231233
...inProgressExport,
232234
exportCreatedAt: Date.now(),
233235
exportStatus: "ready",
236+
docsTransformed,
234237
};
235238
this.emit("export-available", inProgressExport.exportURI);
236239
}
@@ -256,33 +259,39 @@ export class ExportsManager extends EventEmitter<ExportsManagerEvents> {
256259
}
257260
}
258261

259-
private docToEJSONStream(ejsonOptions: EJSONOptions | undefined): Transform {
262+
private docToEJSONStream(ejsonOptions: EJSONOptions | undefined): Transform & { docsTransformed: number } {
260263
let docsTransformed = 0;
261-
return new Transform({
262-
objectMode: true,
263-
transform(chunk: unknown, encoding, callback): void {
264-
try {
265-
const doc = EJSON.stringify(chunk, undefined, undefined, ejsonOptions);
264+
const result = Object.assign(
265+
new Transform({
266+
objectMode: true,
267+
transform(chunk: unknown, encoding, callback): void {
268+
try {
269+
const doc = EJSON.stringify(chunk, undefined, undefined, ejsonOptions);
270+
if (docsTransformed === 0) {
271+
this.push("[" + doc);
272+
} else {
273+
this.push(",\n" + doc);
274+
}
275+
docsTransformed++;
276+
callback();
277+
} catch (err) {
278+
callback(err as Error);
279+
}
280+
},
281+
flush(callback): void {
266282
if (docsTransformed === 0) {
267-
this.push("[" + doc);
283+
this.push("[]");
268284
} else {
269-
this.push(",\n" + doc);
285+
this.push("]");
270286
}
271-
docsTransformed++;
287+
result.docsTransformed = docsTransformed;
272288
callback();
273-
} catch (err) {
274-
callback(err as Error);
275-
}
276-
},
277-
flush(callback): void {
278-
if (docsTransformed === 0) {
279-
this.push("[]");
280-
} else {
281-
this.push("]");
282-
}
283-
callback();
284-
},
285-
});
289+
},
290+
}),
291+
{ docsTransformed }
292+
);
293+
294+
return result;
286295
}
287296

288297
private async cleanupExpiredExports(): Promise<void> {

src/resources/common/exportedData.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
77
import type { Server } from "../../server.js";
88
import { LogId } from "../../common/logger.js";
99
import type { Session } from "../../common/session.js";
10+
import { formatUntrustedData } from "../../tools/tool.js";
1011

1112
export class ExportedData {
1213
private readonly name = "exported-data";
@@ -95,13 +96,17 @@ export class ExportedData {
9596
throw new Error("Cannot retrieve exported data, exportName not provided.");
9697
}
9798

98-
const content = await this.session.exportsManager.readExport(exportName);
99+
const { content, docsTransformed } = await this.session.exportsManager.readExport(exportName);
100+
101+
const text = formatUntrustedData(`The exported data contains ${docsTransformed} documents.`, content)
102+
.map((t) => t.text)
103+
.join("\n");
99104

100105
return {
101106
contents: [
102107
{
103108
uri: url.href,
104-
text: content,
109+
text,
105110
mimeType: "application/json",
106111
},
107112
],

tests/integration/resources/exportedData.test.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import path from "path";
22
import fs from "fs/promises";
3-
import { Long } from "bson";
3+
import type { ObjectId } from "bson";
4+
import { EJSON, Long } from "bson";
45
import { describe, expect, it, beforeEach, afterAll } from "vitest";
56
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
6-
import { defaultTestConfig, resourceChangedNotification, timeout } from "../helpers.js";
7+
import { defaultTestConfig, getDataFromUntrustedContent, resourceChangedNotification, timeout } from "../helpers.js";
78
import { describeWithMongoDB } from "../tools/mongodb/mongodbHelpers.js";
89
import { contentWithResourceURILink } from "../tools/mongodb/read/export.test.js";
910
import type { UserConfig } from "../../../src/lib.js";
@@ -15,18 +16,17 @@ const userConfig: UserConfig = {
1516
exportCleanupIntervalMs: 300,
1617
};
1718

19+
const docs = [
20+
{ name: "foo", longNumber: new Long(1234) },
21+
{ name: "bar", bigInt: new Long(123412341234) },
22+
];
23+
1824
describeWithMongoDB(
1925
"exported-data resource",
2026
(integration) => {
2127
beforeEach(async () => {
2228
const mongoClient = integration.mongoClient();
23-
await mongoClient
24-
.db("db")
25-
.collection("coll")
26-
.insertMany([
27-
{ name: "foo", longNumber: new Long(1234) },
28-
{ name: "bar", bigInt: new Long(123412341234) },
29-
]);
29+
await mongoClient.db("db").collection("coll").insertMany(docs);
3030
});
3131

3232
afterAll(async () => {
@@ -125,7 +125,16 @@ describeWithMongoDB(
125125
});
126126
expect(response.isError).toBeFalsy();
127127
expect(response.contents[0]?.mimeType).toEqual("application/json");
128-
expect(response.contents[0]?.text).toContain("foo");
128+
129+
expect(response.contents[0]?.text).toContain(`The exported data contains ${docs.length} documents.`);
130+
expect(response.contents[0]?.text).toContain("<untrusted-user-data");
131+
const exportContent = getDataFromUntrustedContent((response.contents[0]?.text as string) || "");
132+
const exportedDocs = EJSON.parse(exportContent) as { name: string; _id: ObjectId }[];
133+
const expectedDocs = docs as unknown as { name: string; _id: ObjectId }[];
134+
expect(exportedDocs[0]?.name).toEqual(expectedDocs[0]?.name);
135+
expect(exportedDocs[0]?._id).toEqual(expectedDocs[0]?._id);
136+
expect(exportedDocs[1]?.name).toEqual(expectedDocs[1]?.name);
137+
expect(exportedDocs[1]?._id).toEqual(expectedDocs[1]?._id);
129138
});
130139

131140
it("should be able to autocomplete the resource", async () => {

0 commit comments

Comments
 (0)