Skip to content

Commit 815cefc

Browse files
chore: handle possible path traversals
1 parent f215e31 commit 815cefc

File tree

3 files changed

+39
-19
lines changed

3 files changed

+39
-19
lines changed

src/common/logger.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export const LogId = {
5656
exportCloseError: mongoLogId(1_007_005),
5757
exportedDataListError: mongoLogId(1_007_006),
5858
exportedDataAutoCompleteError: mongoLogId(1_007_007),
59+
exportedDataSessionUninitialized: mongoLogId(1_007_008),
5960
} as const;
6061

6162
export abstract class LoggerBase {

src/common/sessionExportsManager.ts

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@ export type SessionExportsManagerConfig = Pick<
2626
"exportPath" | "exportTimeoutMs" | "exportCleanupIntervalMs"
2727
>;
2828

29+
const MAX_LOCK_RETRIES = 10;
30+
2931
export class SessionExportsManager {
30-
private mutableExports: Export[] = [];
32+
private availableExports: Export[] = [];
3133
private exportsCleanupInterval: NodeJS.Timeout;
3234
private exportsCleanupInProgress: boolean = false;
3335

@@ -56,9 +58,7 @@ export class SessionExportsManager {
5658
}
5759

5860
public exportNameToResourceURI(nameWithExtension: string): string {
59-
if (!path.extname(nameWithExtension)) {
60-
throw new Error("Provided export name has no extension");
61-
}
61+
this.validateExportName(nameWithExtension);
6262
return `exported-data://${nameWithExtension}`;
6363
}
6464

@@ -73,9 +73,7 @@ export class SessionExportsManager {
7373
}
7474

7575
public exportFilePath(exportsDirectoryPath: string, exportNameWithExtension: string): string {
76-
if (!path.extname(exportNameWithExtension)) {
77-
throw new Error("Provided export name has no extension");
78-
}
76+
this.validateExportName(exportNameWithExtension);
7977
return path.join(exportsDirectoryPath, exportNameWithExtension);
8078
}
8179

@@ -84,13 +82,14 @@ export class SessionExportsManager {
8482
// by not acquiring a lock on read. That is because this we require this
8583
// interface to be fast and just accurate enough for MCP completions
8684
// API.
87-
return this.mutableExports.filter(({ createdAt }) => {
85+
return this.availableExports.filter(({ createdAt }) => {
8886
return !this.isExportExpired(createdAt);
8987
});
9088
}
9189

9290
public async readExport(exportNameWithExtension: string): Promise<string> {
9391
try {
92+
this.validateExportName(exportNameWithExtension);
9493
const exportsDirectoryPath = await this.ensureExportsDirectory();
9594
const exportFilePath = this.exportFilePath(exportsDirectoryPath, exportNameWithExtension);
9695
if (await this.isExportFileExpired(exportFilePath)) {
@@ -118,7 +117,9 @@ export class SessionExportsManager {
118117
jsonExportFormat: JSONExportFormat;
119118
}): Promise<void> {
120119
try {
121-
const exportNameWithExtension = this.withExtension(exportName, "json");
120+
const exportNameWithExtension = this.ensureExtension(exportName, "json");
121+
this.validateExportName(exportNameWithExtension);
122+
122123
const inputStream = input.stream();
123124
const ejsonDocStream = this.docToEJSONStream(this.getEJSONOptionsForFormat(jsonExportFormat));
124125
await this.withExportsLock<void>(async (exportsDirectoryPath) => {
@@ -146,8 +147,8 @@ export class SessionExportsManager {
146147
void input.close();
147148
if (pipeSuccessful) {
148149
const resourceURI = this.exportNameToResourceURI(exportNameWithExtension);
149-
this.mutableExports = [
150-
...this.mutableExports,
150+
this.availableExports = [
151+
...this.availableExports,
151152
{
152153
createdAt: (await fs.stat(exportFilePath)).birthtimeMs,
153154
name: exportNameWithExtension,
@@ -216,7 +217,7 @@ export class SessionExportsManager {
216217
const exportPath = this.exportFilePath(exportsDirectoryPath, exportName);
217218
if (await this.isExportFileExpired(exportPath)) {
218219
await fs.unlink(exportPath);
219-
this.mutableExports = this.mutableExports.filter(({ name }) => name !== exportName);
220+
this.availableExports = this.availableExports.filter(({ name }) => name !== exportName);
220221
this.session.emit("export-expired", this.exportNameToResourceURI(exportName));
221222
}
222223
}
@@ -232,6 +233,19 @@ export class SessionExportsManager {
232233
}
233234
}
234235

236+
/**
237+
* Small utility to validate provided export name for path traversal or no
238+
* extension */
239+
private validateExportName(nameWithExtension: string): void {
240+
if (!path.extname(nameWithExtension)) {
241+
throw new Error("Provided export name has no extension");
242+
}
243+
244+
if (nameWithExtension.includes("..") || nameWithExtension.includes("/") || nameWithExtension.includes("\\")) {
245+
throw new Error("Invalid export name: path traversal hinted");
246+
}
247+
}
248+
235249
/**
236250
* Small utility to centrally determine if an export is expired or not */
237251
private async isExportFileExpired(exportFilePath: string): Promise<boolean> {
@@ -252,7 +266,7 @@ export class SessionExportsManager {
252266

253267
/**
254268
* Ensures the path ends with the provided extension */
255-
private withExtension(pathOrName: string, extension: string): string {
269+
private ensureExtension(pathOrName: string, extension: string): string {
256270
const extWithDot = extension.startsWith(".") ? extension : `.${extension}`;
257271
if (path.extname(pathOrName) === extWithDot) {
258272
return pathOrName;
@@ -274,7 +288,7 @@ export class SessionExportsManager {
274288
let releaseLock: (() => Promise<void>) | undefined;
275289
const exportsDirectoryPath = await this.ensureExportsDirectory();
276290
try {
277-
releaseLock = await lock(exportsDirectoryPath, { retries: 10 });
291+
releaseLock = await lock(exportsDirectoryPath, { retries: MAX_LOCK_RETRIES });
278292
return await callback(exportsDirectoryPath);
279293
} finally {
280294
await releaseLock?.();

src/resources/common/exportedData.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ export class ExportedData {
1818
void this.server.mcpServer.server.sendResourceUpdated({
1919
uri,
2020
});
21-
this.server.mcpServer.sendResourceListChanged();
2221
});
2322
this.server.session.on("export-expired", () => {
2423
this.server.mcpServer.sendResourceListChanged();
@@ -52,8 +51,11 @@ export class ExportedData {
5251
if (!sessionId) {
5352
// Note that we don't throw error here because this is a
5453
// non-critical path and safe to return the most harmless value.
55-
56-
// TODO: log warn here
54+
logger.warning(
55+
LogId.exportedDataSessionUninitialized,
56+
"In ListResourcesCallback of exported-data resource",
57+
"Session not initialized"
58+
);
5759
return { resources: [] };
5860
}
5961

@@ -84,8 +86,11 @@ export class ExportedData {
8486
if (!sessionId) {
8587
// Note that we don't throw error here because this is a
8688
// non-critical path and safe to return the most harmless value.
87-
88-
// TODO: log warn here
89+
logger.warning(
90+
LogId.exportedDataSessionUninitialized,
91+
"In CompleteResourceTemplateCallback of exported-data resource",
92+
"Session not initialized"
93+
);
8994
return [];
9095
}
9196

0 commit comments

Comments
 (0)