Files
Gmer4Lfe c34224effa Carry the CSRF token on fetch requests and put mutations behind POST
Unraid already enforces CSRF on every POST via auto_prepend, but its
injector is jQuery-only — the plugin's native fetch() calls carried no
token and were being terminated before the endpoint ran, silently,
because csrf_terminate exits with an empty body that r.json() swallows.
2026-08-02 10:28:53 -04:00

193 lines
9.8 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Script import. Browses the filesystem for .sh files and moves a chosen one into
// CUSTOM_SCRIPTS_DIR, so an existing script can be brought under Varaverk's scheduler
// without retyping it.
//
// OPERATIONAL MODEL
// Two actions on one URL: a GET browser and a POST import. The browser is rooted at / and
// walks one directory at a time, because the scripts people want to import live wherever
// they happened to put them — most often the User Scripts plugin's own folders.
//
// This is a move, not a copy. The source is deleted once the destination is verified, so
// there is exactly one copy afterwards and no chance of editing the wrong one. That is also
// why the verification is so deliberate: a move that half-succeeds destroys the only copy.
//
// DESIGN PRINCIPLES
// Copy, verify, then delete — in that order, always.
// copy() first, then a size comparison and a SHA-256 of both files, and only then the
// unlink. Source and destination are routinely on different filesystems, where rename()
// is not atomic and a partial write is a real outcome rather than a theoretical one.
//
// A failed verification leaves the source untouched.
// The destination is removed and the source is left exactly where it was. Between
// losing the import and losing the script, the import is the acceptable loss.
//
// An undeletable source is a warning, not a failure.
// If the copy verified but the original could not be removed — read-only mount,
// permissions — the import is reported successful with a warning naming the file to
// clean up. The script works from its new home either way, and failing the whole
// operation would leave the user with two copies and an error message.
//
// Directory listings are capped and sorted.
// 300 entries each for directories and .sh files. A browser rooted at / will eventually
// be pointed at something enormous.
//
// OPERATIONAL SAFEGUARDS
// Refuses to import from inside the Varaverk repo.
// Both paths are resolved with realpath() and compared by prefix. Importing a tracked
// file would delete it out from under git with no commit recording it — the next pull
// would either restore it as a phantom or report a deletion nobody made. This check is
// the reason realpath() is used rather than the submitted string: a symlink into the
// repo would otherwise slip past a textual comparison.
//
// Refuses a source already inside CUSTOM_SCRIPTS_DIR.
// Also compared after realpath(). Without it, the move would copy a file onto itself
// and then delete it.
//
// Refuses to overwrite an existing custom script.
// file_exists() on the destination aborts with the conflicting name. A silent overwrite
// here would destroy a script that may already be scheduled and running.
//
// Both paths are validated as absolute with no traversal.
// ^/[^\0]*$ for the browse path and ^/[^\0]*\.sh$ for the import, plus an explicit '..'
// check on each. Null bytes are excluded by the character class, which matters because
// these strings reach both the filesystem and a shell.
//
// Both find invocations escape their argument, and neither takes anything else from the
// request — depth, type and name filters are all literals.
//
// The destination is made executable before it is reported.
// chmod 0755 after verification, so a freshly imported script is immediately runnable
// rather than failing the first time it is scheduled.
//
// Existence is confirmed before work begins — is_dir() for browse, is_file() for import —
// so a bad path returns a named error rather than a warning leaking into the JSON body.
//
// Accepted exposure: the browser can list any directory on the host.
// It returns directory names and .sh filenames only — no file contents, and nothing
// outside those two types. That is the minimum a file picker rooted at / can do. browse
// is a GET read, so the WebGUI session is its whole boundary; the import itself is POST
// and is CSRF-guarded by Unraid's auto_prepend. See README-unraid.md.
//
// REQUEST
// GET ?action=browse&path=/absolute/dir list subdirectories and .sh files
// POST action=import path=/absolute/file.sh move it into CUSTOM_SCRIPTS_DIR
//
// RESPONSE
// browse {"ok":true,"path","dirs":[…],"files":[…],"parent":…}
// import {"ok":true,"id":"Custom/<name>.sh"} optionally with a "warning"
// {"ok":false,"error":"Invalid path"|"Not a directory: …"|"Invalid script path"
// |"Not found: …"|"Could not resolve path"
// |"Refusing to import from inside the Varaverk repo …"
// |"Already in Custom Scripts."|"A script named \"…\" already exists …"
// |"Copy failed"|"Copy verification failed — source left untouched"
// |"Invalid request"}
//
// DEPENDS ON
// include/config.php SCRIPTS_DIR, CUSTOM_SCRIPTS_DIR
// api/script.php manages the scripts once they are here
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache');
require_once dirname(__DIR__) . '/include/config.php';
// ── browse (GET): list subdirectories and .sh files at $path, rooted at / ─────
if ($_SERVER['REQUEST_METHOD'] === 'GET' && ($_GET['action'] ?? '') === 'browse') {
$path = trim($_GET['path'] ?? '/');
if (!preg_match('#^/[^\0]*$#', $path) || str_contains($path, '..')) {
echo json_encode(['ok' => false, 'error' => 'Invalid path']);
exit;
}
$clean = rtrim($path, '/') ?: '/';
if (!is_dir($clean)) {
echo json_encode(['ok' => false, 'error' => 'Not a directory: ' . $clean]);
exit;
}
$dirOut = shell_exec('find ' . escapeshellarg($clean) . ' -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort | head -300') ?: '';
$dirs = array_values(array_filter(array_map('trim', explode("\n", $dirOut))));
$fileOut = shell_exec('find ' . escapeshellarg($clean) . ' -maxdepth 1 -mindepth 1 -type f -iname "*.sh" 2>/dev/null | sort | head -300') ?: '';
$files = array_values(array_filter(array_map('trim', explode("\n", $fileOut))));
$parent = ($clean !== '/') ? (dirname($clean) ?: '/') : null;
echo json_encode(['ok' => true, 'path' => $clean, 'dirs' => $dirs, 'files' => $files, 'parent' => $parent]);
exit;
}
// ── import (POST): move the chosen .sh file into CUSTOM_SCRIPTS_DIR ───────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'import') {
$src = trim($_POST['path'] ?? '');
if (!preg_match('#^/[^\0]*\.sh$#i', $src) || str_contains($src, '..')) {
echo json_encode(['ok' => false, 'error' => 'Invalid script path']);
exit;
}
if (!is_file($src)) {
echo json_encode(['ok' => false, 'error' => 'Not found: ' . $src]);
exit;
}
$srcReal = realpath($src);
if ($srcReal === false) {
echo json_encode(['ok' => false, 'error' => 'Could not resolve path']);
exit;
}
// Refuse to move a file out of the git-tracked repo — that would delete a
// tracked file out from under git without a commit recording it.
$repoReal = realpath(SCRIPTS_DIR);
if ($repoReal && str_starts_with($srcReal, $repoReal . '/')) {
echo json_encode(['ok' => false, 'error' => 'Refusing to import from inside the Varaverk repo — that would delete a git-tracked file.']);
exit;
}
// Already there — nothing to do.
$customReal = realpath(CUSTOM_SCRIPTS_DIR) ?: CUSTOM_SCRIPTS_DIR;
if (str_starts_with($srcReal, rtrim($customReal, '/') . '/')) {
echo json_encode(['ok' => false, 'error' => 'Already in Custom Scripts.']);
exit;
}
if (!is_dir(CUSTOM_SCRIPTS_DIR)) mkdir(CUSTOM_SCRIPTS_DIR, 0755, true);
$name = basename($srcReal);
$dest = CUSTOM_SCRIPTS_DIR . '/' . $name;
if (file_exists($dest)) {
echo json_encode(['ok' => false, 'error' => "A script named \"$name\" already exists in Custom Scripts."]);
exit;
}
// Copy across filesystems, verify, THEN delete the source — never remove the
// only copy on a failed or partial copy.
if (!copy($srcReal, $dest)) {
@unlink($dest);
echo json_encode(['ok' => false, 'error' => 'Copy failed']);
exit;
}
if (filesize($srcReal) !== filesize($dest) || hash_file('sha256', $srcReal) !== hash_file('sha256', $dest)) {
@unlink($dest);
echo json_encode(['ok' => false, 'error' => 'Copy verification failed — source left untouched']);
exit;
}
chmod($dest, 0755);
if (!@unlink($srcReal)) {
// Copied and verified but couldn't remove the original (permissions, read-only
// mount). The script is usable from its new home either way — surface a warning
// rather than failing the import outright.
echo json_encode([
'ok' => true,
'id' => 'Custom/' . $name,
'warning' => 'Imported, but could not delete the original at ' . $srcReal . ' — remove it manually.',
]);
exit;
}
echo json_encode(['ok' => true, 'id' => 'Custom/' . $name]);
exit;
}
echo json_encode(['ok' => false, 'error' => 'Invalid request']);