# Hardening What the office's own code enforces (web side, mailbox, agent, desks, root jobs, the plugin's install and remove, outgoing requests), for reviewers. First written as the review before the first Community Applications release (branch `task/hardening`, commits `Hardening: …`) and kept up to date since — each row names its tests (`php tests/run.php `; the part `hardening` holds the core ones; «The checks» below names them per surface). Checked row by row against the code of 1.81.0 on 2026-10-31: a claim the code no longer meets is written as the code is now, and marked *(1.81.0: …)*. What users read about it: README «Careful by design» and docs/GUIDE.md «Security». ## Who gets in The office's security is Unraid's login. The page and everything under `/plugins/unraid-secretary-office/` are behind nginx `auth_request`, every POST carries Unraid's `csrf_token`, and whoever is logged in to Unraid is root (a single admin, the web terminal without a further password) — whoever leaves Unraid logged in hands out root, with or without the office. So the office has no lock of its own: it adds previews and confirmations against mistakes (every changing action's dialog; the restore's and the Kopia setup's «I have read what will happen»), not a second lock. Up to 1.27 a PIN could guard changes; it only ever protected the old Compose stack's page (no Unraid login in front) and went with it on 2026-10-06. An old `data/office/auth.json` is no longer read (the agent says so once in its log). ## Changed | File | What is enforced now | |---|---| | ~~`src/auth.php`, `plugin/scripts/pin.sh`~~ | Removed 2026-10-06 together with the PIN (wrong PINs counted per client and for all, the signed unlock cookie, `pin.sh unblock/reset/status`, PIN for `setup_plan` and `wake`): see «Who gets in» above. | | `agent/lib/util.php` | `writeAtomic()` creates its tmp exclusively (`fopen 'x'`, random name, mode set by umask from the start, owner via `lchown`), so a file or link planted beforehand is never written through; `rename()` replaces a link at the target. New `writeNewFile()`. `logLine()` removes a link where `agent.log` belongs instead of appending through it, strips CR. | | `agent/agent.php` | Mailbox: every round checks (lstat) that it is a real folder of the web server's user, closed to others — otherwise requests wait until it is set up again. A request is only read if it is a plain file owned by root or the web server's user (no link). The mailbox and `data/office` are never chown'ed/chmod'ed through a link. The data folder loses group/other write access (start + every minute). | | `agent/desks/cleanup.php` | Storeroom manifests (in group-writable run folders in shares) count only in Ms. Dustdevil's own shape: `as` = `/` inside the run (no `..`, `.`, empty parts, control chars; folder matching the kind) or `@` named `.UnraidSecretaryOffice-trash--…` (or, put away before issue #7, `_UnraidSecretaryOffice-trash-…`; a run moved over from the old folder with a clash suffix `-N` owns those of its time without it); `from`/`dataset` clean; stack volumes/images look like Docker names; a Docker volume's record (`volumes/`) counts only as the local driver without options, its name Docker's, label keys without `=` and values without control characters (`clVolumeRecord()`) — a forged record can't make «Put back» create an NFS/bind volume. Restore refuses when a folder between run and entry is a link; no run is created in a linked trash folder. The override file written into a stack's folder goes through `writeNewFile()`. Folders created in shares get their owner via `lchown`. | | `agent/desks/cleanup.php` (Mr. Restori's leftovers, 2026-10-06) | She renames what his journals name, so a journal counts only from his root-only folder (`data/restore` and each journal a real folder/plain file of root's, nobody else may write — `clRestoreJournals()`), with its own id, and only paths of exactly his shapes carrying that restore's own time (`clLeftoverUnits()`; clean absolute paths, no `{T}`, a dataset only when named like its path); the storeroom stays on the leftover's own filesystem inside its share (`clLeftoverTrash()`), back only to his places (`clLeftoverHome()`). | | `mediacache/mediacache_lib.py` | `Locations.to_rel()` skips any path with a `..` part (from a media server's API or a mapping) — MediaCache moves files (rsync, then delete source) along these paths. Listed in `mediacache/README.md`. | | `agent/desks/jack.php` | Path mappings only with clean absolute paths, host side under `/mnt/user/` (`jackMappingOk()`). URL, API key, user id checks end at the end of the string. | | several (snapshot, snapshotplans, house, backup, cleanup, jack, `src/api.php`, `src/place.php`) | Input validators carry the `D` modifier: PCRE's `$` also matches before a final newline, which then reached names, files or commands (e.g. a btrfs snapshot folder). | | `public/assets/core.js` | `Office.safeHref(url)`: only `#…`, `/…` (not `//`) and `http(s)://` — else `null`. | | `public/desks/{caretaker,advisor,cleanup}/desk.js` (cleanup: also her «Where is what», up to 1.30 the desk whereabouts) | Links built from state files (release link, Unraid address, links into Unraid, VM page) only through `Office.safeHref()`. The state files are `nobody:users` and read by the page straight from disk; a `javascript:` value would have run in Unraid's page on a click. | | `agent/lib/officeupdate.php` | Release cache cleaned on write and read (version number; link only to `https://github.com/dropnook/UnraidSecretaryOffice/…`); curl https only, ≤ 1 MB. | | `agent/lib/house.php` | `hostLaunch()`: job file name and environment variable names must be plain names (they go unquoted into the atd script). | | `src/api.php`, `public/desks/restore/desk.js`, `public/assets/core.js` (the flash archive's download, 2026-10-25) | **The one file of the host's data the web side reads**, on the user's «Download…» on Mr. Restori's flash tile after a dialog that names what it holds (the licence key, the password hashes): `restore.flash_download` is a POST like every request (csrf token, `X-Office`, Origin, Mr. Restori hired), answered by the web side itself — never through the mailbox, never a copy into RAM or anywhere. `apiFlashFile()` takes only the path his state (`restore.json` `flash.path`) names, and only when it is exactly `/flash/flash.tar.gz` with the place from the engine's settings.ini (`[general] dumps_share`, its share name checked), the archive and its folder no link (lstat), a regular file, its realpath exactly the place's realpath + `/flash/flash.tar.gz`; the open handle's fstat a regular file again. Streamed read-only in 1 MB chunks with its own Content-Type (`application/gzip`), Content-Length and a Content-Disposition name of only `[A-Za-z0-9_.-]` (`flash--.tar.gz`), no gzip of a .gz; refused: `flash_refused` (404). Whoever is logged in to Unraid is root anyway (Unraid's own flash backup is the same file behind the same login). Test: `testFlashDownload` (a path outside the place, another share, `..`, a link as the archive or its folder, another name, a folder by that name — all refused). | | `src/api.php`, `src/place.php`, `agent/desks/advisor.php` | **Secrets the user types** (the Consultant's Kopia setup: storage keys, repository password) never touch persistent storage on the office's side. The mailbox lies in the data folder on the pool (snapshotted, backed up — by Kopia, too), so a request's `secret` never goes there: `apiSecretStash()` (only for `OFFICE_SECRET_ACTIONS`: the Consultant's `advisor.kopia_repo` and the USO network's `caretaker.usonet_join`) writes it to `officeInboxDir()/.secret`, a root-only 0700 folder in `/run` (tmpfs), 0600 from the start (`fopen 'x'` under `umask 0177`), and sends only the name; `apiSecretDrop()` removes it after the request whatever happened. The agent (`advisorSecretTake()`, `usonetSecretTake()`) accepts only a plain root-owned file closed to others (`mode & 077 = 0`; 0600 as written) with one link in a root-only folder, reads ≤ 16 KB (the auth key ≤ 4 KB) and unlinks it at once (also when it refuses it); the tick sweeps leftovers after two minutes. To Kopia the secrets go on stdin only (`docker exec -i sh -c 'read …; export …; exec kopia repository "$@"'` — the script is fixed text, the arguments carry no secret), never on a command line (`ps`), in a file, a log, a state file, a notification or an answer: Kopia's output is scrubbed of every secret value before it becomes an error text; the log says only `create (s3) … exit N`. The page shows them masked in the preview; the recovery sheet is built in the browser (a frame, node by node) and never sent. Asking the bucket about S3 Object Lock (`step: probe`, and again before a create with `lock_days`) takes the keys the same way; they go into nothing but an AWS Signature V4 computed in the agent (`advisorS3Sign()`, the secret key only inside HMACs) — one `GET ?object-lock` from PHP's curl straight to the endpoint typed (HTTPS only, certificate checked, `CURLOPT_PROXY ''` so no environment proxy sees it, no redirects, ≤ 64 KB read); only the access key ID travels (in the Authorization header, as in Kopia's own requests); the answer kept is enabled/off/unsupported/unknown with a reason code. Tests: `tests/run.php hardening` (`testAdvisorSecrets`, end to end with a stand-in for docker and Kopia: no secret in any file but the RAM one — gone afterwards — and Kopia's own config, none in the data folder, the log, `ps` or an answer; the Object Lock question and a create with the lock with a stand-in for the bucket: the secret key and the password in no request) and `tests/run.php logic` (`testAdvisorObjectLock`: AWS's own Signature V4 example). | | `agent/desks/advisor.php` | Installing: plugin addresses pinned in the code (the makers' repositories, as in the CA feed), never a URL from a request; the atd script takes them as positional arguments of a fixed `sh -c` text. Templates are the office's own files with `{{…}}` filled in XML-escaped and parsed back before use; Unraid's form gets a file in `RUN_DIR/templates` (root-only), never a path from a request. Files written beforehand (prometheus.yml, Grafana's provisioning) only where nothing is (`advisorWriteIfAbsent()`: `writeNewFile()` + rename, re-checked), folders only created when missing and never written through a link (`advisorDirs()` refuses `ad_link`). Kopia's repository fields are validated one by one (`$/D`), a folder repository only below one of the container's writable binds. | | `agent/desks/advisor.php`, `agent/desks/watchman.php` (2026-10-07) | **What the office installed itself:** the Consultant's record `data/advisor/installs.json` (folder 0700, file 0600, `writeAtomic()`, newest 20 within 30 days) — the watchman trusts it only while folder and file are root's own, closed to others, a plain file with one link, ≤ 64 KB, and only in exactly the shape written (`watchmanOfficeInstalls()`); a plugin counts as the office's only by exactly that name with its link made within 15 min, a container only with that name, that image, the consultant's label and created within 2 h — everything else is still reported. The office's own cron file: only lines exactly as `officeJobSetSchedule()` writes them are noted by himself. | | `agent/desks/watchman.php` (SOC, 2026-10-07) | **The host look reads, never keeps secrets:** of /etc/shadow only whether a password is a hash, none or locked; of Connect's `connect.json` only the remote-access type and port; of `oidc.json` only the provider id, name and issuer host (never the client id); of WireGuard configs only a fingerprint of each peer's public key (the private key line is never kept). The syslog export (off by default) writes only what the book says (the entry in English, ids, the ATT&CK id) through `logger` in array form — no hashes, no evidence lines, ≤ 20 entries a round *(1.81.0: the chain lines that follow the entries, `watchmanSyslogForward()`, have no cap of their own)*; his own lines are never evidence. ss and /proc are read only; nothing is switched on. | | `agent/desks/watchman.php`, `agent/agent.php`, `plugin/scripts/agent.sh` (night shift, 2026-10-07) | **The night shift** (`php agent.php nightshift`, while the array isn't started) runs as root with cwd `/`, umask 077, its book, state and log only in `/var/run/unraid-secretary-office/nightshift` (0700) — nothing under `/mnt`, never the data folder (its paths leave out every pool place: `watchmanNightPaths()`; a test runs a round and compares the data folder before and after). One lock (`nightshift.lock`) keeps it apart from a second one and from the agent's rounds. **The mirror** of his baseline it starts from: in RAM (`watchman-mirror.json`, 0600) all of it; on the flash (`/boot/config/plugins/unraid-secretary-office/watchman-mirror.json`, 0600 where the flash keeps modes — on vfat whoever reads the flash share reads it) only addresses, names, versions and fingerprints: no fingerprint of a password (the night's first look stands for those; the agent compares against its own book after the start), crontab lines only as hashes, no syslog position, no failures, no words of an entry; written only when it changed and at most once an hour — at once after an «I know, thanks» (his page's or the Team Lead's), so the night doesn't repeat what was acknowledged; removed when the night watchman is let go and with the plugin. Read back only in its shape — `watchmanMirrorOk()` checks the top level (`v`, `baseline.hired`, `state`, `open`, `boot`), each open entry is taken only with an id and a kind (`watchmanNightBegin()`) *(1.81.0: the baseline's and state's inner values are not checked field by field)*; an edited flash mirror can only make the night shift quieter or louder — the agent never takes the mirror's baseline, and at the handover only entries the day's book has (by id) or new ones of the night are taken over. Up to 1.30 the SIEM switch shared `state.json`'s key `syslog` with the syslog's position (switching broke his rounds): now `siem`. | | `agent/desks/watchman.php`, `agent/desks/caretaker.php` (Unraid's API, 2026-10-07) | **Unraid's API is watched, never used.** The office never creates, reads out or uses an API key and never asks the API anything that needs one. The watchman reads the API's key files on the flash (`/boot/config/plugins/dynamix.my.servers/keys/*.json`, root only; plain files only — a link there isn't followed —, ≤ 64 KB, stat first — an unchanged file isn't read again) and keeps of each only its id, name, roles and the permission set (a fingerprint, the count, the first few in words): the file's text and every other field — the key's value above all, never even a hash of it, the description, the creation time — are dropped in the function that read it (`watchmanApiKeyRead()`); of `configs/api.json` only `sandbox`, the scheme, host and port of each `extraOrigins` entry and a fingerprint of each `ssoSubIds` entry. Nothing of a key's value reaches his book, state, seen.json, the page, the checks, the mirrors (RAM, flash), the night shift's files, a notification or the SIEM line — `testWatchmanApiDoor` plants made-up values and looks for them in all of it (a version that keeps the value fails it). The team lead asks the API one question it answers without a key (`isSSOEnabled`, which Unraid's login page asks too) over its Unix socket, in-process with PHP's curl: no key, cookie or token, no nginx, no proxy, ≤ 2 s, the read stopped once 64 KB are in (one chunk more at most) — `testCaretakerApi` checks the request against a stand-in API. (An office key — the concept's pilot 2 — isn't built: no feature needs one.) | | `agent/agent.php`, `src/mailbox.php`, `src/place.php`, `plugin/scripts/agent.sh` (doorbell, heartbeat, data path; 2026-10-07) | **The doorbell** `RUN_DIR/doorbell` (RAM, the folder 0700 root): the agent makes it anew at every start — whatever lies there (an old one, a file, a link) is removed, never written through —, `mkfifo` 0600 under umask 077, and opens it only when the open handle's fstat is the inode it just made and its own; at the stop it removes only its own. The web side writes one byte and nothing else (`agentRing()`): only into a FIFO (lstat: no link) of its own user that nobody else may open, the handle's fstat the same inode; opened read+write and written non-blocking, so a doorbell nobody reads or a full one never holds a request up. It never signals or touches a process. **The heartbeat** `RUN_DIR/agent.json` is written atomically (`writeAtomic()`, root 0644 in the 0700 folder); the pulse touches it only while it is a plain file (else written anew). **The data folder on the pool** (`officeUnraidPath()`): only Unraid's exact link of an exclusive share is followed (`..//` or `/mnt//`, same name, not user/user0/addons/remotes/disks/rootshare, a real folder there by lstat); anything else keeps the path as set. **Tests** run with a RAM folder of their own and, on the server, in a mount namespace whose live RAM folder is an empty one of theirs (`testLiveRunUntouched()`). | | `agent/partner-door.php`, `plugin/scripts/partner-door.sh`, `agent/lib/partner.php` (partner offices, 2026-10-07) | **The partner door is a root key with a forced command** — whatever the partner's ssh asks for, the door runs: `restrict` (no pty, no forwarding, no agent, no user rc), `from=` the partner's IP, `command=` the door with the pair's id (from the line, never from the client). The wrapper starts PHP with `env -i` (PATH, HOME=/root, LC_ALL=C and the two SSH_ variables only), cwd `/`, umask 077. The request (`SSH_ORIGINAL_COMMAND`) is words of `[A-Za-z0-9._:-]` with single spaces — no path, no quote, no `;`, no newline can pass — and each verb takes exactly its count (`doorWords()`, `doorMain()`): `ping`, `status`, `quota` one word, `list` one or two, `resume` two, `recv` and `send-back` three or four (`-t ` as the last two: recv's resume, send-back's resume), `offer` up to 65 (unit words of at most 70 characters, none twice). Longer than 256 characters only `offer` and `send-back … -t ` (the token `[0-9a-zA-Z-]`). *(1.81.0: the units are no longer tied to an agreement — since 1.60 a pair's request may name any unit of the engine's names, `PARTNER_UNITS_MAX` 64 at once (`refused_units`), bounded by the quota; `offer` answers `agreed` for every unit named (`doorOffer()`: the pairing is the consent — it still only writes the wish file, never a dataset; it stays for senders up to 1.59).)* Snapshots only exactly `uso-backup-YYYYMMDD-HHMM`. It can: answer how this office is (`ping`: with the array stopped from RAM and the flash only; started, the known pairs from the data folder, a ticket's ping from `tickets.json`), receive a ZFS stream into this pair's own datasets (`/UnraidSecretaryOffice-partners//…`, legacy mountpoint, never mounted or shared, read-only, the quota of pairs.json), list and size its own copies. On the pair's own datasets and nowhere else, with no value from the client, a receive also: creates the parents (`zfs create`, `doorParents()`), sets the pair's quota as pairs.json has it (`partnerQuotaSync()`), after a resume sets `mountpoint=legacy canmount=noauto readonly=on` again, renames an aside copy back when its receive failed. It can't: name a path or a dataset, use `-F` (no rollback, no overwrite), send a replication stream (the target is `@` — a resume's `recv -s … -t ` targets the dataset, and ZFS takes only the stream the partial receive began) or carry a stream's own mountpoint, canmount or shares (first receive `-o mountpoint=legacy -o canmount=noauto -o readonly=on -x sharesmb -x sharenfs`, later ones `-x` for those), destroy anything (only this office's own retention on the pair's datasets, never the newest, never a held one, each recorded in `deletes.jsonl`; a full stream onto an existing unit renames the old dataset aside beside it as `.old-