diff --git a/CHANGELOG.md b/CHANGELOG.md index 2383eb8cd..b8a4dbc1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Hardened the relay IPC socket: now lives under `~/.warelay/ipc`, enforces 0700 dir / 0600 socket perms, rejects symlink or foreign-owned paths, and includes unit tests to lock in the behavior. - `warelay logout` now also prunes the shared session store (`~/.warelay/sessions.json`) alongside WhatsApp Web credentials, reducing leftover state after unlinking. - Logging now rolls daily to `/tmp/warelay/warelay-YYYY-MM-DD.log` (or custom dir) and prunes files older than 24h to reduce data retention. +- Media server now rejects symlinked files and ensures resolved paths stay inside the media directory, closing traversal via symlinks; added regression test. ## 1.3.0 — 2025-12-02 diff --git a/src/media/server.test.ts b/src/media/server.test.ts index c30e6ea61..875088cbf 100644 --- a/src/media/server.test.ts +++ b/src/media/server.test.ts @@ -59,4 +59,17 @@ describe("media server", () => { expect(await res.text()).toBe("invalid path"); await new Promise((r) => server.close(r)); }); + + it("blocks symlink escaping outside media dir", async () => { + const target = path.join(process.cwd(), "package.json"); // outside MEDIA_DIR + const link = path.join(MEDIA_DIR, "link-out"); + await fs.symlink(target, link); + + const server = await startMediaServer(0, 5_000); + const port = (server.address() as AddressInfo).port; + const res = await fetch(`http://localhost:${port}/media/link-out`); + expect(res.status).toBe(400); + expect(await res.text()).toBe("invalid path"); + await new Promise((r) => server.close(r)); + }); }); diff --git a/src/media/server.ts b/src/media/server.ts index 27c2d5ed9..1c37c2a33 100644 --- a/src/media/server.ts +++ b/src/media/server.ts @@ -17,24 +17,31 @@ export function attachMediaRoutes( app.get("/media/:id", async (req, res) => { const id = req.params.id; - const file = path.resolve(mediaDir, id); - const mediaRoot = path.resolve(mediaDir) + path.sep; - if (!file.startsWith(mediaRoot)) { - res.status(400).send("invalid path"); - return; - } + const mediaRoot = (await fs.realpath(mediaDir)) + path.sep; + const file = path.resolve(mediaRoot, id); + try { - const stat = await fs.stat(file); + const lstat = await fs.lstat(file); + if (lstat.isSymbolicLink()) { + res.status(400).send("invalid path"); + return; + } + const realPath = await fs.realpath(file); + if (!realPath.startsWith(mediaRoot)) { + res.status(400).send("invalid path"); + return; + } + const stat = await fs.stat(realPath); if (Date.now() - stat.mtimeMs > ttlMs) { - await fs.rm(file).catch(() => {}); + await fs.rm(realPath).catch(() => {}); res.status(410).send("expired"); return; } - res.sendFile(file); + res.sendFile(realPath); // best-effort single-use cleanup after response ends res.on("finish", () => { setTimeout(() => { - fs.rm(file).catch(() => {}); + fs.rm(realPath).catch(() => {}); }, 500); }); } catch {