@@ -26,8 +26,10 @@ export type SessionExportsManagerConfig = Pick<
26
26
"exportPath" | "exportTimeoutMs" | "exportCleanupIntervalMs"
27
27
> ;
28
28
29
+ const MAX_LOCK_RETRIES = 10 ;
30
+
29
31
export class SessionExportsManager {
30
- private mutableExports : Export [ ] = [ ] ;
32
+ private availableExports : Export [ ] = [ ] ;
31
33
private exportsCleanupInterval : NodeJS . Timeout ;
32
34
private exportsCleanupInProgress : boolean = false ;
33
35
@@ -56,9 +58,7 @@ export class SessionExportsManager {
56
58
}
57
59
58
60
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 ) ;
62
62
return `exported-data://${ nameWithExtension } ` ;
63
63
}
64
64
@@ -73,9 +73,7 @@ export class SessionExportsManager {
73
73
}
74
74
75
75
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 ) ;
79
77
return path . join ( exportsDirectoryPath , exportNameWithExtension ) ;
80
78
}
81
79
@@ -84,13 +82,14 @@ export class SessionExportsManager {
84
82
// by not acquiring a lock on read. That is because this we require this
85
83
// interface to be fast and just accurate enough for MCP completions
86
84
// API.
87
- return this . mutableExports . filter ( ( { createdAt } ) => {
85
+ return this . availableExports . filter ( ( { createdAt } ) => {
88
86
return ! this . isExportExpired ( createdAt ) ;
89
87
} ) ;
90
88
}
91
89
92
90
public async readExport ( exportNameWithExtension : string ) : Promise < string > {
93
91
try {
92
+ this . validateExportName ( exportNameWithExtension ) ;
94
93
const exportsDirectoryPath = await this . ensureExportsDirectory ( ) ;
95
94
const exportFilePath = this . exportFilePath ( exportsDirectoryPath , exportNameWithExtension ) ;
96
95
if ( await this . isExportFileExpired ( exportFilePath ) ) {
@@ -118,7 +117,9 @@ export class SessionExportsManager {
118
117
jsonExportFormat : JSONExportFormat ;
119
118
} ) : Promise < void > {
120
119
try {
121
- const exportNameWithExtension = this . withExtension ( exportName , "json" ) ;
120
+ const exportNameWithExtension = this . ensureExtension ( exportName , "json" ) ;
121
+ this . validateExportName ( exportNameWithExtension ) ;
122
+
122
123
const inputStream = input . stream ( ) ;
123
124
const ejsonDocStream = this . docToEJSONStream ( this . getEJSONOptionsForFormat ( jsonExportFormat ) ) ;
124
125
await this . withExportsLock < void > ( async ( exportsDirectoryPath ) => {
@@ -146,8 +147,8 @@ export class SessionExportsManager {
146
147
void input . close ( ) ;
147
148
if ( pipeSuccessful ) {
148
149
const resourceURI = this . exportNameToResourceURI ( exportNameWithExtension ) ;
149
- this . mutableExports = [
150
- ...this . mutableExports ,
150
+ this . availableExports = [
151
+ ...this . availableExports ,
151
152
{
152
153
createdAt : ( await fs . stat ( exportFilePath ) ) . birthtimeMs ,
153
154
name : exportNameWithExtension ,
@@ -216,7 +217,7 @@ export class SessionExportsManager {
216
217
const exportPath = this . exportFilePath ( exportsDirectoryPath , exportName ) ;
217
218
if ( await this . isExportFileExpired ( exportPath ) ) {
218
219
await fs . unlink ( exportPath ) ;
219
- this . mutableExports = this . mutableExports . filter ( ( { name } ) => name !== exportName ) ;
220
+ this . availableExports = this . availableExports . filter ( ( { name } ) => name !== exportName ) ;
220
221
this . session . emit ( "export-expired" , this . exportNameToResourceURI ( exportName ) ) ;
221
222
}
222
223
}
@@ -232,6 +233,19 @@ export class SessionExportsManager {
232
233
}
233
234
}
234
235
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
+
235
249
/**
236
250
* Small utility to centrally determine if an export is expired or not */
237
251
private async isExportFileExpired ( exportFilePath : string ) : Promise < boolean > {
@@ -252,7 +266,7 @@ export class SessionExportsManager {
252
266
253
267
/**
254
268
* Ensures the path ends with the provided extension */
255
- private withExtension ( pathOrName : string , extension : string ) : string {
269
+ private ensureExtension ( pathOrName : string , extension : string ) : string {
256
270
const extWithDot = extension . startsWith ( "." ) ? extension : `.${ extension } ` ;
257
271
if ( path . extname ( pathOrName ) === extWithDot ) {
258
272
return pathOrName ;
@@ -274,7 +288,7 @@ export class SessionExportsManager {
274
288
let releaseLock : ( ( ) => Promise < void > ) | undefined ;
275
289
const exportsDirectoryPath = await this . ensureExportsDirectory ( ) ;
276
290
try {
277
- releaseLock = await lock ( exportsDirectoryPath , { retries : 10 } ) ;
291
+ releaseLock = await lock ( exportsDirectoryPath , { retries : MAX_LOCK_RETRIES } ) ;
278
292
return await callback ( exportsDirectoryPath ) ;
279
293
} finally {
280
294
await releaseLock ?.( ) ;
0 commit comments