Document the PHP api layer and fix what documenting it exposed
Writing down what each endpoint actually guarantees made the places it didn't obvious — shell arguments reaching a crontab or a bash -c unescaped, master.conf written without tmp+rename, and conf edits that could be saved without ever being parsed.
This commit is contained in:
@@ -1,4 +1,77 @@
|
||||
<?php
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
// PURPOSE
|
||||
// Schedule writer. Saves one job's enabled state, cron expression and log flag — or a whole
|
||||
// set of them in one call — and rebuilds varaverk.cron from the result.
|
||||
//
|
||||
// OPERATIONAL MODEL
|
||||
// schedule.json is the source of truth; varaverk.cron is generated from it. Nothing edits
|
||||
// the crontab directly, which is why the generated file carries a "managed by plugin, do
|
||||
// not edit manually" banner. Every save here ends in a vv_cron_rebuild() inside the library,
|
||||
// so the two can never drift.
|
||||
//
|
||||
// Two paths, same effect. The single-entry path is what the UI's toggles use; the batch
|
||||
// path exists so a bulk edit is one load/write/rebuild cycle rather than N of them. The
|
||||
// batch path is currently unreferenced by the UI.
|
||||
//
|
||||
// The two paths differ in how they treat a bad cron, deliberately. A single save rejects it
|
||||
// and tells the user; a batch save blanks that one field and continues, because failing an
|
||||
// entire bulk edit over one malformed row would lose every other change in it.
|
||||
//
|
||||
// DESIGN PRINCIPLES
|
||||
// Event triggers are stored here but never reach cron.
|
||||
// array_start and array_stop are accepted as cron values and recorded in schedule.json,
|
||||
// but vv_cron_rebuild() skips them — they fire from the array event hook instead. One
|
||||
// schedule holds both kinds of trigger so the page has a single list to render.
|
||||
//
|
||||
// Enablement is decided here, precedence in the library.
|
||||
// A child script whose orchestrator is enabled gets no independent cron line, and that
|
||||
// suppression lives in vv_cron_rebuild(). This endpoint records intent; the library
|
||||
// resolves what that intent means against the rest of the schedule.
|
||||
//
|
||||
// OPERATIONAL SAFEGUARDS
|
||||
// The job id and the cron expression are both crontab injection surfaces, and are validated
|
||||
// as such.
|
||||
// vv_cron_rebuild() interpolates both into a generated crontab line inside double
|
||||
// quotes. The id must match ^[A-Za-z0-9_./\-]+\.sh$ with no '..', and the cron
|
||||
// expression must consist only of [0-9A-Za-z*,\-/ ] — neither can carry a quote, a
|
||||
// shell metacharacter, or a newline. Previously the id was checked only for emptiness,
|
||||
// and the field-count pattern used \s, which matches newline: a value of "* * * *\nX"
|
||||
// satisfied it and would have appended a second, attacker-chosen line to root's crontab.
|
||||
// The character-class check is what carries that guarantee now; the field count is a
|
||||
// usability check on top of it.
|
||||
//
|
||||
// Both paths share one validator.
|
||||
// vv_sched_id_valid() and vv_sched_cron_valid() are called from the single and batch
|
||||
// paths alike, so the bulk path cannot become the loose one — which is exactly how the
|
||||
// id check came to be missing from it before.
|
||||
//
|
||||
// POST only, checked before any parameter is read.
|
||||
//
|
||||
// Anything other than the literal "1" is false.
|
||||
// Both flags compare strictly against '1', so a missing or malformed parameter disables
|
||||
// rather than enables. The single-entry path previously used a (bool) cast, under which
|
||||
// the string "false" evaluates to true.
|
||||
//
|
||||
// Malformed batch JSON degrades to an empty set.
|
||||
// json_decode with a ?: [] fallback, so a truncated payload writes nothing rather than
|
||||
// rebuilding the crontab from garbage.
|
||||
//
|
||||
// REQUEST
|
||||
// POST id=<Category/name.sh> enabled=0|1 cron=<5 fields|array_start|array_stop|empty>
|
||||
// log_enabled=0|1
|
||||
// POST batch=<JSON array of {id, enabled, cron, log_enabled}>
|
||||
//
|
||||
// RESPONSE
|
||||
// {"ok":true,"error":null}
|
||||
// {"ok":false,"error":"POST only"|"Invalid id"|"Invalid cron expression"
|
||||
// |"Failed to write schedule"}
|
||||
//
|
||||
// DEPENDS ON
|
||||
// include/scheduler.php vv_schedule_update(), vv_schedule_update_batch(),
|
||||
// vv_cron_rebuild()
|
||||
// varaverk.cron generated output — never edited directly
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
header('Content-Type: application/json');
|
||||
require_once dirname(__DIR__) . '/include/scheduler.php';
|
||||
|
||||
@@ -7,6 +80,20 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Both values below are interpolated into the generated crontab by vv_cron_rebuild(), so
|
||||
// neither may contain a quote, a shell metacharacter, or a newline.
|
||||
function vv_sched_id_valid(string $id): bool {
|
||||
return $id !== '' && !str_contains($id, '..') && (bool)preg_match('/^[A-Za-z0-9_.\-\/]+\.sh$/', $id);
|
||||
}
|
||||
|
||||
// Five whitespace-separated fields of cron-safe characters only. \s matches newline, so the
|
||||
// field-count pattern alone would accept a value carrying a second crontab line.
|
||||
function vv_sched_cron_valid(string $cron): bool {
|
||||
if (in_array($cron, ['array_start', 'array_stop'], true)) return true;
|
||||
if (!preg_match('/^[0-9A-Za-z*,\-\/ ]+$/', $cron)) return false;
|
||||
return (bool)preg_match('/^(\S+ +){4}\S+$/', $cron);
|
||||
}
|
||||
|
||||
// Batch save — all entries in one load/write/rebuild cycle
|
||||
if (!empty($_POST['batch'])) {
|
||||
$entries = json_decode($_POST['batch'], true) ?: [];
|
||||
@@ -14,9 +101,8 @@ if (!empty($_POST['batch'])) {
|
||||
foreach ($entries as $e) {
|
||||
$id = trim($e['id'] ?? '');
|
||||
$cron = trim($e['cron'] ?? '');
|
||||
if (!$id) continue;
|
||||
if ($cron && !in_array($cron, ['array_start', 'array_stop'], true)
|
||||
&& !preg_match('/^(\S+\s+){4}\S+$/', $cron)) $cron = '';
|
||||
if (!vv_sched_id_valid($id)) continue;
|
||||
if ($cron && !vv_sched_cron_valid($cron)) $cron = '';
|
||||
$clean[] = [
|
||||
'id' => $id,
|
||||
'enabled' => ($e['enabled'] ?? '0') === '1',
|
||||
@@ -30,18 +116,17 @@ if (!empty($_POST['batch'])) {
|
||||
}
|
||||
|
||||
$id = trim($_POST['id'] ?? '');
|
||||
$enabled = (bool)($_POST['enabled'] ?? false);
|
||||
$enabled = ($_POST['enabled'] ?? '0') === '1';
|
||||
$cron = trim($_POST['cron'] ?? '');
|
||||
$log_enabled = ($_POST['log_enabled'] ?? '0') === '1';
|
||||
|
||||
if (!$id) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Missing id']);
|
||||
if (!vv_sched_id_valid($id)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid id']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Basic cron validation — 5 fields, or known @event trigger, or empty
|
||||
if ($cron && !in_array($cron, ['array_start', 'array_stop'], true)
|
||||
&& !preg_match('/^(\S+\s+){4}\S+$/', $cron)) {
|
||||
// 5 cron fields, or a known event trigger, or empty
|
||||
if ($cron && !vv_sched_cron_valid($cron)) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid cron expression']);
|
||||
exit;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user