diff --git a/src/index.test.js b/src/index.test.js index 2bf1e23..120ad17 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import formatDuration from '$lib/formatDuration'; +import { safePath } from '$lib/server/safePath'; describe('formatDuration test', () => { it('converts 120 seconds to "2:00"', () => { @@ -21,4 +22,26 @@ describe('formatDuration test', () => { it('converts 3601 seconds to "1:00:01"', () => { expect(formatDuration(3601)).toBe('1:00:01'); }); +}); + +describe('safePath test', () => { + const base = "data/subdir"; + it('rejects empty string', () => { + expect(safePath('', { base })).toBe(null); + }); + it('accepts a regular path', () => { + expect(safePath('subsubdir/file.txt', { base })).toBe('data/subdir/subsubdir/file.txt'); + }); + it('rejects path with ..', () => { + expect(safePath('../file.txt', { base })).toBe(null); + }); + it('accepts path with .', () => { + expect(safePath('./file.txt', { base })).toBe('data/subdir/file.txt'); + }); + it('accepts path traversal within base', () => { + expect(safePath('subsubdir/../file.txt', { base })).toBe('data/subdir/file.txt'); + }); + it('rejects path with subdir if noSubDir is true', () => { + expect(safePath('subsubdir/file.txt', { base, noSubDir: true })).toBe(null); + }); }); \ No newline at end of file diff --git a/src/lib/server/database/loadData.ts b/src/lib/server/database/loadData.ts index ad1848a..ff5e610 100644 --- a/src/lib/server/database/loadData.ts +++ b/src/lib/server/database/loadData.ts @@ -1,6 +1,7 @@ import fs from 'fs'; import { globalMemoryStorage, songData, songNameCache } from '$lib/server/cache.js'; import { getDirectoryHash } from '../dirHash'; +import { safePath } from '../safePath'; const dataPath = './data/song/'; @@ -25,7 +26,12 @@ export async function loadData() { songNameCache.flushAll(); for (const songID of songList) { try { - const fileContentString = fs.readFileSync(dataPath + songID + '.json').toString(); + const normPath = safePath(songID + '.json', { base: dataPath }); + if (!normPath) { + console.error(`[load-song-data] Invalid path for song ID ${songID}`); + continue; + } + const fileContentString = fs.readFileSync(normPath).toString(); const data = JSON.parse(fileContentString); songData.set(songID, data); const metadata: MusicMetadata = data; diff --git a/src/lib/server/dirHash.ts b/src/lib/server/dirHash.ts index 856ca5c..19ef295 100644 --- a/src/lib/server/dirHash.ts +++ b/src/lib/server/dirHash.ts @@ -12,7 +12,7 @@ export function getDirectoryHash(dir: string): string { files.forEach(file => { const filePath = path.join(currentDir, file); - const stats = fs.statSync(filePath); + const stats = fs.lstatSync(filePath); if (stats.isDirectory()) { traverseDirectory(filePath); @@ -30,7 +30,7 @@ export function getDirectoryHash(dir: string): string { // Create hash from file details const hash = crypto.createHash('sha256'); - hash.update(fileDetails.join('|')); + hash.update(fileDetails.join('\x00')); return hash.digest('hex'); } \ No newline at end of file diff --git a/src/lib/server/safePath.ts b/src/lib/server/safePath.ts new file mode 100644 index 0000000..52dc095 --- /dev/null +++ b/src/lib/server/safePath.ts @@ -0,0 +1,22 @@ +import path from 'path'; + +export function safePath(pathIn: string, options: { base: string, noSubDir?: boolean }): string | null { + const base = options.base.endsWith('/') ? options.base : options.base + '/'; + if (!pathIn.startsWith("./")) { + pathIn = "./" + pathIn; + } + pathIn = path.join(base, pathIn); + const normBase = path.normalize(base); + const normPath = path.normalize(pathIn); + + if (normPath !== normBase && normPath.startsWith(normBase)) { + if (options.noSubDir) { + let rel = path.relative(normBase, normPath); + if (rel.indexOf(path.sep) !== -1) { + return null; + } + } + return normPath; + } + return null; +} \ No newline at end of file diff --git a/src/routes/api/database/song/[id]/+server.ts b/src/routes/api/database/song/[id]/+server.ts index c546949..7b03ee5 100644 --- a/src/routes/api/database/song/[id]/+server.ts +++ b/src/routes/api/database/song/[id]/+server.ts @@ -1,31 +1,43 @@ +import { safePath } from '$lib/server/safePath'; import { getCurrentFormattedDateTime } from '$lib/songUpdateTime'; import { json, error } from '@sveltejs/kit'; import fs from 'fs'; import type { RequestHandler } from './$types'; export const GET: RequestHandler = async ({ params }) => { - const filePath = `./data/song/${params.id}.json`; - if (!fs.existsSync(filePath)) { + const filePath = safePath(`${params.id}.json`, { base: './data/song' }); + if (!filePath) { + return error(404, { + message: "No correspoding song." + }); + } + let data; + try { data = fs.readFileSync(filePath); } catch (e) { return error(404, { message: "No corresponding song." - }) + }); } - const data = fs.readFileSync(filePath); - return json(JSON.parse(data.toString())); + return json(JSON.parse(data.toString())); } export const POST: RequestHandler = async ({ request, params }) => { const timeStamp = new Date().getTime(); - if (!fs.existsSync("./data/pending/")) { - fs.mkdirSync("./data/pending"); + try { + if (!fs.existsSync("./data/pending/")) { + fs.mkdirSync("./data/pending", { mode: 0o755 }); + } + const filePath = `./data/pending/${params.id}-${timeStamp}.json`; + const data: MusicMetadata = await request.json(); + data.updateTime = getCurrentFormattedDateTime(); + fs.writeFileSync(filePath, JSON.stringify(data, null, 4), { mode: 0o644 }); + return json({ + "message": "successfully created" + }, { + status: 201 + }); + } catch (e) { + return error(500, { + message: "Internal server error." + }); } - const filePath = `./data/pending/${params.id}-${timeStamp}.json`; - const data: MusicMetadata = await request.json(); - data.updateTime = getCurrentFormattedDateTime(); - fs.writeFileSync(filePath, JSON.stringify(data, null, 4)); - return json({ - "message": "successfully created" - }, { - status: 201 - }); } \ No newline at end of file diff --git a/src/routes/database/edit/[id]/+page.server.ts b/src/routes/database/edit/[id]/+page.server.ts index 0699ca2..ce0f590 100644 --- a/src/routes/database/edit/[id]/+page.server.ts +++ b/src/routes/database/edit/[id]/+page.server.ts @@ -1,22 +1,21 @@ -import type { PageServerLoad } from './$types'; import fs from 'fs'; - +import type { PageServerLoad } from './$types'; +import { safePath } from '$lib/server/safePath'; export const load: PageServerLoad = ({ params }) => { - const filePath = `./data/song/${params.id}.json`; - if (!fs.existsSync(filePath)) { + const filePath = safePath(`${params.id}.json`, { base: './data/song' }); + if (!filePath) { return { songData: null - } + }; } - const dataBuffer = fs.readFileSync(filePath); try { + const dataBuffer = fs.readFileSync(filePath); const data = JSON.parse(dataBuffer.toString()); return { songData: data }; - } - catch { + } catch { return { songData: null }