feat: implement landlock

This commit is contained in:
Emrik Östling
2026-09-09 21:36:48 +02:00
parent 2ea8e00f1e
commit 181941717e
29 changed files with 802 additions and 112 deletions
+1
View File
@@ -50,3 +50,4 @@ package-lock.json
/Bruno
/tsconfig.tsbuildinfo
/public/generated.css
/bin
+8 -4
View File
@@ -12,9 +12,9 @@ RUN apt-get update && apt-get install -y \
# if architecture is arm64, use the arm64 version of bun
RUN ARCH=$(uname -m) && \
if [ "$ARCH" = "aarch64" ]; then \
curl -fsSL -o bun-linux-aarch64.zip https://github.com/oven-sh/bun/releases/download/bun-v1.2.2/bun-linux-aarch64.zip; \
curl -fsSL -o bun-linux-aarch64.zip https://github.com/oven-sh/bun/releases/download/bun-v1.2.2/bun-linux-aarch64.zip; \
else \
curl -fsSL -o bun-linux-x64-baseline.zip https://github.com/oven-sh/bun/releases/download/bun-v1.2.2/bun-linux-x64-baseline.zip; \
curl -fsSL -o bun-linux-x64-baseline.zip https://github.com/oven-sh/bun/releases/download/bun-v1.2.2/bun-linux-x64-baseline.zip; \
fi
RUN unzip -j bun-linux-*.zip -d /usr/local/bin && \
@@ -35,6 +35,7 @@ RUN cd /temp/prod && bun install --frozen-lockfile --production
FROM base AS prerelease
WORKDIR /app
RUN apt-get update && apt-get install -y gcc --no-install-recommends && rm -rf /var/lib/apt/lists/*
COPY --from=install /temp/dev/node_modules node_modules
COPY . .
@@ -52,6 +53,7 @@ RUN apt-get update && apt-get install -y \
dcraw \
dvisvgm \
ffmpeg \
fonts-liberation \
ghostscript \
graphicsmagick \
imagemagick-7.q16 \
@@ -90,9 +92,9 @@ ENV PATH="/root/.local/bin:${PATH}"
# Install VTracer binary
RUN ARCH=$(uname -m) && \
if [ "$ARCH" = "aarch64" ]; then \
VTRACER_ASSET="vtracer-aarch64-unknown-linux-musl.tar.gz"; \
VTRACER_ASSET="vtracer-aarch64-unknown-linux-musl.tar.gz"; \
else \
VTRACER_ASSET="vtracer-x86_64-unknown-linux-musl.tar.gz"; \
VTRACER_ASSET="vtracer-x86_64-unknown-linux-musl.tar.gz"; \
fi && \
curl -L -o /tmp/vtracer.tar.gz "https://github.com/visioncortex/vtracer/releases/download/0.6.4/${VTRACER_ASSET}" && \
tar -xzf /tmp/vtracer.tar.gz -C /tmp/ && \
@@ -103,6 +105,7 @@ RUN ARCH=$(uname -m) && \
COPY --from=install /temp/prod/node_modules node_modules
COPY --from=prerelease /app/public/ /app/public/
COPY --from=prerelease /app/dist /app/dist
COPY --from=prerelease /app/bin/landlock-runner /usr/local/bin/landlock-runner
# COPY . .
RUN mkdir data
@@ -110,5 +113,6 @@ RUN mkdir data
EXPOSE 3000/tcp
# used for calibre
ENV QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox"
ENV SANDBOX_STRICT="true"
ENV NODE_ENV=production
ENTRYPOINT [ "bun", "run", "dist/src/index.js" ]
+2 -1
View File
@@ -18,7 +18,8 @@
"heif-info",
"potrace",
"soffice",
"perl"
"perl",
"gcc"
],
"tailwind": {
"entry": ["src/main.css"]
+2 -1
View File
@@ -8,7 +8,8 @@
"format:eslint": "eslint --fix .",
"format:prettier": "prettier --write .",
"build:js": "tsc",
"build": "bun x @tailwindcss/cli -i ./src/main.css -o ./public/generated.css && bun run build:js",
"build:runner": "mkdir -p bin && gcc -O2 -Wall -Wextra src/native/landlock-runner.c -o bin/landlock-runner || true",
"build": "bun x @tailwindcss/cli -i ./src/main.css -o ./public/generated.css && bun run build:runner && bun run build:js",
"lint": "npm-run-all 'lint:*'",
"lint:tsc": "tsc --noEmit",
"lint:knip": "knip",
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -118,7 +117,7 @@ export async function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -65,7 +64,7 @@ export async function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
return new Promise((resolve, reject) => {
execFile("ebook-convert", [filePath, targetPath], (error, stdout, stderr) => {
+2 -3
View File
@@ -1,6 +1,5 @@
import fs from "fs";
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -21,7 +20,7 @@ export async function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
const args = buildDaselArgs(filePath, fileType, convertTo);
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -16,7 +15,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
const inputArgs: string[] = [];
if (fileType === "eps") {
+3 -14
View File
@@ -1,22 +1,11 @@
import {
execFile as execFileOriginal,
type ChildProcess,
type ExecFileOptions,
} from "node:child_process";
// ffmpeg streams continuous progress to stderr, so a long conversion overflows
// execFile's 1 MB default maxBuffer and fails with "stderr maxBuffer length
// exceeded" (issue #565). Raise it well above that. The options object must be
// passed before the callback: execFile ignores an options argument placed after
// the callback, which is why the shared ExecFileFn type cannot carry it.
// the callback.
const FFMPEG_MAX_BUFFER = 1024 * 1024 * 64; // 64 MB
type FfmpegExecFile = (
cmd: string,
args: string[],
options: ExecFileOptions,
callback: (err: Error | null, stdout: string, stderr: string) => void,
) => ChildProcess | void;
import { defaultExecFile, ExecFileFn } from "./types";
// This could be done dynamically by running `ffmpeg -formats` and parsing the output
export const properties = {
@@ -716,7 +705,7 @@ export async function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: FfmpegExecFile = execFileOriginal as FfmpegExecFile, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
let extraArgs: string[] = [];
let message = "Done";
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -315,7 +314,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
// Apply EXIF orientation so photos (e.g. from phones) don't end up sideways
// when converted to formats where the orientation tag is lost or ignored
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
// declare possible conversions
export const properties = {
@@ -447,7 +446,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
let outputArgs: string[] = [];
let inputArgs: string[] = [];
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -34,7 +33,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
return new Promise((resolve, reject) => {
execFile("inkscape", [filePath, "-o", targetPath], (error, stdout, stderr) => {
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -16,7 +15,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
return new Promise((resolve, reject) => {
execFile("heif-convert", [filePath, targetPath], (error, stdout, stderr) => {
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
// declare possible conversions
export const properties = {
@@ -19,7 +18,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
let tool = "";
if (fileType === "jxl") {
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -162,7 +161,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal,
execFile: ExecFileFn = defaultExecFile,
): Promise<string> {
const outputPath = targetPath.split("/").slice(0, -1).join("/").replace("./", "") ?? targetPath;
+74 -32
View File
@@ -1,7 +1,10 @@
import { Cookie } from "elysia";
import { rmSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import db from "../db/db";
import { MAX_CONVERT_PROCESS } from "../helpers/env";
import { normalizeFiletype, normalizeOutputFiletype } from "../helpers/normalizeFiletype";
import { createSandboxedExec } from "../helpers/sandbox";
import { convert as convertassimp, properties as propertiesassimp } from "./assimp";
import { convert as convertCalibre, properties as propertiesCalibre } from "./calibre";
import { convert as convertDasel, properties as propertiesDasel } from "./dasel";
@@ -16,15 +19,16 @@ import { convert as convertInkscape, properties as propertiesInkscape } from "./
import { convert as convertLibheif, properties as propertiesLibheif } from "./libheif";
import { convert as convertLibjxl, properties as propertiesLibjxl } from "./libjxl";
import { convert as convertLibreOffice, properties as propertiesLibreOffice } from "./libreoffice";
import { convert as convertMarkitdown, properties as propertiesMarkitdown } from "./markitdown";
import { convert as convertMsgconvert, properties as propertiesMsgconvert } from "./msgconvert";
import { convert as convertPandoc, properties as propertiesPandoc } from "./pandoc";
import { convert as convertPotrace, properties as propertiesPotrace } from "./potrace";
import { convert as convertresvg, properties as propertiesresvg } from "./resvg";
import { ExecFileFn } from "./types";
import { convert as convertVcf, properties as propertiesVcf } from "./vcf";
import { convert as convertImage, properties as propertiesImage } from "./vips";
import { convert as convertVtracer, properties as propertiesVtracer } from "./vtracer";
import { convert as convertVcf, properties as propertiesVcf } from "./vcf";
import { convert as convertxelatex, properties as propertiesxelatex } from "./xelatex";
import { convert as convertMarkitdown, properties as propertiesMarkitdown } from "./markitdown";
// This should probably be reconstructed so that the functions are not imported instead the functions hook into this to make the converters more modular
@@ -51,8 +55,8 @@ const properties: Record<
fileType: string,
convertTo: string,
targetPath: string,
options?: unknown,
execFile?: ExecFileFn,
) => unknown;
}
> = {
@@ -160,37 +164,67 @@ export async function handleConvert(
"INSERT INTO file_names (job_id, file_name, output_file_name, status) VALUES (?1, ?2, ?3, ?4)",
);
for (const chunk of chunks(fileNames, MAX_CONVERT_PROCESS)) {
const toProcess: Promise<string>[] = [];
for (const fileName of chunk) {
const filePath = `${userUploadsDir}${fileName}`;
const fileTypeOrig = fileName.includes(".") ? (fileName.split(".").pop() ?? "") : "";
const fileType = normalizeFiletype(fileTypeOrig);
const newFileExt = normalizeOutputFiletype(convertTo);
let newFileName: string;
if (fileTypeOrig === "") {
newFileName = `${fileName}.${newFileExt}`;
} else {
newFileName = fileName.replace(
new RegExp(`${fileTypeOrig}(?!.*${fileTypeOrig})`),
newFileExt,
const effectiveJobId = jobId.value ?? `convertx_${Date.now()}`;
const tempJobDir = `/tmp/convertx_${effectiveJobId}/`;
try {
await mkdir(tempJobDir, { recursive: true });
} catch (err) {
console.error(`Failed to create temp directory ${tempJobDir}:`, err);
}
const sandboxedExec = createSandboxedExec({
inputDir: userUploadsDir,
outputDir: userOutputDir,
tempDir: tempJobDir,
});
try {
for (const chunk of chunks(fileNames, MAX_CONVERT_PROCESS)) {
const toProcess: Promise<string>[] = [];
for (const fileName of chunk) {
const filePath = `${userUploadsDir}${fileName}`;
const fileTypeOrig = fileName.includes(".") ? (fileName.split(".").pop() ?? "") : "";
const fileType = normalizeFiletype(fileTypeOrig);
const newFileExt = normalizeOutputFiletype(convertTo);
let newFileName: string;
if (fileTypeOrig === "") {
newFileName = `${fileName}.${newFileExt}`;
} else {
newFileName = fileName.replace(
new RegExp(`${fileTypeOrig}(?!.*${fileTypeOrig})`),
newFileExt,
);
}
const targetPath = `${userOutputDir}${newFileName}`;
toProcess.push(
new Promise((resolve, reject) => {
mainConverter(
filePath,
fileType,
convertTo,
targetPath,
{},
converterName,
sandboxedExec,
)
.then((r) => {
if (jobId.value) {
query.run(jobId.value, fileName, newFileName, r);
}
resolve(r);
})
.catch((c) => reject(c));
}),
);
}
const targetPath = `${userOutputDir}${newFileName}`;
toProcess.push(
new Promise((resolve, reject) => {
mainConverter(filePath, fileType, convertTo, targetPath, {}, converterName)
.then((r) => {
if (jobId.value) {
query.run(jobId.value, fileName, newFileName, r);
}
resolve(r);
})
.catch((c) => reject(c));
}),
);
await Promise.all(toProcess);
}
} finally {
try {
rmSync(tempJobDir, { recursive: true, force: true });
} catch {
// Ignore cleanup error if temp directory was already removed
}
await Promise.all(toProcess);
}
}
@@ -201,6 +235,7 @@ async function mainConverter(
targetPath: string,
options?: unknown,
converterName?: string,
execFile?: ExecFileFn,
) {
const fileType = normalizeFiletype(fileTypeOriginal);
@@ -235,7 +270,14 @@ async function mainConverter(
}
try {
const result = await converterFunc(inputFilePath, fileType, convertTo, targetPath, options);
const result = await converterFunc(
inputFilePath,
fileType,
convertTo,
targetPath,
options,
execFile,
);
console.log(
`Converted ${inputFilePath} from ${fileType} to ${convertTo} successfully using ${converterName}.`,
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -16,7 +15,7 @@ export async function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal,
execFile: ExecFileFn = defaultExecFile,
): Promise<string> {
return new Promise((resolve, reject) => {
execFile("markitdown", [filePath, "-o", targetPath], (err, stdout, stderr) => {
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -16,7 +15,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal,
execFile: ExecFileFn = defaultExecFile,
): Promise<string> {
return new Promise((resolve, reject) => {
if (fileType === "msg" && convertTo === "eml") {
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -126,7 +125,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal,
execFile: ExecFileFn = defaultExecFile,
): Promise<string> {
// set xelatex here
const xelatex = ["pdf", "latex"];
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -28,7 +27,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
return new Promise((resolve, reject) => {
execFile("potrace", [filePath, "-o", targetPath, "-b", convertTo], (error, stdout, stderr) => {
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -16,7 +15,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
return new Promise((resolve, reject) => {
execFile("resvg", [filePath, targetPath], (error, stdout, stderr) => {
+11 -3
View File
@@ -1,12 +1,20 @@
import type { ChildProcess, ExecFileOptions } from "child_process";
import {
execFile as execFileOriginal,
type ChildProcess,
type ExecFileOptions,
} from "node:child_process";
export type ExecFileCallback = (err: Error | null, stdout: string, stderr: string) => void;
export type ExecFileFn = (
cmd: string,
args: string[],
callback: (err: Error | null, stdout: string, stderr: string) => void,
options?: ExecFileOptions,
...argsOrOptions:
[callback: ExecFileCallback] | [options: ExecFileOptions, callback: ExecFileCallback]
) => ChildProcess | void;
export const defaultExecFile: ExecFileFn = execFileOriginal as unknown as ExecFileFn;
export type ConvertFnWithExecFile = (
filePath: string,
fileType: string,
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
// declare possible conversions
export const properties = {
@@ -96,7 +95,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal,
execFile: ExecFileFn = defaultExecFile,
): Promise<string> {
// if (fileType === "svg") {
// const scale = options.scale || 1;
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -30,7 +29,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal, // to make it mockable
execFile: ExecFileFn = defaultExecFile, // to make it mockable
): Promise<string> {
return new Promise((resolve, reject) => {
// Build vtracer arguments
+2 -3
View File
@@ -1,5 +1,4 @@
import { execFile as execFileOriginal } from "node:child_process";
import { ExecFileFn } from "./types";
import { defaultExecFile, ExecFileFn } from "./types";
export const properties = {
from: {
@@ -16,7 +15,7 @@ export function convert(
convertTo: string,
targetPath: string,
options?: unknown,
execFile: ExecFileFn = execFileOriginal,
execFile: ExecFileFn = defaultExecFile,
): Promise<string> {
return new Promise((resolve, reject) => {
// const fileName: string = (targetPath.split("/").pop() as string).replace(".pdf", "")
+176
View File
@@ -0,0 +1,176 @@
import {
execFile as execFileOriginal,
type ChildProcess,
type ExecFileOptions,
} from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import type { ExecFileCallback, ExecFileFn } from "../converters/types";
export interface SandboxConfig {
inputDir: string;
outputDir: string;
tempDir: string;
allowNet?: boolean;
}
let cachedRunnerPath: string | null | undefined = undefined;
/**
* Locate the landlock-runner binary if available.
*/
export function getLandlockRunnerPath(): string | null {
if (cachedRunnerPath !== undefined) {
return cachedRunnerPath;
}
if (process.env.SANDBOX_DISABLED === "true") {
cachedRunnerPath = null;
return null;
}
if (process.platform !== "linux") {
cachedRunnerPath = null;
return null;
}
if (process.env.LANDLOCK_RUNNER_PATH && existsSync(process.env.LANDLOCK_RUNNER_PATH)) {
cachedRunnerPath = process.env.LANDLOCK_RUNNER_PATH;
return cachedRunnerPath;
}
const candidatePaths = [
"/usr/local/bin/landlock-runner",
resolve(process.cwd(), "bin/landlock-runner"),
];
for (const candidate of candidatePaths) {
if (existsSync(candidate)) {
cachedRunnerPath = candidate;
return cachedRunnerPath;
}
}
cachedRunnerPath = null;
return null;
}
/**
* Reset the cached runner path (primarily for testing).
*/
export function resetCachedRunnerPath(): void {
cachedRunnerPath = undefined;
}
/**
* Returns true if Landlock sandboxing is supported and enabled.
*/
export function isSandboxAvailable(): boolean {
return getLandlockRunnerPath() !== null;
}
const DEFAULT_SYSTEM_RO = "/usr:/lib:/lib64:/bin:/sbin:/etc:/proc:/var";
/**
* Create a sandboxed execFile function configured for a specific conversion job.
*/
export function createSandboxedExec(config: SandboxConfig): ExecFileFn {
const runnerPath = getLandlockRunnerPath();
const isStrict = process.env.SANDBOX_STRICT === "true";
const resolvedInput = resolve(config.inputDir);
const resolvedOutput = resolve(config.outputDir);
const resolvedTemp = resolve(config.tempDir);
const sandboxedExec = ((
cmd: string,
args: string[],
optionsOrCallback?: ExecFileOptions | ExecFileCallback,
callbackOrOptions?: ExecFileOptions | ExecFileCallback,
): ChildProcess | void => {
let options: ExecFileOptions = {};
let callback: ExecFileCallback | undefined;
if (typeof optionsOrCallback === "function") {
callback = optionsOrCallback;
if (typeof callbackOrOptions === "object" && callbackOrOptions !== null) {
options = callbackOrOptions;
}
} else {
if (typeof optionsOrCallback === "object" && optionsOrCallback !== null) {
options = optionsOrCallback;
}
if (typeof callbackOrOptions === "function") {
callback = callbackOrOptions;
}
}
const cb: ExecFileCallback = callback ?? (() => {});
if (!runnerPath) {
if (isStrict) {
const error = new Error(
"SANDBOX_STRICT is enabled, but landlock-runner is not available on this system.",
);
cb(error, "", error.message);
return;
}
return (
execFileOriginal as (
c: string,
a: string[],
o: ExecFileOptions,
f: ExecFileCallback,
) => ChildProcess
)(cmd, args, options, cb);
}
// Build landlock-runner arguments
const runnerArgs: string[] = [
"--ro",
DEFAULT_SYSTEM_RO,
"--ro",
resolvedInput,
"--rw",
resolvedOutput,
"--rw",
`${resolvedTemp}:/dev`,
];
if (!config.allowNet) {
runnerArgs.push("--no-net");
}
if (isStrict) {
runnerArgs.push("--strict");
}
runnerArgs.push("--", cmd, ...args);
// Provide isolated per-job TMPDIR and HOME
const sandboxedEnv = {
...process.env,
...options.env,
TMPDIR: resolvedTemp,
TEMP: resolvedTemp,
TMP: resolvedTemp,
HOME: resolvedTemp,
};
const sandboxedOptions: ExecFileOptions = {
...options,
env: sandboxedEnv,
};
return (
execFileOriginal as (
c: string,
a: string[],
o: ExecFileOptions,
f: ExecFileCallback,
) => ChildProcess
)(runnerPath, runnerArgs, sandboxedOptions, cb);
}) as unknown as ExecFileFn;
return sandboxedExec;
}
+12 -3
View File
@@ -1,23 +1,24 @@
import { rmSync } from "node:fs";
import { html } from "@elysiajs/html";
import { staticPlugin } from "@elysiajs/static";
import { Elysia } from "elysia";
import "./helpers/printVersions";
import { rmSync } from "node:fs";
import db from "./db/db";
import { Jobs } from "./db/types";
import { AUTO_DELETE_EVERY_N_HOURS, WEBROOT } from "./helpers/env";
import "./helpers/printVersions";
import { getLandlockRunnerPath, isSandboxAvailable } from "./helpers/sandbox";
import { chooseConverter } from "./pages/chooseConverter";
import { convert } from "./pages/convert";
import { deleteFile } from "./pages/deleteFile";
import { deleteJob } from "./pages/deleteJob";
import { download } from "./pages/download";
import { healthcheck } from "./pages/healthcheck";
import { history } from "./pages/history";
import { listConverters } from "./pages/listConverters";
import { results } from "./pages/results";
import { root } from "./pages/root";
import { upload } from "./pages/upload";
import { user } from "./pages/user";
import { healthcheck } from "./pages/healthcheck";
export const uploadsDir = "./data/uploads/";
export const outputDir = "./data/output/";
@@ -73,6 +74,14 @@ app.listen(process.env.PORT || 3000);
console.log(`🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}${WEBROOT}`);
if (isSandboxAvailable()) {
console.log(`🔒 Landlock sandbox: ENABLED (${getLandlockRunnerPath()})`);
} else if (process.env.SANDBOX_STRICT === "true") {
console.warn("⚠️ Landlock sandbox: STRICT mode enabled but runner unavailable!");
} else {
console.log("️ Landlock sandbox: NOT AVAILABLE (running unsandboxed)");
}
const clearJobs = () => {
const jobs = db
.query("SELECT * FROM jobs WHERE date_created < ?")
+307
View File
@@ -0,0 +1,307 @@
#define _GNU_SOURCE
#include <linux/landlock.h>
#include <linux/prctl.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <stdbool.h>
#include <stdint.h>
#ifndef landlock_create_ruleset
static inline int landlock_create_ruleset(
const struct landlock_ruleset_attr *const attr,
const size_t size, const __u32 flags) {
return syscall(__NR_landlock_create_ruleset, attr, size, flags);
}
#endif
#ifndef landlock_add_rule
static inline int landlock_add_rule(
const int ruleset_fd, const enum landlock_rule_type rule_type,
const void *const rule_attr, const __u32 flags) {
return syscall(__NR_landlock_add_rule, ruleset_fd, rule_type, rule_attr, flags);
}
#endif
#ifndef landlock_restrict_self
static inline int landlock_restrict_self(
const int ruleset_fd, const __u32 flags) {
return syscall(__NR_landlock_restrict_self, ruleset_fd, flags);
}
#endif
#ifndef LANDLOCK_RULE_PATH_BENEATH
#define LANDLOCK_RULE_PATH_BENEATH 1
#endif
#ifndef LANDLOCK_ACCESS_FS_EXECUTE
#define LANDLOCK_ACCESS_FS_EXECUTE (1ULL << 0)
#define LANDLOCK_ACCESS_FS_WRITE_FILE (1ULL << 1)
#define LANDLOCK_ACCESS_FS_READ_FILE (1ULL << 2)
#define LANDLOCK_ACCESS_FS_READ_DIR (1ULL << 3)
#define LANDLOCK_ACCESS_FS_REMOVE_DIR (1ULL << 4)
#define LANDLOCK_ACCESS_FS_REMOVE_FILE (1ULL << 5)
#define LANDLOCK_ACCESS_FS_MAKE_CHAR (1ULL << 6)
#define LANDLOCK_ACCESS_FS_MAKE_DIR (1ULL << 7)
#define LANDLOCK_ACCESS_FS_MAKE_REG (1ULL << 8)
#define LANDLOCK_ACCESS_FS_MAKE_SOCK (1ULL << 9)
#define LANDLOCK_ACCESS_FS_MAKE_FIFO (1ULL << 10)
#define LANDLOCK_ACCESS_FS_MAKE_BLOCK (1ULL << 11)
#define LANDLOCK_ACCESS_FS_MAKE_SYM (1ULL << 12)
#endif
#ifndef LANDLOCK_ACCESS_FS_REFER
#define LANDLOCK_ACCESS_FS_REFER (1ULL << 13)
#endif
#ifndef LANDLOCK_ACCESS_FS_TRUNCATE
#define LANDLOCK_ACCESS_FS_TRUNCATE (1ULL << 14)
#endif
#ifndef LANDLOCK_ACCESS_FS_IOCTL_DEV
#define LANDLOCK_ACCESS_FS_IOCTL_DEV (1ULL << 15)
#endif
#ifndef LANDLOCK_ACCESS_NET_BIND_TCP
#define LANDLOCK_ACCESS_NET_BIND_TCP (1ULL << 0)
#define LANDLOCK_ACCESS_NET_CONNECT_TCP (1ULL << 1)
#endif
struct path_node {
char *path;
struct path_node *next;
};
static void add_node(struct path_node **head, const char *path) {
if (!path || path[0] == '\0') return;
struct path_node *node = malloc(sizeof(struct path_node));
if (!node) return;
node->path = strdup(path);
node->next = *head;
*head = node;
}
static void add_colon_separated(struct path_node **head, const char *path_list) {
if (!path_list || path_list[0] == '\0') return;
char *copy = strdup(path_list);
if (!copy) return;
char *saveptr = NULL;
char *token = strtok_r(copy, ":", &saveptr);
while (token != NULL) {
if (strlen(token) > 0) {
add_node(head, token);
}
token = strtok_r(NULL, ":", &saveptr);
}
free(copy);
}
static void free_nodes(struct path_node *head) {
while (head) {
struct path_node *next = head->next;
free(head->path);
free(head);
head = next;
}
}
static void add_path_rule(int ruleset_fd, const char *path, __u64 allowed_access, int abi) {
if (!path || path[0] == '\0') return;
int fd = open(path, O_PATH | O_CLOEXEC);
if (fd < 0) {
// Path does not exist or cannot be opened, ignore non-fatal missing paths
return;
}
struct stat st;
if (fstat(fd, &st) < 0) {
close(fd);
return;
}
__u64 access = allowed_access;
if (!S_ISDIR(st.st_mode)) {
// Character devices, regular files, sockets, etc. cannot take directory-only access flags
__u64 file_mask = LANDLOCK_ACCESS_FS_EXECUTE |
LANDLOCK_ACCESS_FS_READ_FILE |
LANDLOCK_ACCESS_FS_WRITE_FILE;
if (abi >= 2) file_mask |= LANDLOCK_ACCESS_FS_REFER;
if (abi >= 3) file_mask |= LANDLOCK_ACCESS_FS_TRUNCATE;
if (abi >= 5) file_mask |= LANDLOCK_ACCESS_FS_IOCTL_DEV;
access &= file_mask;
}
struct landlock_path_beneath_attr path_beneath = {
.parent_fd = fd,
.allowed_access = access,
};
landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, &path_beneath, 0);
close(fd);
}
static void print_usage(const char *prog) {
fprintf(stderr,
"Usage: %s [options] -- <command> [args...]\n\n"
"Options:\n"
" --ro <paths> Colon-separated list of read-only paths (can be repeated)\n"
" --rw <paths> Colon-separated list of read-write paths (can be repeated)\n"
" --no-net Disable all network operations (TCP connect & bind)\n"
" --strict Fail if Landlock is unsupported instead of falling back\n"
" --version Print Landlock ABI version and exit\n"
" --help Print this help message\n",
prog);
}
int main(int argc, char *argv[]) {
struct path_node *ro_head = NULL;
struct path_node *rw_head = NULL;
bool no_net = false;
bool strict = false;
int cmd_idx = -1;
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "--") == 0) {
cmd_idx = i + 1;
break;
} else if (strcmp(argv[i], "--version") == 0) {
int abi = landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION);
if (abi < 0) {
printf("Landlock unsupported (%s)\n", strerror(errno));
return 1;
}
printf("Landlock ABI v%d\n", abi);
return 0;
} else if (strcmp(argv[i], "--help") == 0) {
print_usage(argv[0]);
return 0;
} else if (strcmp(argv[i], "--ro") == 0) {
if (++i >= argc) {
fprintf(stderr, "landlock-runner: --ro requires an argument\n");
return 1;
}
add_colon_separated(&ro_head, argv[i]);
} else if (strcmp(argv[i], "--rw") == 0) {
if (++i >= argc) {
fprintf(stderr, "landlock-runner: --rw requires an argument\n");
return 1;
}
add_colon_separated(&rw_head, argv[i]);
} else if (strcmp(argv[i], "--no-net") == 0) {
no_net = true;
} else if (strcmp(argv[i], "--strict") == 0) {
strict = true;
} else {
fprintf(stderr, "landlock-runner: unrecognized option '%s'\n", argv[i]);
print_usage(argv[0]);
return 1;
}
}
if (cmd_idx < 0 || cmd_idx >= argc) {
fprintf(stderr, "landlock-runner: no command specified after '--'\n");
free_nodes(ro_head);
free_nodes(rw_head);
return 1;
}
// Query Landlock ABI version
int abi = landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION);
if (abi < 1) {
free_nodes(ro_head);
free_nodes(rw_head);
if (strict) {
fprintf(stderr, "landlock-runner error: Landlock LSM is not supported or disabled on this kernel (errno=%d: %s)\n",
errno, strerror(errno));
return 1;
}
// Fallback: run command without Landlock
execvp(argv[cmd_idx], &argv[cmd_idx]);
perror("landlock-runner: execvp");
return 127;
}
// Build supported filesystem access rights
__u64 fs_ro_access = LANDLOCK_ACCESS_FS_EXECUTE |
LANDLOCK_ACCESS_FS_READ_FILE |
LANDLOCK_ACCESS_FS_READ_DIR;
if (abi >= 2) fs_ro_access |= LANDLOCK_ACCESS_FS_REFER;
__u64 fs_rw_access = fs_ro_access |
LANDLOCK_ACCESS_FS_WRITE_FILE |
LANDLOCK_ACCESS_FS_REMOVE_DIR |
LANDLOCK_ACCESS_FS_REMOVE_FILE |
LANDLOCK_ACCESS_FS_MAKE_CHAR |
LANDLOCK_ACCESS_FS_MAKE_DIR |
LANDLOCK_ACCESS_FS_MAKE_REG |
LANDLOCK_ACCESS_FS_MAKE_SOCK |
LANDLOCK_ACCESS_FS_MAKE_FIFO |
LANDLOCK_ACCESS_FS_MAKE_BLOCK |
LANDLOCK_ACCESS_FS_MAKE_SYM;
if (abi >= 3) fs_rw_access |= LANDLOCK_ACCESS_FS_TRUNCATE;
if (abi >= 5) fs_rw_access |= LANDLOCK_ACCESS_FS_IOCTL_DEV;
struct landlock_ruleset_attr attr = {
.handled_access_fs = fs_rw_access,
};
if (no_net && abi >= 4) {
attr.handled_access_net = LANDLOCK_ACCESS_NET_BIND_TCP | LANDLOCK_ACCESS_NET_CONNECT_TCP;
}
int ruleset_fd = landlock_create_ruleset(&attr, sizeof(attr), 0);
if (ruleset_fd < 0) {
free_nodes(ro_head);
free_nodes(rw_head);
if (strict) {
fprintf(stderr, "landlock-runner error: failed to create ruleset: %s\n", strerror(errno));
return 1;
}
execvp(argv[cmd_idx], &argv[cmd_idx]);
perror("landlock-runner: execvp");
return 127;
}
// Add read-only paths
for (struct path_node *curr = ro_head; curr; curr = curr->next) {
add_path_rule(ruleset_fd, curr->path, fs_ro_access, abi);
}
free_nodes(ro_head);
// Add read-write paths
for (struct path_node *curr = rw_head; curr; curr = curr->next) {
add_path_rule(ruleset_fd, curr->path, fs_rw_access, abi);
}
free_nodes(rw_head);
// Prevent processes from gaining new privileges
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0) {
if (strict) {
perror("landlock-runner: prctl(PR_SET_NO_NEW_PRIVS)");
close(ruleset_fd);
return 1;
}
}
// Apply the Landlock sandbox to the current process and all future children
if (landlock_restrict_self(ruleset_fd, 0) < 0) {
if (strict) {
perror("landlock-runner: landlock_restrict_self");
close(ruleset_fd);
return 1;
}
}
close(ruleset_fd);
// Execute the target program
execvp(argv[cmd_idx], &argv[cmd_idx]);
perror("landlock-runner: execvp");
return 127;
}
+170
View File
@@ -0,0 +1,170 @@
import { afterEach, beforeEach, expect, test } from "bun:test";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
createSandboxedExec,
getLandlockRunnerPath,
isSandboxAvailable,
resetCachedRunnerPath,
} from "../../src/helpers/sandbox";
const originalEnv = { ...process.env };
beforeEach(() => {
resetCachedRunnerPath();
delete process.env.SANDBOX_DISABLED;
delete process.env.SANDBOX_STRICT;
delete process.env.LANDLOCK_RUNNER_PATH;
});
afterEach(() => {
resetCachedRunnerPath();
process.env = { ...originalEnv };
});
test("isSandboxAvailable returns false when SANDBOX_DISABLED is true", () => {
process.env.SANDBOX_DISABLED = "true";
expect(isSandboxAvailable()).toBe(false);
expect(getLandlockRunnerPath()).toBeNull();
});
test("getLandlockRunnerPath respects LANDLOCK_RUNNER_PATH if it exists", () => {
const runner = getLandlockRunnerPath();
if (runner) {
process.env.LANDLOCK_RUNNER_PATH = runner;
resetCachedRunnerPath();
expect(getLandlockRunnerPath()).toBe(runner);
}
});
test("createSandboxedExec sets TMPDIR and HOME in environment", async () => {
const testDir = join(tmpdir(), `sandbox-test-env-${Date.now()}`);
mkdirSync(testDir, { recursive: true });
const inputDir = join(testDir, "input");
const outputDir = join(testDir, "output");
const tempDir = join(testDir, "temp");
mkdirSync(inputDir);
mkdirSync(outputDir);
mkdirSync(tempDir);
const sandboxedExec = createSandboxedExec({
inputDir,
outputDir,
tempDir,
});
await new Promise<void>((resolve) => {
sandboxedExec("/bin/sh", ["-c", "echo $TMPDIR"], (_err, stdout) => {
expect(stdout.trim()).toBe(tempDir);
resolve();
});
});
rmSync(testDir, { recursive: true, force: true });
});
test("createSandboxedExec handles SANDBOX_STRICT when runner is missing", async () => {
process.env.SANDBOX_DISABLED = "true";
process.env.SANDBOX_STRICT = "true";
const sandboxedExec = createSandboxedExec({
inputDir: "/tmp/in",
outputDir: "/tmp/out",
tempDir: "/tmp/temp",
});
let errorCaptured: Error | null = null;
sandboxedExec("some-cmd", ["arg"], (err) => {
errorCaptured = err;
});
expect(errorCaptured).not.toBeNull();
expect(errorCaptured?.message).toContain("SANDBOX_STRICT is enabled");
});
test("createSandboxedExec passes options before callback correctly", async () => {
const testDir = join(tmpdir(), `sandbox-test-opts-${Date.now()}`);
mkdirSync(testDir, { recursive: true });
const sandboxedExec = createSandboxedExec({
inputDir: testDir,
outputDir: testDir,
tempDir: testDir,
});
let called = false;
sandboxedExec("echo", ["hi"], { maxBuffer: 1024 }, () => {
called = true;
});
// Give child process time if real exec was spawned
await new Promise((r) => setTimeout(r, 50));
expect(called).toBe(true);
rmSync(testDir, { recursive: true, force: true });
});
test("landlock-runner enforces real sandboxing when available", async () => {
const runnerPath = getLandlockRunnerPath();
if (!runnerPath) {
// Skip if landlock is not built/available on host
return;
}
const testDir = join(tmpdir(), `landlock-real-test-${Date.now()}`);
const inputDir = join(testDir, "input");
const outputDir = join(testDir, "output");
const tempDir = join(testDir, "temp");
mkdirSync(inputDir, { recursive: true });
mkdirSync(outputDir, { recursive: true });
mkdirSync(tempDir, { recursive: true });
const inputFile = join(inputDir, "sample.txt");
const outputFile = join(outputDir, "out.txt");
writeFileSync(inputFile, "content from input");
const sandboxedExec = createSandboxedExec({
inputDir,
outputDir,
tempDir,
});
// 1. Reading from inputDir and writing to outputDir must SUCCEED
const success = await new Promise<boolean>((resolve) => {
sandboxedExec("/bin/sh", ["-c", `cat "${inputFile}" > "${outputFile}"`], (err) => {
resolve(!err);
});
});
expect(success).toBe(true);
expect(existsSync(outputFile)).toBe(true);
// 2. Writing to unpermitted directory (parent testDir) must FAIL
const forbiddenFile = join(testDir, "forbidden.txt");
const failure = await new Promise<boolean>((resolve) => {
sandboxedExec("/bin/sh", ["-c", `echo "should fail" > "${forbiddenFile}"`], (err) => {
resolve(!!err);
});
});
expect(failure).toBe(true);
expect(existsSync(forbiddenFile)).toBe(false);
// 3. Network operations must be blocked (Permission denied)
const netBlocked = await new Promise<boolean>((resolve) => {
sandboxedExec(
"/usr/bin/python3",
["-c", "import socket; s = socket.socket(); s.connect(('127.0.0.1', 80))"],
(err) => {
resolve(!!err);
},
);
});
expect(netBlocked).toBe(true);
rmSync(testDir, { recursive: true, force: true });
});