fix(worker): fetchfile only reads the job's own output file (bug-1508)
handle_fetchfile() runs as root in xcecd and open()ed whatever filepath the request carried. Any libxce client could read any file on a worker (/etc/shadow, keys) with a fetchfile RPC, and so could any REST user: the apiserver resolves the path from the job's user-supplied out_file/err_file, so submitting a job with out_file=/etc/shadow and then reading its output passed the apiserver's ownership check and read the file as root. - job_output_path.h: the per-task output name uv_proc.c writes, <dir>/job_<jobid>_task<N>.out, now shared by the writer (xcecd_worker) and the reader (xcecd), plus xcecd_is_job_output_path(), an exact string match for that job's names only. - fetchfile_open_checked(): rejects any other path with -EACCES, opens with O_NOFOLLOW|O_NONBLOCK|O_CLOEXEC and requires a regular file with a single link, so a symlink, hardlink, FIFO or directory planted at the output name is refused. Three new tests in test_controller_connector.c (2 RED before the fix); worker suite 10/10. Sequential HA soak (5 rounds, xcecd_virtual) PASS. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ff4e2cb84e
commit
7723a4f444
4 changed files with 245 additions and 28 deletions
71
src/worker/include/job_output_path.h
Normal file
71
src/worker/include/job_output_path.h
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
#pragma once
|
||||||
|
/*
|
||||||
|
* job_output_path.h — the worker's per-task job output file naming.
|
||||||
|
*
|
||||||
|
* uv_proc.c (xcecd_worker) creates each task's stdout/stderr file under this
|
||||||
|
* name; handle_fetchfile() (controller_connector.c, xcecd) serves reads of it
|
||||||
|
* and must accept nothing else (bug-1508). The two live in different
|
||||||
|
* executables, so the format is shared here rather than in either .c file.
|
||||||
|
*/
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "utils.h"
|
||||||
|
|
||||||
|
#ifdef AUTOSCALER_TEST
|
||||||
|
#define XCECD_JOB_OUTPUT_DIR "/shared/log"
|
||||||
|
#else
|
||||||
|
#define XCECD_JOB_OUTPUT_DIR "/tmp"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* <XCECD_JOB_OUTPUT_DIR>/job_<fmt_jobid>_task<task>.out */
|
||||||
|
static inline int xcecd_job_output_path(char* buf, size_t bufsize,
|
||||||
|
uint64_t jobid, int task)
|
||||||
|
{
|
||||||
|
char id[128];
|
||||||
|
|
||||||
|
if (buf == NULL)
|
||||||
|
return -1;
|
||||||
|
fmt_jobid(id, sizeof(id) - 1, jobid);
|
||||||
|
return snprintf(buf, bufsize, "%s/job_%s_task%d.out",
|
||||||
|
XCECD_JOB_OUTPUT_DIR, id, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* True iff `path` is exactly xcecd_job_output_path(jobid, N) for some task
|
||||||
|
* index N >= 0, spelled the way snprintf("%d") spells it (no sign, no
|
||||||
|
* leading zeros). An exact string match, so "..", "//", other jobs' files
|
||||||
|
* and any other path are all rejected without touching the filesystem. */
|
||||||
|
static inline bool xcecd_is_job_output_path(uint64_t jobid, const char* path)
|
||||||
|
{
|
||||||
|
char prefix[256];
|
||||||
|
char id[128];
|
||||||
|
const char* p;
|
||||||
|
int n;
|
||||||
|
int ndigits = 0;
|
||||||
|
|
||||||
|
if (path == NULL)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
fmt_jobid(id, sizeof(id) - 1, jobid);
|
||||||
|
n = snprintf(prefix, sizeof(prefix), "%s/job_%s_task",
|
||||||
|
XCECD_JOB_OUTPUT_DIR, id);
|
||||||
|
if (n < 0 || (size_t)n >= sizeof(prefix))
|
||||||
|
return false;
|
||||||
|
if (strncmp(path, prefix, (size_t)n) != 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
p = path + n;
|
||||||
|
while (*p >= '0' && *p <= '9') {
|
||||||
|
p++;
|
||||||
|
ndigits++;
|
||||||
|
}
|
||||||
|
if (ndigits == 0 || ndigits > 9)
|
||||||
|
return false;
|
||||||
|
if (ndigits > 1 && path[n] == '0')
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return strcmp(p, ".out") == 0;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
#include "client.h"
|
#include "client.h"
|
||||||
#include "controller_connector.h"
|
#include "controller_connector.h"
|
||||||
|
#include "job_output_path.h"
|
||||||
#include "netio.h"
|
#include "netio.h"
|
||||||
#include "uv_ipc.h"
|
#include "uv_ipc.h"
|
||||||
#include "xcecd_cgroups.h"
|
#include "xcecd_cgroups.h"
|
||||||
|
|
@ -1403,6 +1404,37 @@ static int handle_runtasks(xce_request_t* request)
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* bug-1508: this daemon runs as root, so a fetchfile request may only read
|
||||||
|
* the worker's own output file for that job (job_output_path.h), never an
|
||||||
|
* arbitrary path. The name check is an exact string match; the open then
|
||||||
|
* refuses a symlink (O_NOFOLLOW), a FIFO that would block the reply
|
||||||
|
* (O_NONBLOCK) and anything but a regular file with a single link, so a
|
||||||
|
* link planted at the output name cannot expose another file. Returns an
|
||||||
|
* fd or -errno (-EACCES for a disallowed path). */
|
||||||
|
static int fetchfile_open_checked(uint64_t jobid, const char* path)
|
||||||
|
{
|
||||||
|
struct stat st;
|
||||||
|
int fd;
|
||||||
|
|
||||||
|
if (!xcecd_is_job_output_path(jobid, path))
|
||||||
|
return -EACCES;
|
||||||
|
|
||||||
|
fd = open(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
|
||||||
|
if (fd < 0)
|
||||||
|
return -errno;
|
||||||
|
|
||||||
|
if (fstat(fd, &st) < 0) {
|
||||||
|
int err = -errno;
|
||||||
|
close(fd);
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
if (!S_ISREG(st.st_mode) || st.st_nlink != 1) {
|
||||||
|
close(fd);
|
||||||
|
return -EACCES;
|
||||||
|
}
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Handles the fetchfile request from the controller.
|
* @brief Handles the fetchfile request from the controller.
|
||||||
*
|
*
|
||||||
|
|
@ -1442,11 +1474,11 @@ static int handle_fetchfile(xce_request_t* request)
|
||||||
? 4 * 1024 * 1024
|
? 4 * 1024 * 1024
|
||||||
: msg->max_bytes;
|
: msg->max_bytes;
|
||||||
|
|
||||||
fd = open(msg->filepath, O_RDONLY);
|
fd = fetchfile_open_checked(msg->jobid, msg->filepath);
|
||||||
if (fd < 0) {
|
if (fd < 0) {
|
||||||
XCECD_LOG(XCE_LOG_ERROR, "Fetchfile: open(%s) failed: %s",
|
XCECD_LOG(XCE_LOG_ERROR, "Fetchfile: open(%s) failed: %s",
|
||||||
msg->filepath, strerror(errno));
|
msg->filepath, strerror(-fd));
|
||||||
freply.error = -errno;
|
freply.error = fd;
|
||||||
goto send;
|
goto send;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,26 +8,9 @@
|
||||||
#include <sys/wait.h>
|
#include <sys/wait.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include "job_output_path.h"
|
||||||
#include "utils.h"
|
#include "utils.h"
|
||||||
|
|
||||||
static int make_job_output_path(char* buf, size_t bufsize, const char* dir,
|
|
||||||
uint64_t jobid, int task)
|
|
||||||
{
|
|
||||||
char idstring[128];
|
|
||||||
|
|
||||||
if (buf == NULL) {
|
|
||||||
return -EINVAL;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dir == NULL) {
|
|
||||||
dir = "/tmp";
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt_jobid(idstring, sizeof(idstring) - 1, jobid);
|
|
||||||
|
|
||||||
return snprintf(buf, bufsize, "%s/job_%s_task%d.out", dir, idstring, task);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void on_process_exit(uv_process_t* process, int64_t exit_status,
|
static void on_process_exit(uv_process_t* process, int64_t exit_status,
|
||||||
int term_signal)
|
int term_signal)
|
||||||
{
|
{
|
||||||
|
|
@ -261,13 +244,8 @@ int uv_proc_exec(uv_proc_manager_t* manager, const char* command,
|
||||||
}
|
}
|
||||||
|
|
||||||
char job_output[256];
|
char job_output[256];
|
||||||
#ifdef AUTOSCALER_TEST
|
xcecd_job_output_path(job_output, sizeof(job_output) - 1,
|
||||||
make_job_output_path(job_output, sizeof(job_output) - 1, "/shared/log",
|
manager->job_id, i);
|
||||||
manager->job_id, i);
|
|
||||||
#else
|
|
||||||
make_job_output_path(job_output, sizeof(job_output) - 1, "/tmp",
|
|
||||||
manager->job_id, i);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
uv_stdio_container_t stdio[3];
|
uv_stdio_container_t stdio[3];
|
||||||
uv_fs_t file_req;
|
uv_fs_t file_req;
|
||||||
|
|
|
||||||
|
|
@ -1410,6 +1410,139 @@ test_blocked_connector_thread_does_not_stall_other_liveness_duty(void)
|
||||||
ticks, SLOW_REJOIN_BLOCK_MS);
|
ticks, SLOW_REJOIN_BLOCK_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════════════
|
||||||
|
* bug-1508: handle_fetchfile() runs as root and used to open() whatever
|
||||||
|
* path the request named. fetchfile_open_checked() must only open the
|
||||||
|
* worker's own output file for that job, /tmp/job_<jobid>_task<N>.out
|
||||||
|
* (uv_proc.c), as a regular, single-link, non-symlink file.
|
||||||
|
* ════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
#define FF_JOBID ((uint64_t)915080001ULL)
|
||||||
|
#define FF_OTHER_JOBID ((uint64_t)915080002ULL)
|
||||||
|
|
||||||
|
static void ff_output_path(char* buf, size_t len, uint64_t jobid, int task)
|
||||||
|
{
|
||||||
|
char id[128];
|
||||||
|
|
||||||
|
fmt_jobid(id, sizeof(id) - 1, jobid);
|
||||||
|
snprintf(buf, len, "/tmp/job_%s_task%d.out", id, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ff_write_file(const char* path, const char* content)
|
||||||
|
{
|
||||||
|
FILE* f;
|
||||||
|
|
||||||
|
unlink(path);
|
||||||
|
f = fopen(path, "w");
|
||||||
|
TEST_ASSERT_NOT_NULL(f);
|
||||||
|
fputs(content, f);
|
||||||
|
fclose(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ff_assert_rejected(uint64_t jobid, const char* path)
|
||||||
|
{
|
||||||
|
int fd = fetchfile_open_checked(jobid, path);
|
||||||
|
|
||||||
|
if (fd >= 0)
|
||||||
|
close(fd);
|
||||||
|
TEST_ASSERT_TRUE_MESSAGE(fd < 0, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_fetchfile_opens_own_job_output(void)
|
||||||
|
{
|
||||||
|
char p0[256], p3[256];
|
||||||
|
char buf[16] = { 0 };
|
||||||
|
int fd;
|
||||||
|
ssize_t n;
|
||||||
|
|
||||||
|
ff_output_path(p0, sizeof(p0), FF_JOBID, 0);
|
||||||
|
ff_output_path(p3, sizeof(p3), FF_JOBID, 3);
|
||||||
|
ff_write_file(p0, "hello");
|
||||||
|
ff_write_file(p3, "task3");
|
||||||
|
|
||||||
|
fd = fetchfile_open_checked(FF_JOBID, p0);
|
||||||
|
TEST_ASSERT_TRUE_MESSAGE(fd >= 0, "task0 output of the job must open");
|
||||||
|
n = read(fd, buf, sizeof(buf) - 1);
|
||||||
|
close(fd);
|
||||||
|
TEST_ASSERT_EQUAL_INT(5, (int)n);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("hello", buf);
|
||||||
|
|
||||||
|
fd = fetchfile_open_checked(FF_JOBID, p3);
|
||||||
|
TEST_ASSERT_TRUE_MESSAGE(fd >= 0, "any task index's output must open");
|
||||||
|
close(fd);
|
||||||
|
|
||||||
|
unlink(p0);
|
||||||
|
unlink(p3);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_fetchfile_rejects_arbitrary_paths(void)
|
||||||
|
{
|
||||||
|
char own[256], other[256], buf[512];
|
||||||
|
|
||||||
|
ff_output_path(own, sizeof(own), FF_JOBID, 0);
|
||||||
|
ff_output_path(other, sizeof(other), FF_OTHER_JOBID, 0);
|
||||||
|
ff_write_file(own, "own");
|
||||||
|
ff_write_file(other, "other");
|
||||||
|
|
||||||
|
ff_assert_rejected(FF_JOBID, "/etc/passwd");
|
||||||
|
ff_assert_rejected(FF_JOBID, "/etc/hostname");
|
||||||
|
/* another job's output, even though it is a real worker output file */
|
||||||
|
ff_assert_rejected(FF_JOBID, other);
|
||||||
|
|
||||||
|
/* traversal / non-canonical spellings of an allowed name */
|
||||||
|
snprintf(buf, sizeof(buf), "%s/../../etc/passwd", own);
|
||||||
|
ff_assert_rejected(FF_JOBID, buf);
|
||||||
|
snprintf(buf, sizeof(buf), "/tmp/../tmp/%s", own + strlen("/tmp/"));
|
||||||
|
ff_assert_rejected(FF_JOBID, buf);
|
||||||
|
snprintf(buf, sizeof(buf), "/tmp//%s", own + strlen("/tmp/"));
|
||||||
|
ff_assert_rejected(FF_JOBID, buf);
|
||||||
|
|
||||||
|
/* malformed task suffixes */
|
||||||
|
snprintf(buf, sizeof(buf), "%sx", own);
|
||||||
|
ff_assert_rejected(FF_JOBID, buf);
|
||||||
|
snprintf(buf, sizeof(buf), "%.*s.out", (int)(strlen(own) - strlen("0.out")),
|
||||||
|
own);
|
||||||
|
ff_assert_rejected(FF_JOBID, buf); /* "..._task.out" */
|
||||||
|
snprintf(buf, sizeof(buf), "%.*s-1.out",
|
||||||
|
(int)(strlen(own) - strlen("0.out")), own);
|
||||||
|
ff_assert_rejected(FF_JOBID, buf);
|
||||||
|
|
||||||
|
ff_assert_rejected(FF_JOBID, "");
|
||||||
|
|
||||||
|
unlink(own);
|
||||||
|
unlink(other);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_fetchfile_rejects_symlink_and_hardlink_at_output_name(void)
|
||||||
|
{
|
||||||
|
char own[256];
|
||||||
|
char target[] = "/tmp/xce_ff_hardlink_target_XXXXXX";
|
||||||
|
int tfd;
|
||||||
|
|
||||||
|
ff_output_path(own, sizeof(own), FF_JOBID, 0);
|
||||||
|
|
||||||
|
/* a symlink planted at the job's output name must not be followed */
|
||||||
|
unlink(own);
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, symlink("/etc/hostname", own));
|
||||||
|
ff_assert_rejected(FF_JOBID, own);
|
||||||
|
unlink(own);
|
||||||
|
|
||||||
|
/* a hardlink to another file at the output name must not be read */
|
||||||
|
tfd = mkstemp(target);
|
||||||
|
TEST_ASSERT_TRUE(tfd >= 0);
|
||||||
|
TEST_ASSERT_TRUE(write(tfd, "secret", 6) == 6);
|
||||||
|
close(tfd);
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, link(target, own));
|
||||||
|
ff_assert_rejected(FF_JOBID, own);
|
||||||
|
unlink(own);
|
||||||
|
unlink(target);
|
||||||
|
|
||||||
|
/* a directory at the output name is not a readable output file */
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, mkdir(own, 0700));
|
||||||
|
ff_assert_rejected(FF_JOBID, own);
|
||||||
|
rmdir(own);
|
||||||
|
}
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════════════════════════
|
/* ════════════════════════════════════════════════════════════════════════
|
||||||
* main
|
* main
|
||||||
* ════════════════════════════════════════════════════════════════════════ */
|
* ════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
@ -1455,6 +1588,9 @@ int main(void)
|
||||||
RUN_TEST(test_xcecd_join_cluster_all_unreachable_reports_every_candidate);
|
RUN_TEST(test_xcecd_join_cluster_all_unreachable_reports_every_candidate);
|
||||||
RUN_TEST(
|
RUN_TEST(
|
||||||
test_blocked_connector_thread_does_not_stall_other_liveness_duty);
|
test_blocked_connector_thread_does_not_stall_other_liveness_duty);
|
||||||
|
RUN_TEST(test_fetchfile_opens_own_job_output);
|
||||||
|
RUN_TEST(test_fetchfile_rejects_arbitrary_paths);
|
||||||
|
RUN_TEST(test_fetchfile_rejects_symlink_and_hardlink_at_output_name);
|
||||||
|
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue