From 8db66563e24b74af6eb7426e47a3f1d22e13d3d5 Mon Sep 17 00:00:00 2001 From: DHDHLZ <731642078@qq.com> Date: Wed, 2 Sep 2026 19:16:54 +0800 Subject: [PATCH] fix(media): contain media file resolution inside the store GET /api/media/:date/:name joined URL segments into the media path without a containment check. Hono percent-decodes route params, so ..%2F.. sequences escaped MEDIA_DIR and read arbitrary files readable by the Alice process (e.g. data/config/auth.json, data/config/accounts.json). Add a path fence in resolveMediaPath (reject any result outside MEDIA_DIR), restrict the route to known media extensions, and cover both with regression specs. --- src/core/media-store.spec.ts | 10 ++++++++ src/core/media-store.ts | 11 +++++++-- src/webui/routes/media.spec.ts | 44 ++++++++++++++++++++++++++++++++++ src/webui/routes/media.ts | 10 +++++++- 4 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 src/webui/routes/media.spec.ts diff --git a/src/core/media-store.spec.ts b/src/core/media-store.spec.ts index 6d3b6719f..becc318e1 100644 --- a/src/core/media-store.spec.ts +++ b/src/core/media-store.spec.ts @@ -12,6 +12,16 @@ describe('resolveMediaPath', () => { const result = resolveMediaPath('2026-01-01/ace-aim-air.png') expect(result).toContain(join('data', 'media', '2026-01-01', 'ace-aim-air.png')) }) + + it.each([ + '../config/auth.json', + '../../config/auth.json', + '2026-01-01/../../../etc/passwd', + '..', + '../..', + ])('should reject path traversal input %s (CWE-22)', (name) => { + expect(() => resolveMediaPath(name)).toThrow(/escapes the media store/) + }) }) // ==================== persistMedia ==================== diff --git a/src/core/media-store.ts b/src/core/media-store.ts index fd9733c71..a957b9f60 100644 --- a/src/core/media-store.ts +++ b/src/core/media-store.ts @@ -9,7 +9,7 @@ import { createHash } from 'node:crypto' import { readFile, copyFile, mkdir } from 'node:fs/promises' import { existsSync } from 'node:fs' -import { extname, join, posix } from 'node:path' +import { extname, join, posix, relative, sep, isAbsolute } from 'node:path' import { dataPath } from '@/core/paths.js' /** 256 short, common English words — one per byte value. */ @@ -84,5 +84,12 @@ export async function persistMedia(filePath: string): Promise { /** Resolve a media relative path to its absolute path on disk. */ export function resolveMediaPath(name: string): string { - return join(MEDIA_DIR, name) + const resolved = join(MEDIA_DIR, name) + // Path fence: the resolved path must stay inside MEDIA_DIR. Reject '..' + // traversal attempts instead of letting them read files outside the store. + const rel = relative(MEDIA_DIR, resolved) + if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new Error(`media path escapes the media store: ${name}`) + } + return resolved } diff --git a/src/webui/routes/media.spec.ts b/src/webui/routes/media.spec.ts new file mode 100644 index 000000000..c9a0fdd01 --- /dev/null +++ b/src/webui/routes/media.spec.ts @@ -0,0 +1,44 @@ +/** + * Route-level regression for GET /api/media/:date/:name (CWE-22): + * - a benign file inside the store is served, + * - percent-encoded '..' traversal is answered 404 instead of disclosing files. + * Note: createMediaRoutes() mounts at '/:date/:name'; the '/api/media' prefix is + * added by the web plugin, so requests here omit it. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +type MediaRoutes = typeof import('./media.js') + +describe('GET /api/media/:date/:name path traversal', () => { + let home = '' + let createMediaRoutes: MediaRoutes['createMediaRoutes'] + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'oa-media-route-spec-')) + process.env['OPENALICE_HOME'] = home + const mediaDir = join(home, 'data', 'media', '2026-09-02') + await mkdir(mediaDir, { recursive: true }) + await writeFile(join(mediaDir, 'a.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])) + vi.resetModules() + createMediaRoutes = (await import('./media.js')).createMediaRoutes + }) + + it('serves a file that lives inside the store', async () => { + const app = createMediaRoutes() + const res = await app.request('/2026-09-02/a.png') + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('image/png') + expect(Buffer.from(await res.arrayBuffer())[0]).toBe(0x89) + }) + + it('answers percent-encoded .. traversal with 404 instead of leaking files', async () => { + const app = createMediaRoutes() + const res = await app.request( + '/2026-09-02/..%2F..%2Fconfig%2Fauth.json', + ) + expect(res.status).toBe(404) + }) +}) diff --git a/src/webui/routes/media.ts b/src/webui/routes/media.ts index 86ffdb186..a78b9fd85 100644 --- a/src/webui/routes/media.ts +++ b/src/webui/routes/media.ts @@ -24,7 +24,15 @@ export function createMediaRoutes() { app.get('/:date/:name', async (c) => { const { date, name } = c.req.param() - const filePath = resolveMediaPath(join(date, name)) + const ext = extname(name).toLowerCase() + if (!MIME[ext]) return c.notFound() + + let filePath: string + try { + filePath = resolveMediaPath(join(date, name)) + } catch { + return c.notFound() + } try { const buf = await readFile(filePath)