fix symlink in downloads

This commit is contained in:
Emrik Östling committed 2026-09-09 23:43:55 +02:00
1 parent bc5a163fb5
commit d112ef9a1b
7 files changed
+166 -26

No files matched your search

+3 -6
View File
@@ -1,6 +1,6 @@
import { test, expect, beforeEach, afterEach, afterAll } from "bun:test";
import { Database } from "bun:sqlite";
import { unlinkSync, existsSync, mkdirSync } from "node:fs";
import { afterAll, afterEach, beforeEach, expect, test } from "bun:test";
import { existsSync, mkdirSync, unlinkSync } from "node:fs";
// set environment variable to ensure the test database is used instead of production data
process.env.DB_PATH = "./data/test-isolated.sqlite";
@@ -98,10 +98,7 @@ afterEach(() => {
});
afterAll(() => {
// Close the module-level default database before cleanup to prevent file lock errors
if (defaultDb) {
defaultDb.close();
}
// Do not close defaultDb as subsequent test files in the test suite share the db instance
// Cleanup of the isolated test database after the test run
if (existsSync("./data/test-isolated.sqlite")) {
unlinkSync("./data/test-isolated.sqlite");
+13 -1
View File
@@ -153,7 +153,19 @@ test("landlock-runner enforces real sandboxing when available", async () => {
expect(failure).toBe(true);
expect(existsSync(forbiddenFile)).toBe(false);
// 3. Network operations must be blocked (Permission denied)
// 3. Creating symlinks in outputDir must be blocked (Permission denied)
const symlinkTarget = "/etc/passwd";
const symlinkFile = join(outputDir, "symlink-test");
const symlinkFailed = await new Promise<boolean>((resolve) => {
sandboxedExec("/bin/ln", ["-s", symlinkTarget, symlinkFile], (err) => {
resolve(!!err);
});
});
expect(symlinkFailed).toBe(true);
expect(existsSync(symlinkFile)).toBe(false);
// 4. Network operations must be blocked (Permission denied)
const netBlocked = await new Promise<boolean>((resolve) => {
sandboxedExec(
"/usr/bin/python3",
+90
View File
@@ -0,0 +1,90 @@
import { jwt } from "@elysiajs/jwt";
import { expect, test } from "bun:test";
import { Elysia } from "elysia";
import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import * as tar from "tar";
import db from "../../src/db/db";
import { download } from "../../src/pages/download";
test("download route blocks symlinks and serves normal files", async () => {
const testUserId = 998;
const testUserEmail = `download-test-${Date.now()}@example.com`;
db.query("INSERT INTO users (id, email, password) VALUES (?, ?, ?)").run(
testUserId,
testUserEmail,
"hashed_pwd",
);
const res = db
.query(
"INSERT INTO jobs (user_id, date_created, status, num_files) VALUES (?, ?, ?, ?) RETURNING id",
)
.get(testUserId, new Date().toISOString(), "completed", 1) as { id: number };
const jobId = String(res.id);
const jobDir = `./data/output/${testUserId}/${jobId}`;
mkdirSync(jobDir, { recursive: true });
writeFileSync(`${jobDir}/normal.txt`, "safe content");
symlinkSync("/etc/passwd", `${jobDir}/symlink.txt`);
try {
const tokenApp = new Elysia()
.use(jwt({ name: "jwt", secret: process.env.JWT_SECRET ?? "secret", exp: "7d" }))
.get("/token", async ({ jwt }) => jwt.sign({ id: String(testUserId) }));
const tokenRes = await tokenApp.handle(new Request("http://localhost/token"));
const token = await tokenRes.text();
const app = new Elysia().use(download);
// 1. Downloading normal file must succeed (200)
const normalRes = await app.handle(
new Request(`http://localhost/download/${testUserId}/${jobId}/normal.txt`, {
headers: { cookie: `auth=${token}` },
}),
);
expect(normalRes.status).toBe(200);
expect(await normalRes.text()).toBe("safe content");
// 2. Downloading a symlink must be blocked (403)
const symlinkRes = await app.handle(
new Request(`http://localhost/download/${testUserId}/${jobId}/symlink.txt`, {
headers: { cookie: `auth=${token}` },
}),
);
expect(symlinkRes.status).toBe(403);
const body = (await symlinkRes.json()) as { message?: string };
expect(body.message).toBe("Access denied.");
// 3. Downloading non-existent file must return 404
const notFoundRes = await app.handle(
new Request(`http://localhost/download/${testUserId}/${jobId}/nonexistent.txt`, {
headers: { cookie: `auth=${token}` },
}),
);
expect(notFoundRes.status).toBe(404);
// 4. Archive tarball must exclude symlinks
const archiveRes = await app.handle(
new Request(`http://localhost/archive/${jobId}`, {
headers: { cookie: `auth=${token}` },
}),
);
expect(archiveRes.status).toBe(200);
const tarEntries: string[] = [];
await tar.t({
file: `${jobDir}/converted_files_${jobId}.tar`,
onReadEntry: (e) => {
tarEntries.push(e.path);
},
});
expect(tarEntries).toContain("./normal.txt");
expect(tarEntries).not.toContain("./symlink.txt");
} finally {
rmSync(`./data/output/${testUserId}`, { recursive: true, force: true });
db.query("DELETE FROM jobs WHERE id = ?").run(jobId);
db.query("DELETE FROM users WHERE id = ?").run(testUserId);
}
});