fix(media): block symlink traversal
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user