@@ -23,7 +23,7 @@ import {
2323 unlinkSync ,
2424} from 'fs' ;
2525import { homedir } from 'os' ;
26- import { join } from 'path' ;
26+ import { join , resolve } from 'path' ;
2727import { lockSync , unlockSync } from 'proper-lockfile' ;
2828import type { Thread , ThreadIndex } from './types.js' ;
2929
@@ -105,6 +105,15 @@ function atomicWrite(filePath: string, content: string): void {
105105 chmodSync ( filePath , 0o600 ) ;
106106}
107107
108+ function safeThreadPath ( id : string ) : string {
109+ const threadPath = join ( THREADS_DIR , `${ id } .json` ) ;
110+ // Defense-in-depth: ensure resolved path stays under THREADS_DIR
111+ if ( ! resolve ( threadPath ) . startsWith ( resolve ( THREADS_DIR ) ) ) {
112+ throw new Error ( 'Invalid thread ID: path traversal detected' ) ;
113+ }
114+ return threadPath ;
115+ }
116+
108117function extractMeta ( thread : Thread ) : ThreadMeta {
109118 return {
110119 summary : thread . summary ,
@@ -150,7 +159,7 @@ function migrateToPerThread(): void {
150159 const meta : MetaIndex = { version : 2 , threads : { } } ;
151160
152161 for ( const [ id , thread ] of Object . entries ( oldIndex ) ) {
153- atomicWrite ( join ( THREADS_DIR , ` ${ id } .json` ) , JSON . stringify ( thread , null , 2 ) ) ;
162+ atomicWrite ( safeThreadPath ( id ) , JSON . stringify ( thread , null , 2 ) ) ;
154163 meta . threads [ id ] = extractMeta ( thread ) ;
155164 }
156165
@@ -234,7 +243,7 @@ export function updateMetaIndex(fn: (meta: MetaIndex) => MetaIndex): MetaIndex {
234243export function loadThread ( id : string ) : Thread | null {
235244 ensureMigrated ( ) ;
236245
237- const threadPath = join ( THREADS_DIR , ` ${ id } .json` ) ;
246+ const threadPath = safeThreadPath ( id ) ;
238247 if ( ! existsSync ( threadPath ) ) return null ;
239248
240249 try {
@@ -253,7 +262,7 @@ export function saveThread(id: string, thread: Thread): void {
253262 ensureThreadsDir ( ) ;
254263
255264 // Write thread file atomically
256- atomicWrite ( join ( THREADS_DIR , ` ${ id } .json` ) , JSON . stringify ( thread , null , 2 ) ) ;
265+ atomicWrite ( safeThreadPath ( id ) , JSON . stringify ( thread , null , 2 ) ) ;
257266
258267 // Update meta index (locked)
259268 updateMetaIndex ( ( meta ) => {
@@ -270,7 +279,7 @@ export function updateThread(id: string, fn: (thread: Thread) => Thread): Thread
270279 ensureMigrated ( ) ;
271280 ensureThreadsDir ( ) ;
272281
273- const threadPath = join ( THREADS_DIR , ` ${ id } .json` ) ;
282+ const threadPath = safeThreadPath ( id ) ;
274283 ensureFileExists ( threadPath , '{}' ) ;
275284
276285 let release : ( ( ) => void ) | null = null ;
@@ -309,7 +318,7 @@ export function updateThread(id: string, fn: (thread: Thread) => Thread): Thread
309318export function deleteThreadFile ( id : string ) : void {
310319 ensureMigrated ( ) ;
311320
312- const threadPath = join ( THREADS_DIR , ` ${ id } .json` ) ;
321+ const threadPath = safeThreadPath ( id ) ;
313322 if ( existsSync ( threadPath ) ) {
314323 unlinkSync ( threadPath ) ;
315324 }
@@ -363,7 +372,7 @@ export function saveIndex(index: ThreadIndex): void {
363372
364373 // Write each thread file
365374 for ( const [ id , thread ] of Object . entries ( index ) ) {
366- atomicWrite ( join ( THREADS_DIR , ` ${ id } .json` ) , JSON . stringify ( thread , null , 2 ) ) ;
375+ atomicWrite ( safeThreadPath ( id ) , JSON . stringify ( thread , null , 2 ) ) ;
367376 }
368377
369378 // Remove thread files not in the new index
0 commit comments