fix(media): block symlink traversal

This commit is contained in:
Peter Steinberger
2025-12-02 18:37:15 +00:00
parent b94b220156
commit 2cf134668c
3 changed files with 31 additions and 10 deletions

View File

@@ -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

View File

@@ -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));
});
});

View File

@@ -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 {