@@ -26,8 +26,10 @@ export type SessionExportsManagerConfig = Pick<
2626 "exportPath" | "exportTimeoutMs" | "exportCleanupIntervalMs"
2727> ;
2828
29+ const MAX_LOCK_RETRIES = 10 ;
30+
2931export 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 ?.( ) ;
0 commit comments