Files
Plasma-Addon-Synology-NAS/package/contents/code/nas.js
T
Jaroslav Beneš 43088a2316 Add nas.js logic layer (command builders + parsers)
Injection-safe sh -c wrapper (static script + positional args), KWallet
password flow into a 0600 cred file for mount, smbclient/-g share parsing,
findmnt mounted-state parsing, and the pkexec helper invocations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 14:22:03 +02:00

182 lines
7.3 KiB
JavaScript

/*
* nas.js — command builders + output parsers for the Synology NAS plasmoid.
*
* These functions are pure: they build shell command strings and parse the
* text those commands produce. The QML layer owns a Plasma5Support executable
* DataSource and actually runs them.
*
* Security model:
* - The plasma5support "executable" engine does NOT use a shell; it tokenises
* with KShell and runs the program directly. So every command is wrapped as
* `sh -c '<STATIC SCRIPT>' _ <arg1> <arg2> ...`. The script text is constant
* (no interpolation) and all dynamic, possibly-hostile values arrive as
* positional parameters ($1, $2, ...). This makes shell injection via a
* share/host/user name impossible.
* - Passwords live in KWallet. On mount we read the password *inside* the
* shell straight into a 0600 credentials file — it never appears in QML,
* in argv, or in `ps`. (The one exception is the initial save in the config
* page, where the freshly-typed password is handed to kwallet-query.)
*/
// KWallet folder + wallet used for all stored NAS passwords.
var WALLET = "kdewallet";
var WALLET_FOLDER = "Synology NAS";
var HELPER = "/usr/lib/synology-nas/helper";
// POSIX single-quote escaping: wrap in '...' and replace ' with '\''.
function sq(s) {
return "'" + String(s).replace(/'/g, "'\\''") + "'";
}
// Build `sh -c '<script>' _ arg1 arg2 ...` with every arg safely quoted.
// `script` is trusted constant text; `args` are untrusted runtime values.
function shCmd(script, args) {
var out = "sh -c " + sq(script) + " _";
for (var i = 0; i < (args ? args.length : 0); ++i) {
out += " " + sq(args[i]);
}
return out;
}
// KWallet entry key for a host's credentials.
function walletKey(host) {
return host.id + "/" + (host.username || "");
}
// Turn a label/host into a safe single path component.
function safeComponent(s) {
return String(s).replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "_");
}
// Resolve the effective mount root (config override -> home/NAS).
function mountRoot(cfg, home, host) {
if (host && host.mountRoot && host.mountRoot.length > 0) return host.mountRoot;
if (cfg && cfg.length > 0) return cfg;
return home + "/NAS";
}
// Absolute mount point for a given host + share.
function mountpointFor(cfg, home, host, share) {
var root = mountRoot(cfg, home, host);
var seg = safeComponent(host.label && host.label.length ? host.label : host.host);
return root + "/" + seg + "/" + safeComponent(share);
}
// //host/share as passed to mount.cifs.
function unc(host, share) {
return "//" + host.host + "/" + share;
}
/* ---- command builders -------------------------------------------------- */
// Enumerate shares on a host. Password is pulled from KWallet into the PASSWD
// env var (read by smbclient) so it never reaches argv.
function enumerateCmd(host) {
var script =
"export PASSWD=\"$(kwallet-query -f 'Synology NAS' -r \"$1\" " + WALLET + ")\"\n" +
"exec smbclient -L \"//$2\" -U \"$3\" -g -N 2>/dev/null";
return shCmd(script, [walletKey(host), host.host, host.username || ""]);
}
// List currently mounted cifs filesystems (no privilege needed).
function listMountedCmd() {
return shCmd("exec findmnt -rnt cifs -o TARGET,SOURCE", []);
}
// Mount a share. Reads the password from KWallet into a 0600 cred file, then
// invokes the privileged helper via pkexec. The cred file is always removed.
//
// `settings` = { defaultMountRoot, home, smbVersion, mountOptions,
// fileMode, dirMode }.
function mountCmd(settings, host, share) {
var mp = mountpointFor(settings.defaultMountRoot, settings.home, host, share);
var credId = safeComponent(host.id + "-" + share);
var script =
"set -u\n" +
"umask 077\n" +
"runtime=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/synology-nas\"\n" +
"mkdir -p \"$runtime\" || exit 1\n" +
"cf=\"$runtime/$1.cred\"\n" +
"trap 'rm -f \"$cf\"' EXIT INT TERM\n" +
"{ printf 'username=%s\\n' \"$3\"; printf 'domain=%s\\n' \"$4\"; " +
"printf 'password='; kwallet-query -f 'Synology NAS' -r \"$2\" " + WALLET + "; } > \"$cf\" || exit 1\n" +
"chmod 600 \"$cf\"\n" +
"exec pkexec " + HELPER + " mount \"$5\" \"$6\" \"$cf\" \"$7\" \"$8\" \"$9\" \"${10}\"\n";
var args = [
credId, // $1 cred file id
walletKey(host), // $2 wallet entry
host.username || "", // $3
host.domain || "", // $4
unc(host, share), // $5 //host/share
mp, // $6 mountpoint
host.vers || settings.smbVersion, // $7 smb version
settings.mountOptions, // $8 mount options
settings.fileMode, // $9 file mode
settings.dirMode // $10 dir mode
];
return { cmd: shCmd(script, args), mountpoint: mp };
}
// Unmount a share (privileged helper via pkexec).
function unmountCmd(mountpoint) {
return shCmd("exec pkexec " + HELPER + " unmount \"$1\"", [mountpoint]);
}
// Save a password to KWallet (used by the config page). kwallet-query reads the
// secret from stdin; we feed it via printf from a positional param. The value
// is in this process's argv for the brief write (unavoidable with a CLI-only
// KWallet path) — see the security notes in the README.
function savePasswordCmd(host, password) {
var script = "printf '%s' \"$2\" | kwallet-query -f 'Synology NAS' -w \"$1\" " + WALLET;
return shCmd(script, [walletKey(host), password]);
}
// Remove a host's stored password (best effort; overwrites with empty).
function deletePasswordCmd(host) {
var script = "printf '' | kwallet-query -f 'Synology NAS' -w \"$1\" " + WALLET + " 2>/dev/null || true";
return shCmd(script, [walletKey(host)]);
}
/* ---- parsers ----------------------------------------------------------- */
// Parse `smbclient -L -g` output into [{name, comment}], dropping admin shares.
function parseShares(stdout) {
var shares = [];
var lines = (stdout || "").split("\n");
for (var i = 0; i < lines.length; ++i) {
var f = lines[i].split("|");
if (f.length >= 2 && f[0] === "Disk") {
var name = f[1];
if (name.length === 0 || /\$$/.test(name)) continue; // skip IPC$, print$, admin$
shares.push({ name: name, comment: f.length >= 3 ? f[2] : "" });
}
}
shares.sort(function (a, b) { return a.name.localeCompare(b.name); });
return shares;
}
// Parse `findmnt -rn TARGET,SOURCE` into a set-like object keyed by mountpoint.
function parseMounted(stdout) {
var set = {};
var lines = (stdout || "").split("\n");
for (var i = 0; i < lines.length; ++i) {
var line = lines[i];
if (line.length === 0) continue;
var sp = line.indexOf(" ");
var target = sp === -1 ? line : line.substring(0, sp);
target = target.replace(/\\040/g, " ").replace(/\\011/g, "\t").replace(/\\134/g, "\\");
set[target] = true;
}
return set;
}
// Parse the hosts JSON config into an array (never throws).
function parseHosts(json) {
try {
var v = JSON.parse(json || "[]");
return Array.isArray(v) ? v : [];
} catch (e) {
return [];
}
}