Skip to content

Commit ffb5078

Browse files
committed
Security audit
1 parent f670280 commit ffb5078

3 files changed

Lines changed: 65 additions & 39 deletions

File tree

package-lock.json

Lines changed: 42 additions & 30 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/core/storage.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
unlinkSync,
2424
} from 'fs';
2525
import { homedir } from 'os';
26-
import { join } from 'path';
26+
import { join, resolve } from 'path';
2727
import { lockSync, unlockSync } from 'proper-lockfile';
2828
import 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+
108117
function 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 {
234243
export 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
309318
export 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

src/core/utils.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,13 @@ export function validateTag(tag: string): string {
3636
throw new Error(`Tag too long (max ${MAX_TAG_LENGTH} characters)`);
3737
}
3838

39-
// Check for dangerous characters
40-
if (/[<>"'&\n\r\0]/.test(tag)) {
39+
// Check for dangerous characters (including path traversal)
40+
if (/[<>"'&\n\r\0\/\\]/.test(tag)) {
41+
throw new Error('Tag contains invalid characters');
42+
}
43+
44+
// Block path traversal
45+
if (tag.includes('..')) {
4146
throw new Error('Tag contains invalid characters');
4247
}
4348

0 commit comments

Comments
 (0)