Mercurial
diff mrjunejune/latex_renderer.c @ 244:b8aa08503378
[tools] Add sandboxed online LaTeX editor
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 03 Aug 2026 16:56:25 -0700 |
| parents | |
| children | 70f2a3dafc1c |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/latex_renderer.c Mon Aug 03 16:56:25 2026 -0700 @@ -0,0 +1,718 @@ +#include "mrjunejune/latex_renderer.h" + +#ifdef __linux__ + +#include <dirent.h> +#include <errno.h> +#include <fcntl.h> +#include <linux/audit.h> +#include <linux/filter.h> +#include <linux/landlock.h> +#include <linux/seccomp.h> +#include <limits.h> +#include <poll.h> +#include <signal.h> +#include <stdbool.h> +#include <stddef.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/prctl.h> +#include <sys/resource.h> +#include <sys/stat.h> +#include <sys/syscall.h> +#include <sys/types.h> +#include <sys/wait.h> +#include <time.h> +#include <unistd.h> + +#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 + +#if defined(__x86_64__) +#define LATEX_AUDIT_ARCH AUDIT_ARCH_X86_64 +#elif defined(__aarch64__) +#define LATEX_AUDIT_ARCH AUDIT_ARCH_AARCH64 +#else +#define LATEX_AUDIT_ARCH 0 +#endif + +#define LATEX_DIAGNOSTICS_MAX_BYTES (16 * 1024) +#define LATEX_CPU_TIMEOUT_SECONDS 5 +#define LATEX_WALL_TIMEOUT_SECONDS 8 + +static char *duplicate_message(const char *message) +{ + size_t length = strlen(message); + char *copy = malloc(length + 1); + if (copy) + memcpy(copy, message, length + 1); + return copy; +} + +static Latex_Render_Result result_with_message( + Latex_Render_Status status, + const char *message) +{ + return (Latex_Render_Result){ + .status = status, + .diagnostics = duplicate_message(message), + }; +} + +static bool write_all(int fd, const uint8_t *data, size_t size) +{ + while (size > 0) + { + ssize_t written = write(fd, data, size); + if (written < 0) + { + if (errno == EINTR) + continue; + return false; + } + data += written; + size -= (size_t)written; + } + return true; +} + +static int remove_tree(const char *path, unsigned depth) +{ + if (depth > 16) + return -1; + + struct stat status; + if (lstat(path, &status) != 0) + return errno == ENOENT ? 0 : -1; + if (!S_ISDIR(status.st_mode)) + return unlink(path); + + DIR *directory = opendir(path); + if (!directory) + return -1; + + int result = 0; + struct dirent *entry; + while ((entry = readdir(directory)) != NULL) + { + if (strcmp(entry->d_name, ".") == 0 || + strcmp(entry->d_name, "..") == 0) + continue; + + char child[1024]; + int length = snprintf( + child, + sizeof(child), + "%s/%s", + path, + entry->d_name); + if (length < 0 || + (size_t)length >= sizeof(child) || + remove_tree(child, depth + 1) != 0) + { + result = -1; + break; + } + } + closedir(directory); + return result == 0 ? rmdir(path) : result; +} + +static bool set_limit(int resource, rlim_t value) +{ + struct rlimit limit = { + .rlim_cur = value, + .rlim_max = value, + }; + return setrlimit(resource, &limit) == 0; +} + +static bool add_landlock_path_rule( + int ruleset_fd, + const char *path, + uint64_t access, + bool required) +{ + int path_fd = open(path, O_PATH | O_CLOEXEC); + if (path_fd < 0) + return !required && errno == ENOENT; + + struct stat status; + bool ok = fstat(path_fd, &status) == 0; + if (ok && !S_ISDIR(status.st_mode)) + { + access &= LANDLOCK_ACCESS_FS_EXECUTE | + LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_WRITE_FILE; + } + + struct landlock_path_beneath_attr rule = { + .allowed_access = access, + .parent_fd = path_fd, + }; + if (ok) + ok = syscall( + SYS_landlock_add_rule, + ruleset_fd, + LANDLOCK_RULE_PATH_BENEATH, + &rule, + 0) == 0; + close(path_fd); + return ok; +} + +static bool apply_filesystem_sandbox(const char *job_directory) +{ + int abi = syscall( + SYS_landlock_create_ruleset, + NULL, + 0, + LANDLOCK_CREATE_RULESET_VERSION); + if (abi < 1) + return false; + + uint64_t read_access = + LANDLOCK_ACCESS_FS_EXECUTE | + LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_READ_DIR; + uint64_t write_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 >= 2) + write_access |= LANDLOCK_ACCESS_FS_REFER; + if (abi >= 3) + write_access |= LANDLOCK_ACCESS_FS_TRUNCATE; + + struct landlock_ruleset_attr ruleset = { + .handled_access_fs = read_access | write_access, + }; + int ruleset_fd = syscall( + SYS_landlock_create_ruleset, + &ruleset, + sizeof(ruleset), + 0); + if (ruleset_fd < 0) + return false; + + const char *read_only_directories[] = { + "/usr", + "/lib", + "/lib64", + "/bin", + "/var/lib/texmf", + "/etc/texmf", + "/etc/fonts", + }; + bool ok = true; + for (size_t i = 0; + ok && i < sizeof(read_only_directories) / sizeof(read_only_directories[0]); + i++) + { + ok = add_landlock_path_rule( + ruleset_fd, + read_only_directories[i], + read_access, + false); + } + if (ok) + ok = add_landlock_path_rule( + ruleset_fd, + "/etc/ld.so.cache", + LANDLOCK_ACCESS_FS_READ_FILE, + false); + if (ok) + ok = add_landlock_path_rule( + ruleset_fd, + "/dev/null", + LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_WRITE_FILE, + true); + if (ok) + ok = add_landlock_path_rule( + ruleset_fd, + "/dev/urandom", + LANDLOCK_ACCESS_FS_READ_FILE, + false); + if (ok) + ok = add_landlock_path_rule( + ruleset_fd, + job_directory, + read_access | write_access, + true); + if (ok) + ok = prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == 0; + if (ok) + ok = syscall( + SYS_landlock_restrict_self, + ruleset_fd, + 0) == 0; + close(ruleset_fd); + return ok; +} + +static bool apply_network_sandbox(void) +{ + if (LATEX_AUDIT_ARCH == 0) + return false; + + struct sock_filter filters[64]; + size_t count = 0; +#define ADD_FILTER(value) filters[count++] = (struct sock_filter)value +#define DENY_SYSCALL(number) \ + do { \ + ADD_FILTER(BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, number, 0, 1)); \ + ADD_FILTER(BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM)); \ + } while (0) + + ADD_FILTER(BPF_STMT( + BPF_LD | BPF_W | BPF_ABS, + offsetof(struct seccomp_data, arch))); + ADD_FILTER(BPF_JUMP( + BPF_JMP | BPF_JEQ | BPF_K, + LATEX_AUDIT_ARCH, + 1, + 0)); + ADD_FILTER(BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS)); + ADD_FILTER(BPF_STMT( + BPF_LD | BPF_W | BPF_ABS, + offsetof(struct seccomp_data, nr))); + DENY_SYSCALL(__NR_socket); + DENY_SYSCALL(__NR_socketpair); + DENY_SYSCALL(__NR_connect); + DENY_SYSCALL(__NR_accept); + DENY_SYSCALL(__NR_accept4); + DENY_SYSCALL(__NR_bind); + DENY_SYSCALL(__NR_listen); + DENY_SYSCALL(__NR_sendto); + DENY_SYSCALL(__NR_recvfrom); + DENY_SYSCALL(__NR_sendmsg); + DENY_SYSCALL(__NR_recvmsg); + ADD_FILTER(BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)); + + struct sock_fprog program = { + .len = (unsigned short)count, + .filter = filters, + }; +#undef DENY_SYSCALL +#undef ADD_FILTER + return prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &program) == 0; +} + +static void configure_child_environment(const char *job_directory) +{ + clearenv(); + setenv("HOME", job_directory, 1); + setenv("TMPDIR", job_directory, 1); + setenv("TEXMFVAR", job_directory, 1); + setenv("TEXMFCONFIG", job_directory, 1); + setenv("openin_any", "p", 1); + setenv("openout_any", "p", 1); + setenv("shell_escape", "0", 1); + setenv("SOURCE_DATE_EPOCH", "0", 1); + setenv("LANG", "C.UTF-8", 1); +} + +static void close_inherited_descriptors(void) +{ +#ifdef SYS_close_range + if (syscall(SYS_close_range, 3U, UINT_MAX, 0) == 0) + return; +#endif + struct rlimit limit; + rlim_t maximum = 65536; + if (getrlimit(RLIMIT_NOFILE, &limit) == 0 && + limit.rlim_cur != RLIM_INFINITY && + limit.rlim_cur < maximum) + maximum = limit.rlim_cur; + for (int fd = 3; fd < (int)maximum; fd++) + close(fd); +} + +static void run_compiler_child( + const char *compiler, + const char *job_directory, + int output_fd) +{ + if (dup2(output_fd, STDOUT_FILENO) < 0 || + dup2(output_fd, STDERR_FILENO) < 0) + _exit(120); + if (output_fd != STDOUT_FILENO && output_fd != STDERR_FILENO) + close(output_fd); + close_inherited_descriptors(); + if (setpgid(0, 0) != 0 || + prctl(PR_SET_PDEATHSIG, SIGKILL) != 0 || + getppid() == 1) + _exit(119); + if (chdir(job_directory) != 0) + _exit(121); + + configure_child_environment(job_directory); + if (!set_limit(RLIMIT_CPU, LATEX_CPU_TIMEOUT_SECONDS) || + !set_limit(RLIMIT_AS, 768 * 1024 * 1024) || + !set_limit(RLIMIT_FSIZE, LATEX_PDF_MAX_BYTES) || + !set_limit(RLIMIT_NOFILE, 64) || + !set_limit(RLIMIT_NPROC, 1)) + _exit(122); + if (!apply_filesystem_sandbox(job_directory) || + !apply_network_sandbox()) + _exit(123); + + char *const arguments[] = { + (char *)compiler, + "-interaction=nonstopmode", + "-halt-on-error", + "-no-shell-escape", + "-file-line-error", + "-output-directory=.", + "document.tex", + NULL, + }; + execv(compiler, arguments); + _exit(errno == ENOENT ? 127 : 126); +} + +static double elapsed_seconds( + const struct timespec *start, + const struct timespec *now) +{ + return (double)(now->tv_sec - start->tv_sec) + + (double)(now->tv_nsec - start->tv_nsec) / 1000000000.0; +} + +static int wait_for_compiler( + pid_t child, + int output_fd, + char *diagnostics, + size_t diagnostics_size, + bool *timed_out) +{ + int flags = fcntl(output_fd, F_GETFL, 0); + if (flags >= 0) + fcntl(output_fd, F_SETFL, flags | O_NONBLOCK); + + size_t used = 0; + int child_status = 0; + bool child_done = false; + struct timespec start; + clock_gettime(CLOCK_MONOTONIC, &start); + + while (!child_done) + { + char buffer[2048]; + ssize_t count; + while ((count = read(output_fd, buffer, sizeof(buffer))) > 0) + { + size_t available = diagnostics_size - used - 1; + size_t copy_size = (size_t)count < available ? (size_t)count : available; + if (copy_size > 0) + { + memcpy(diagnostics + used, buffer, copy_size); + used += copy_size; + } + } + + pid_t waited = waitpid(child, &child_status, WNOHANG); + if (waited == child) + { + child_done = true; + break; + } + if (waited < 0 && errno != EINTR) + break; + + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + if (elapsed_seconds(&start, &now) >= LATEX_WALL_TIMEOUT_SECONDS) + { + *timed_out = true; + kill(-child, SIGKILL); + while (waitpid(child, &child_status, 0) < 0 && errno == EINTR) + { + } + child_done = true; + break; + } + + struct pollfd poll_fd = { + .fd = output_fd, + .events = POLLIN, + }; + poll(&poll_fd, 1, 50); + } + + char buffer[2048]; + ssize_t count; + while ((count = read(output_fd, buffer, sizeof(buffer))) > 0) + { + size_t available = diagnostics_size - used - 1; + size_t copy_size = (size_t)count < available ? (size_t)count : available; + if (copy_size > 0) + { + memcpy(diagnostics + used, buffer, copy_size); + used += copy_size; + } + } + diagnostics[used] = '\0'; + kill(-child, SIGKILL); + return child_status; +} + +static bool read_pdf( + const char *path, + uint8_t **data, + size_t *size) +{ + int fd = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0) + return false; + + struct stat status; + bool ok = fstat(fd, &status) == 0 && + S_ISREG(status.st_mode) && + status.st_size > 4 && + status.st_size <= LATEX_PDF_MAX_BYTES; + uint8_t *content = NULL; + if (ok) + { + content = malloc((size_t)status.st_size); + ok = content != NULL; + } + size_t offset = 0; + while (ok && offset < (size_t)status.st_size) + { + ssize_t count = read( + fd, + content + offset, + (size_t)status.st_size - offset); + if (count < 0 && errno == EINTR) + continue; + if (count <= 0) + { + ok = false; + break; + } + offset += (size_t)count; + } + close(fd); + + if (!ok || + memcmp(content, "%PDF-", 5) != 0) + { + free(content); + return false; + } + *data = content; + *size = offset; + return true; +} + +Latex_Render_Result Latex_Render(const uint8_t *source, size_t source_size) +{ + if (!source || source_size == 0) + return result_with_message( + LATEX_RENDER_INVALID_INPUT, + "LaTeX source is required."); + if (source_size > LATEX_SOURCE_MAX_BYTES) + return result_with_message( + LATEX_RENDER_INVALID_INPUT, + "LaTeX source exceeds the 64 KiB limit."); + if (memchr(source, '\0', source_size)) + return result_with_message( + LATEX_RENDER_INVALID_INPUT, + "LaTeX source cannot contain NUL bytes."); + + const char *compiler = "/usr/bin/pdflatex"; + if (access(compiler, X_OK) != 0) + return result_with_message( + LATEX_RENDER_COMPILER_UNAVAILABLE, + "The server LaTeX compiler is unavailable."); + + char job_directory[] = "/tmp/mrjunejune-latex-XXXXXX"; + if (!mkdtemp(job_directory)) + return result_with_message( + LATEX_RENDER_INTERNAL_ERROR, + "Unable to create the LaTeX workspace."); + + char source_path[512]; + snprintf( + source_path, + sizeof(source_path), + "%s/document.tex", + job_directory); + int source_fd = open( + source_path, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + 0600); + if (source_fd < 0 || !write_all(source_fd, source, source_size)) + { + if (source_fd >= 0) + close(source_fd); + remove_tree(job_directory, 0); + return result_with_message( + LATEX_RENDER_INTERNAL_ERROR, + "Unable to write the LaTeX source."); + } + close(source_fd); + + int output_pipe[2]; + if (pipe2(output_pipe, O_CLOEXEC) != 0) + { + remove_tree(job_directory, 0); + return result_with_message( + LATEX_RENDER_INTERNAL_ERROR, + "Unable to capture compiler output."); + } + + pid_t child = fork(); + if (child < 0) + { + close(output_pipe[0]); + close(output_pipe[1]); + remove_tree(job_directory, 0); + return result_with_message( + LATEX_RENDER_INTERNAL_ERROR, + "Unable to start the LaTeX compiler."); + } + if (child == 0) + { + close(output_pipe[0]); + run_compiler_child(compiler, job_directory, output_pipe[1]); + } + setpgid(child, child); + + close(output_pipe[1]); + char *diagnostics = calloc(1, LATEX_DIAGNOSTICS_MAX_BYTES + 1); + if (!diagnostics) + { + kill(-child, SIGKILL); + waitpid(child, NULL, 0); + close(output_pipe[0]); + remove_tree(job_directory, 0); + return result_with_message( + LATEX_RENDER_INTERNAL_ERROR, + "Unable to allocate compiler diagnostics."); + } + + bool timed_out = false; + int child_status = wait_for_compiler( + child, + output_pipe[0], + diagnostics, + LATEX_DIAGNOSTICS_MAX_BYTES + 1, + &timed_out); + close(output_pipe[0]); + + Latex_Render_Result result = { + .status = LATEX_RENDER_INTERNAL_ERROR, + .diagnostics = diagnostics, + }; + if (timed_out) + { + result.status = LATEX_RENDER_TIMEOUT; + free(result.diagnostics); + result.diagnostics = duplicate_message( + "Compilation exceeded the 8 second limit."); + } + else if (WIFEXITED(child_status) && WEXITSTATUS(child_status) == 127) + { + result.status = LATEX_RENDER_COMPILER_UNAVAILABLE; + } + else if (WIFEXITED(child_status) && WEXITSTATUS(child_status) == 123) + { + result.status = LATEX_RENDER_SANDBOX_ERROR; + free(result.diagnostics); + result.diagnostics = duplicate_message( + "The server could not apply the LaTeX security sandbox."); + } + else if (WIFSIGNALED(child_status) && + (WTERMSIG(child_status) == SIGXCPU || + WTERMSIG(child_status) == SIGKILL)) + { + result.status = LATEX_RENDER_TIMEOUT; + free(result.diagnostics); + result.diagnostics = duplicate_message( + "Compilation exceeded its CPU or memory limit."); + } + else if (!WIFEXITED(child_status) || WEXITSTATUS(child_status) != 0) + { + result.status = LATEX_RENDER_COMPILE_ERROR; + if (!result.diagnostics[0]) + { + free(result.diagnostics); + result.diagnostics = duplicate_message("LaTeX compilation failed."); + } + } + else + { + char pdf_path[512]; + snprintf(pdf_path, sizeof(pdf_path), "%s/document.pdf", job_directory); + if (read_pdf(pdf_path, &result.pdf_data, &result.pdf_size)) + { + result.status = LATEX_RENDER_OK; + free(result.diagnostics); + result.diagnostics = NULL; + } + else + { + result.status = LATEX_RENDER_INTERNAL_ERROR; + free(result.diagnostics); + result.diagnostics = duplicate_message( + "The compiler did not produce a valid PDF."); + } + } + + remove_tree(job_directory, 0); + return result; +} + +void Latex_Render_Result_Destroy(Latex_Render_Result *result) +{ + if (!result) + return; + free(result->pdf_data); + free(result->diagnostics); + memset(result, 0, sizeof(*result)); +} + +#else + +#include <stdlib.h> +#include <string.h> + +Latex_Render_Result Latex_Render(const uint8_t *source, size_t source_size) +{ + (void)source; + (void)source_size; + const char *message = + "The LaTeX security sandbox is only available on Linux."; + Latex_Render_Result result = { + .status = LATEX_RENDER_SANDBOX_ERROR, + .diagnostics = malloc(strlen(message) + 1), + }; + if (result.diagnostics) + strcpy(result.diagnostics, message); + return result; +} + +void Latex_Render_Result_Destroy(Latex_Render_Result *result) +{ + if (!result) + return; + free(result->pdf_data); + free(result->diagnostics); + memset(result, 0, sizeof(*result)); +} + +#endif