| … | … | @@ -656,6 +656,14 @@ export class NoteManager { |
| 656 | 656 | } |
| 657 | 657 | |
| 658 | 658 | async getNote (notebook, noteId) { |
| 659 | // Path-traversal guard: noteId addresses /notes/<id>.json — it is never a | |
| 660 | // path. Public routes (web.js) pass it raw, so validate at the source. | |
| 661 | if (typeof noteId !== 'string' || !noteId || | |
| 662 | noteId.includes('/') || noteId.includes('\\') || | |
| 663 | noteId.includes('..') || noteId.includes('\0')) { | |
| 664 | return null | |
| 665 | } | |
| 666 | ||
| 659 | 667 | // SharedNotebook path (multi-writer) |
| 660 | 668 | const snb = this._getSharedNotebook(notebook) |
| 661 | 669 | if (snb) { |
| … | … | @@ -92,6 +92,14 @@ export class MediaService { |
| 92 | 92 | * - data: Buffer (for drive/shared fallbacks) |
| 93 | 93 | */ |
| 94 | 94 | async resolve (notebook, identifier) { |
| 95 | // Path-traversal guard: identifier is a media hash/filename used to build a | |
| 96 | // drive path (/media/<identifier>) — it is never a path itself. | |
| 97 | if (typeof identifier !== 'string' || !identifier || | |
| 98 | identifier.includes('/') || identifier.includes('\\') || | |
| 99 | identifier.includes('..') || identifier.includes('\0')) { | |
| 100 | return null | |
| 101 | } | |
| 102 | ||
| 95 | 103 | // 1. MediaStore (content-addressed) |
| 96 | 104 | const info = await this.mediaStore.getInfo(identifier) |
| 97 | 105 | if (info) { |
| … | … | @@ -0,0 +1,26 @@ |
| 1 | import test from 'brittle' | |
| 2 | import { NoteManager } from '../../lib/note-manager.js' | |
| 3 | import { MediaService } from '../../lib/services/media-service.js' | |
| 4 | ||
| 5 | const TRAVERSALS = ['../notes/secret', 'a/../../x', 'foo/bar', 'x\\y', '..', 'a\0b', ''] | |
| 6 | ||
| 7 | test('getNote rejects path-traversal noteIds before any storage access', async (t) => { | |
| 8 | const trap = () => { throw new Error('storage touched — traversal guard failed') } | |
| 9 | const nm = new NoteManager({ getDrive: trap }) | |
| 10 | nm._getSharedNotebook = trap // must not be reached for malicious input | |
| 11 | ||
| 12 | for (const bad of TRAVERSALS) { | |
| 13 | t.is(await nm.getNote('nb', bad), null, `rejected noteId ${JSON.stringify(bad)}`) | |
| 14 | } | |
| 15 | ||
| 16 | clearInterval(nm._keyCacheCleanupInterval) // release the key-cache timer | |
| 17 | }) | |
| 18 | ||
| 19 | test('MediaService.resolve rejects path-traversal identifiers before any storage access', async (t) => { | |
| 20 | const trap = new Proxy({}, { get () { throw new Error('storage touched — traversal guard failed') } }) | |
| 21 | const ms = new MediaService({ mediaStore: trap, driveManager: trap, noteManager: trap }) | |
| 22 | ||
| 23 | for (const bad of TRAVERSALS) { | |
| 24 | t.is(await ms.resolve('nb', bad), null, `rejected identifier ${JSON.stringify(bad)}`) | |
| 25 | } | |
| 26 | }) |