From 5696110489e811532a441390719f51aec20ab299 Mon Sep 17 00:00:00 2001 From: TheJakeBraun <210177983+TheJakeBraun@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:49:54 -0700 Subject: [PATCH 01/12] Narrow Docker volume mounts and stop chmodding the host home With the documented `-v ~:/opt` mount, /opt is the operator's home directory, and entry.sh reset it to 755 on every start. In the Compose example the node's wallet directory also sat inside that mount. entry.sh now only adds traverse permission to /opt (so legacy ~:/opt mounts keep working) and makes the config directory itself readable. README mounts only ~/nanoNodeMonitor for the monitor and ~/Nano for the node (same host locations as before) and notes that old setups keep working but should switch. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 ++++++++++--- entry.sh | 7 +++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 487a5d6..f65e953 100644 --- a/README.md +++ b/README.md @@ -29,11 +29,18 @@ They are also pushed to Docker Hub as a secondary, legacy location: #### Standalone - sudo docker run -d -p 80:80 -v ~:/opt --restart=unless-stopped ghcr.io/gigaionllc/nanonodemonitor + sudo docker run -d -p 80:80 -v ~/nanoNodeMonitor:/opt/nanoNodeMonitor --restart=unless-stopped ghcr.io/gigaionllc/nanonodemonitor This will create a directory called _nanoNodeMonitor_ inside your home directory with the _config.php_ inside it. Edit it according to your needs and you're good to go! +> **Upgrading from an older setup?** Earlier versions of this README mounted the whole +> home directory (`-v ~:/opt`). That still works, but it gives the monitor container +> access to everything in your home directory (in the Compose setup that includes the +> node's wallet files). Switch to the narrower mounts shown here; your config stays in +> the same place (`~/nanoNodeMonitor/config.php`), and in Compose the node's data stays +> in `~/Nano` as before. + #### Docker Compose 1. Create a directory called _nano_ and go inside it: `mkdir nano && cd nano` @@ -49,7 +56,7 @@ services: ports: - "80:80" volumes: - - "~:/opt" + - "~/nanoNodeMonitor:/opt/nanoNodeMonitor" node: image: "nanocurrency/nano:TAG" restart: "unless-stopped" @@ -57,7 +64,7 @@ services: - "7075:7075" - "127.0.0.1:7076:7076" volumes: - - "~:/root" + - "~/Nano:/root/Nano" ``` 3. Nice! Now execute `sudo docker-compose up -d` to start everything. diff --git a/entry.sh b/entry.sh index a3c01fc..5fd051c 100644 --- a/entry.sh +++ b/entry.sh @@ -19,8 +19,11 @@ ln -s $monitordir/config.php /var/www/html/modules/config.php # the old file next to it on the volume). Never blocks the web server. php /var/www/html/scripts/migrate-config.php || echo "WARNING: config migration failed, starting with existing config." -# change folder rights so www-data can read -chmod 755 /opt +# let www-data reach the config. /opt only needs traverse (o+x), not +# read/list: with the legacy `-v ~:/opt` mount /opt *is* the host home +# directory, which must not be made listable for other users. +chmod o+x /opt +chmod 755 "${monitordir}" # start apache apache2-foreground From 8f09b62fc8e4b9741708a902096d87d78b19eeef Mon Sep 17 00:00:00 2001 From: TheJakeBraun <210177983+TheJakeBraun@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:50:01 -0700 Subject: [PATCH 02/12] Document node RPC safety requirements in README The monitor needs only read-only RPC. Tell operators to keep enable_control off and never expose the RPC port publicly. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index f65e953..e3c01e2 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,19 @@ services: 6. Done! +## Node RPC safety + +The monitor only uses read-only RPC actions (`version`, `block_count`, `peers`, +`account_balance`, `telemetry`, ...). It never needs wallet or control actions, so: + +- Keep `enable_control = false` in the node's `config-rpc.toml`. +- Bind RPC to localhost (`::1` / `127.0.0.1`) or to the Docker network only. Never + publish port 7076 on a public interface (the Compose example above uses + `127.0.0.1:7076:7076` for that reason). +- When the monitor reaches the node by container name, any other container on the same + Docker network can reach the RPC as well, which is one more reason to keep + `enable_control` off. + ## Manual Installation ### Prerequisites From 3de6890c809a2ce17c714afa8af23ac461448f71 Mon Sep 17 00:00:00 2001 From: TheJakeBraun <210177983+TheJakeBraun@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:50:48 -0700 Subject: [PATCH 03/12] Write config migration backups as config.backup-.php The backup used to be config.php.bak-, which a web server serves as plain text when the config lives in the webroot (manual installs), and it was created with the default umask. Use a .php name next to the config, copy the original's permission bits, restore the backup when writing the new config fails, and ignore the backups in git and the Docker build context. Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 1 + .gitignore | 3 ++- scripts/migrate-config.php | 8 +++++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.dockerignore b/.dockerignore index c381112..2ca585f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,3 +3,4 @@ build.txt docker-compose.dev.yml modules/config.php +modules/config.backup-*.php diff --git a/.gitignore b/.gitignore index 491eace..118e716 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ modules/config.php .idea/ nanote.txt -nanoNodeMonitor \ No newline at end of file +nanoNodeMonitor +modules/config.backup-*.php diff --git a/scripts/migrate-config.php b/scripts/migrate-config.php index e980477..90014ff 100644 --- a/scripts/migrate-config.php +++ b/scripts/migrate-config.php @@ -212,13 +212,19 @@ function nnm_load_config_state($modulesDir, $configPath) exit(0); } -$backupPath = $configPath . '.bak-' . date('Ymd-His'); +// The backup keeps a .php extension so a web server executes it (printing +// nothing) instead of serving the config source as plain text when it sits +// in a webroot (manual installs keep config.php in modules/). It also keeps +// the original's permission bits instead of the default umask. +$backupPath = dirname($configPath) . '/config.backup-' . date('Ymd-His') . '.php'; if (!copy($configPath, $backupPath)) { exit("ERROR: could not back up config.php to $backupPath - aborting.\n"); } +@chmod($backupPath, fileperms($configPath) & 0777); echo " - old config backed up to " . basename($backupPath) . "\n"; if (file_put_contents($configPath, $out, LOCK_EX) === false) { + copy($backupPath, $configPath); exit("ERROR: could not write $configPath. Old config restored from backup.\n"); } From 53b66c5752a21072ef2f3365c9829554d878985c Mon Sep 17 00:00:00 2001 From: TheJakeBraun <210177983+TheJakeBraun@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:52:38 -0700 Subject: [PATCH 04/12] Only flatten configs that pass a token allowlist The custom-logic scanner in migrate-config.php was a denylist, so getenv(), backticks, new, eval, ?:, fn, match and any function call passed, were evaluated as root at container start and written back as literals (materializing secrets, or producing a config that fatals). After the existing denylist, require every token to be on an allowlist of plain assignments (literals, arrays, true/false/null, defined constants, non-superglobal variables, = ; , ( ) [ ] . - +) and reject "(" directly after a value, which would be a call. Anything else is treated as scripted and left untouched. config.sample.php and plain configs migrate exactly as before. Co-Authored-By: Claude Opus 5.5 --- AGENT.md | 9 +++++- scripts/migrate-config.php | 61 +++++++++++++++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/AGENT.md b/AGENT.md index 5d0a894..08a6b3d 100644 --- a/AGENT.md +++ b/AGENT.md @@ -82,7 +82,14 @@ one-time rules (like the v1 dark→modern theme move) never re-fire. The script token-scans the config source **before executing it** and skips configs containing custom PHP logic (control flow, superglobals like -`$_SERVER`, `die`/`exit`, output, includes). Never weaken this: operators run +`$_SERVER`, `die`/`exit`, output, includes). After that denylist an +**allowlist** pass (`nnm_config_is_plain()`) accepts only assignments of +literals, arrays, `true`/`false`/`null`, defined constants, variables and +`= ; , ( ) [ ] . - +`; any other token (function calls such as `getenv()`, +backticks, `new`, `eval`, `?:`, `fn`, `match`, heredocs, `"$interpolation"`) +marks the config as scripted. `(` directly after a value is treated as a call. +This keeps runtime-computed values (e.g. secrets read from the environment) +from being evaluated as root and baked into the file. Never weaken this: operators run hand-crafted configs with host-based switching and request guards, and flattening one would bake in a single branch of that logic. Scripted configs silence the startup notice by declaring a literal `$configVersion = N;` at the diff --git a/scripts/migrate-config.php b/scripts/migrate-config.php index 90014ff..5c9201d 100644 --- a/scripts/migrate-config.php +++ b/scripts/migrate-config.php @@ -62,7 +62,66 @@ function nnm_config_is_scripted($source, &$reason) return true; } } - return false; + + return !nnm_config_is_plain($source, $reason); +} + +/** + * Allowlist pass: a config is "plain" (safe to execute and flatten) only if + * it consists of variable assignments of literals, arrays, constants and + * simple arithmetic/concatenation. Anything else - function calls, getenv(), + * backticks, new, eval, ?:, fn, match, heredocs, inline HTML... - means + * the file is treated as scripted and left untouched, so values the operator + * deliberately computes at runtime (e.g. secrets from the environment) are + * never evaluated here and baked into the file as literals. + */ +function nnm_config_is_plain($source, &$reason) +{ + $allowedTokens = array( + T_OPEN_TAG, T_CLOSE_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT, + T_VARIABLE, T_CONSTANT_ENCAPSED_STRING, T_LNUMBER, T_DNUMBER, + T_ARRAY, T_DOUBLE_ARROW, T_STRING, + ); + $allowedChars = array('=', ';', ',', '(', ')', '[', ']', '.', '-', '+'); + // "(" may open array(...) or group an expression, but never call + // something: after a value ($var, 'string', CONST, ")" or "]") it + // would be a (variable) function call such as 'phpinfo'() or $f(). + $callableBefore = array(T_VARIABLE, T_CONSTANT_ENCAPSED_STRING, T_STRING, ')', ']'); + + $prev = null; // previous significant token id (int) or char + foreach (token_get_all($source) as $token) { + if (is_array($token)) { + list($id, $text) = $token; + if (!in_array($id, $allowedTokens, true)) { + $reason = token_name($id) . " '" . trim($text) . "'"; + return false; + } + if ($id === T_VARIABLE && preg_match('/^\$(GLOBALS|_[A-Z]+)$/', $text)) { + $reason = $text; + return false; + } + if ($id === T_STRING && !in_array(strtolower($text), array('true', 'false', 'null'), true) + && !defined($text)) { + $reason = "'$text'"; + return false; + } + if (in_array($id, array(T_WHITESPACE, T_COMMENT, T_DOC_COMMENT), true)) { + continue; + } + $prev = $id; + continue; + } + if (!in_array($token, $allowedChars, true)) { + $reason = "'$token'"; + return false; + } + if ($token === '(' && in_array($prev, $callableBefore, true)) { + $reason = 'function call'; + return false; + } + $prev = $token; + } + return true; } $source = file_get_contents($configPath); From 9111de051ee974199b9c4e0e77822b592f99ac30 Mon Sep 17 00:00:00 2001 From: TheJakeBraun <210177983+TheJakeBraun@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:54:18 -0700 Subject: [PATCH 05/12] Refuse untrusted FileCache directories and escape latest version The default file cache lives in the shared system temp dir. A local user could pre-create it (world-writable or as a symlink) and plant cache entries, which reached the page unescaped via getVersionInformation(), or plant symlinks so writes clobber other files. FileCache now only uses its base directory when it is a real directory (not a symlink), owned by the effective user and not world-writable, and never reads or writes a cache file that is a symlink. An unsafe directory disables the cache (NullCache behaviour, logged), never fatal. The latest monitor version is escaped with e(); normal version strings render byte-identical. Co-Authored-By: Claude Opus 5.5 --- modules/cache/FileCache.php | 77 ++++++++++++++++++++++++++++++++++--- modules/functions.php | 2 +- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/modules/cache/FileCache.php b/modules/cache/FileCache.php index f6b5308..f616a70 100644 --- a/modules/cache/FileCache.php +++ b/modules/cache/FileCache.php @@ -28,6 +28,12 @@ class FileCache extends Cache */ private $ttl = 30; + /** + * Result of the base directory safety check (null = not checked yet). + * @var bool|null + */ + private $cache_dir_safe = null; + /** * Creates a FileCache object * @@ -56,9 +62,12 @@ public function __construct(array $options = array()) */ public function read($id) { + if (!$this->isCacheDirectorySafe()) { + return NULL; + } $file_name = $this->getFileName($id); - if (!is_file($file_name) || !is_readable($file_name)) { + if (is_link($file_name) || !is_file($file_name) || !is_readable($file_name)) { return NULL; } @@ -97,13 +106,13 @@ public function delete($id) */ public function write($id, $data) { - $dir = $this->getDirectory($id); - if (!is_dir($dir)) { - if (!@mkdir($dir, 0700, true) && !is_dir($dir)) { - return false; - } + if (!$this->isCacheDirectorySafe() || !$this->ensureDirectory($id)) { + return false; } $file_name = $this->getFileName($id); + if (is_link($file_name)) { + return false; + } $lifetime = time() + $this->ttl; $encoded = json_encode($data); $result = file_put_contents($file_name, $lifetime . PHP_EOL . $encoded, LOCK_EX); @@ -117,6 +126,62 @@ public function write($id, $data) // PRIVATE METHODS //------------------------------------------------ + /** + * Creates the entry's sub directory inside the (already checked) base dir. + * + * @param string $id + * + * @return bool + */ + protected function ensureDirectory($id) + { + $dir = $this->getDirectory($id); + if (!is_dir($dir)) { + if (!@mkdir($dir, 0700, true) && !is_dir($dir)) { + return false; + } + } + return true; + } + + /** + * Checks that the base cache directory can be trusted. + * + * The default directory lives in the shared system temp dir, where another + * local user could pre-create it (world-writable, or as a symlink) and + * plant or redirect cache files. Only use it if it is a real directory + * owned by the effective user of this process and not world-writable. + * Otherwise the cache is disabled (NullCache behaviour), never fatal. + * POSIX mode/owner checks are skipped on Windows, where they do not apply. + * + * @return bool + */ + protected function isCacheDirectorySafe() + { + if ($this->cache_dir_safe !== null) { + return $this->cache_dir_safe; + } + $base = $this->getCacheDirectory(); + if (!is_link($base) && !is_dir($base)) { + @mkdir($base, 0700, true); + } + clearstatcache(true, $base); + + $safe = !is_link($base) && is_dir($base); + if ($safe && PHP_OS_FAMILY !== 'Windows') { + $perms = @fileperms($base); + $safe = $perms !== false && ($perms & 0002) === 0; + if ($safe && function_exists('posix_geteuid')) { + $safe = @fileowner($base) === posix_geteuid(); + } + } + if (!$safe) { + error_log("nanoNodeMonitor: cache directory $base is not safe to use " + . "(symlink, world-writable or owned by another user) - file cache disabled"); + } + return $this->cache_dir_safe = $safe; + } + /** * Fetches a directory to store the cache data * diff --git a/modules/functions.php b/modules/functions.php index d0a9cbc..f1ea2bf 100644 --- a/modules/functions.php +++ b/modules/functions.php @@ -170,7 +170,7 @@ function getVersionInformation($latestVersion) if ( version_compare($currentVersion, $latestVersion) < 0 ) { - $versionInfo .= "
A new version " . $latestVersion; + $versionInfo .= "
A new version " . e($latestVersion); $versionInfo .= " is available on "; $versionInfo .= "GitHub."; } From b87f14bcd726a8bd81d3ce7e04235eb03c4f9123 Mon Sep 17 00:00:00 2001 From: TheJakeBraun <210177983+TheJakeBraun@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:54:58 -0700 Subject: [PATCH 06/12] Rebuild expired cache entries single-flight While an entry was expired every concurrent request rebuilt it, so a burst of api.php requests fired about 11 RPCs each at the node. FileCache::fetch() now rebuilds under an exclusive flock() on .lock and re-reads after acquiring it; ApcuCache::fetch() does the same with apcu_add() as an atomic lock (released on exit too). Waiting is bounded to 10 s, after which, or when locking is impossible, both fall back to the previous read-callback-write behaviour. Co-Authored-By: Claude Opus 5.5 --- modules/cache/ApcuCache.php | 58 +++++++++++++++++++++++++++ modules/cache/FileCache.php | 79 +++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/modules/cache/ApcuCache.php b/modules/cache/ApcuCache.php index 697a1bc..ac2bb0a 100644 --- a/modules/cache/ApcuCache.php +++ b/modules/cache/ApcuCache.php @@ -3,6 +3,11 @@ class ApcuCache extends Cache { private $ttl; + // how long to wait for another request's rebuild before rebuilding anyway + const LOCK_WAIT_SECONDS = 10; + // safety expiry of the rebuild lock should its holder die unexpectedly + const LOCK_TTL = 30; + public function __construct(array $options = array()) { $this->ttl = array_key_exists('ttl', $options) ? $options['ttl'] : 30; } @@ -16,4 +21,57 @@ public function read($key) { public function write($key, $data) { return apcu_store($key, $data, $this->ttl); } + + /** + * Single-flight fetch: apcu_add() on ".lock" is an atomic lock, so + * only one request rebuilds an expired entry while the others wait + * (bounded) for its result. The lock is also released if the callback + * exits (e.g. node down -> HTTP 503). If the wait times out, this falls + * back to the plain read-callback-write behaviour. + */ + public function fetch($key, $callback) { + $data = $this->read($key); + if (!is_null($data)) return $data; + + $lockKey = $key . '.lock'; + $token = uniqid('', true); + $locked = apcu_add($lockKey, $token, self::LOCK_TTL); + + if (!$locked) { + $deadline = microtime(true) + self::LOCK_WAIT_SECONDS; + while (!$locked && microtime(true) < $deadline) { + usleep(50000); + $data = $this->read($key); + if (!is_null($data)) return $data; + $locked = apcu_add($lockKey, $token, self::LOCK_TTL); + } + } + + if (!$locked) { + $data = $callback(); + $this->write($key, $data); + return $data; + } + + $released = false; + $release = function () use ($lockKey, $token, &$released) { + if (!$released && apcu_fetch($lockKey) === $token) { + apcu_delete($lockKey); + } + $released = true; + }; + register_shutdown_function($release); + + try { + // another request may have rebuilt the entry just before we locked + $data = $this->read($key); + if (is_null($data)) { + $data = $callback(); + $this->write($key, $data); + } + } finally { + $release(); + } + return $data; + } } diff --git a/modules/cache/FileCache.php b/modules/cache/FileCache.php index f616a70..bd11da4 100644 --- a/modules/cache/FileCache.php +++ b/modules/cache/FileCache.php @@ -34,6 +34,11 @@ class FileCache extends Cache */ private $cache_dir_safe = null; + /** + * Maximum time in seconds to wait for another request's rebuild. + */ + const LOCK_WAIT_SECONDS = 10; + /** * Creates a FileCache object * @@ -122,10 +127,84 @@ public function write($id, $data) return true; } + /** + * Fetches an entry, rebuilding it with $callback when missing/expired. + * + * Single-flight: only one request rebuilds an expired entry. The rebuild + * runs under an exclusive flock() on ".lock"; concurrent + * requests wait (bounded) for it and then read the fresh entry instead + * of all hitting the node at once. The lock is released automatically + * if the callback exits (e.g. node down -> HTTP 503). If locking is not + * possible, or the wait times out, this falls back to the plain + * read-callback-write behaviour. + * + * @param string $id + * @param callable $callback + */ + public function fetch($id, $callback) + { + $data = $this->read($id); + if (!is_null($data)) { + return $data; + } + + $lock = $this->acquireLock($id); + if ($lock === null) { + $data = $callback(); + $this->write($id, $data); + return $data; + } + + try { + // another request may have rebuilt the entry while we waited + $data = $this->read($id); + if (is_null($data)) { + $data = $callback(); + $this->write($id, $data); + } + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + return $data; + } + //------------------------------------------------ // PRIVATE METHODS //------------------------------------------------ + /** + * Takes the rebuild lock for an entry. + * + * @param string $id + * + * @return resource|null locked handle, or null when locking is not possible + */ + protected function acquireLock($id) + { + if (!$this->isCacheDirectorySafe() || !$this->ensureDirectory($id)) { + return null; + } + $lock_name = $this->getFileName($id) . '.lock'; + if (is_link($lock_name)) { + return null; + } + $handle = @fopen($lock_name, 'c'); + if ($handle === false) { + return null; + } + $deadline = microtime(true) + self::LOCK_WAIT_SECONDS; + do { + if (flock($handle, LOCK_EX | LOCK_NB)) { + return $handle; + } + usleep(50000); + } while (microtime(true) < $deadline); + + fclose($handle); + return null; + } + /** * Creates the entry's sub directory inside the (already checked) base dir. * From 7a9b4ef3c2fb2aa354ff3e501256e5bc578174c2 Mon Sep 17 00:00:00 2001 From: TheJakeBraun <210177983+TheJakeBraun@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:55:42 -0700 Subject: [PATCH 07/12] Deny web access to modules/, scripts/ and repo metadata Direct requests to modules/*.php leaked paths through PHP errors, and installs that git-clone into the webroot served .git/, the docs and the Docker files. modules/.htaccess and scripts/.htaccess deny all requests (nothing the browser loads lives there). The root .htaccess answers 404 for .git/, .github/, dotfiles, *.md/yml/yaml/sh/ps1 and Dockerfile using only mod_alias, so it needs no AuthConfig override and cannot 500 the page on hosts that only allow FileInfo. .dockerignore now keeps the root .htaccess in the image. Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 1 + .htaccess | 10 ++++++++++ modules/.htaccess | 10 ++++++++++ scripts/.htaccess | 10 ++++++++++ 4 files changed, 31 insertions(+) create mode 100644 .htaccess create mode 100644 modules/.htaccess create mode 100644 scripts/.htaccess diff --git a/.dockerignore b/.dockerignore index 2ca585f..75ba39b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ .* +!.htaccess *.md build.txt docker-compose.dev.yml diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..fba5eaf --- /dev/null +++ b/.htaccess @@ -0,0 +1,10 @@ +# Hide repository metadata and docs on installs that git-clone the project +# into the webroot. Uses only mod_alias (AllowOverride FileInfo), answering +# 404 as if the files were not there. The page itself (index.php, api.php, +# templates/, static/) is unaffected. + + RedirectMatch 404 "/\.(git|github)(/|$)" + RedirectMatch 404 "/\.(gitignore|dockerignore)$" + RedirectMatch 404 "(?i)\.(md|yml|yaml|sh|ps1)$" + RedirectMatch 404 "/Dockerfile$" + diff --git a/modules/.htaccess b/modules/.htaccess new file mode 100644 index 0000000..8b880a1 --- /dev/null +++ b/modules/.htaccess @@ -0,0 +1,10 @@ +# Nothing in this directory is meant to be requested by a browser: PHP files +# here are only included by index.php/api.php (or run from the CLI), and +# modules/ holds the operator's config.php and its backups. + + Require all denied + + + Order allow,deny + Deny from all + diff --git a/scripts/.htaccess b/scripts/.htaccess new file mode 100644 index 0000000..8b880a1 --- /dev/null +++ b/scripts/.htaccess @@ -0,0 +1,10 @@ +# Nothing in this directory is meant to be requested by a browser: PHP files +# here are only included by index.php/api.php (or run from the CLI), and +# modules/ holds the operator's config.php and its backups. + + Require all denied + + + Order allow,deny + Deny from all + From c179df25b7bb66aa618b297b09e22b9d5e14b7a4 Mon Sep 17 00:00:00 2001 From: TheJakeBraun <210177983+TheJakeBraun@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:56:00 -0700 Subject: [PATCH 08/12] Render API errors as text instead of innerHTML The error handler assigned the raw response body to innerHTML, so a proxy/CDN error page or any future error path echoing data would be injected as markup. Extract the text with DOMParser and show it in the same .myError box via textContent (fallback 'API error'). Co-Authored-By: Claude Opus 5.5 --- static/js/index.js | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/static/js/index.js b/static/js/index.js index d6eef87..2441878 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -80,10 +80,30 @@ function updateStats(){ .catch(function (error) { console.log('FAIL', error); if(error.response){ - document.getElementById("content").innerHTML = error.response.data; + showError(error.response.data); } }) .finally(function () { setTimeout(updateStats, GLOBAL_REFRESH * 1000); }); } + +// Render an API error as plain text in the .myError box. The response body is +// never inserted as markup: only its text is extracted (myError() sends +// '
message
'; proxies/CDNs may send full pages). +function showError(data){ + var text = ''; + if (typeof data === 'string') { + try { + text = new DOMParser().parseFromString(data, 'text/html').body.textContent; + } catch (e) { + text = ''; + } + } + var box = document.createElement('div'); + box.className = 'myError'; + box.textContent = (text && text.trim()) ? text : 'API error'; + var content = document.getElementById('content'); + content.textContent = ''; + content.appendChild(box); +} From 1b4e82d5818dcd67b1941fe3bd1e7356c07358b3 Mon Sep 17 00:00:00 2001 From: TheJakeBraun <210177983+TheJakeBraun@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:56:11 -0700 Subject: [PATCH 09/12] Send nosniff and Referrer-Policy headers on the status page X-Frame-Options/frame-ancestors are intentionally not set so operators can keep embedding the page. Co-Authored-By: Claude Opus 5.5 --- index.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/index.php b/index.php index 25f34cc..2124abb 100644 --- a/index.php +++ b/index.php @@ -1,4 +1,9 @@ Date: Mon, 28 Sep 2026 20:56:11 -0700 Subject: [PATCH 10/12] Use EXTERNAL_TIMEOUT for the monitor release lookup The GitHub lookup rendered in the footer used a 30 s timeout, so index.php could block for about 32 s when GitHub is slow. Use EXTERNAL_TIMEOUT like the other external lookups. Co-Authored-By: Claude Opus 5.5 --- modules/functions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/functions.php b/modules/functions.php index f1ea2bf..bcf2e72 100644 --- a/modules/functions.php +++ b/modules/functions.php @@ -126,7 +126,7 @@ function getLatestReleaseVersion() CURLOPT_RETURNTRANSFER => true, CURLOPT_ENCODING => "", CURLOPT_MAXREDIRS => 10, - CURLOPT_TIMEOUT => 30, + CURLOPT_TIMEOUT => EXTERNAL_TIMEOUT, CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, CURLOPT_CUSTOMREQUEST => "GET", From c44af4270a86bcb991d1525bfdd8c4b02fd58409 Mon Sep 17 00:00:00 2001 From: TheJakeBraun <210177983+TheJakeBraun@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:56:58 -0700 Subject: [PATCH 11/12] Pin workflow actions to commit SHAs The publish job holds packages: write, so a moved tag on a third-party action could push a malicious :latest image. Pin every action to its full commit SHA (version in a trailing comment; Dependabot keeps SHA pins updated) and stop persisting the checkout token. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docker-publish.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 5ff1da8..857d20a 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -28,7 +28,9 @@ jobs: DOCKERHUB_ENABLED: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }} steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false - name: Select target registries id: images run: | @@ -41,7 +43,7 @@ jobs: echo "EOF" >> "$GITHUB_OUTPUT" - name: Docker meta id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: # GitHub Container Registry is the primary image location; # Docker Hub is included only when its secrets are configured @@ -57,24 +59,24 @@ jobs: type=semver,pattern={{major}} type=sha - name: Set up QEMU - uses: docker/setup-qemu-action@v4 + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Login to GHCR if: github.event_name != 'pull_request' - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to DockerHub if: github.event_name != 'pull_request' && env.DOCKERHUB_ENABLED == 'true' - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . platforms: linux/amd64,linux/arm64 From 0e0628950ed13f54ca4ad311862eea0e518670ed Mon Sep 17 00:00:00 2001 From: TheJakeBraun <210177983+TheJakeBraun@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:57:15 -0700 Subject: [PATCH 12/12] Bump PROJECT_VERSION to 1.10.2 and document hardening in AGENT.md Co-Authored-By: Claude Opus 5.5 --- AGENT.md | 29 ++++++++++++++++++++++++++++- modules/constants.php | 2 +- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/AGENT.md b/AGENT.md index 08a6b3d..ec9581d 100644 --- a/AGENT.md +++ b/AGENT.md @@ -43,6 +43,7 @@ modules/ templates/index.hbs Handlebars template rendered client-side with api.php data static/ Vendored CSS/JS, themes, images (no build step) Dockerfile, entry.sh Docker image; entry.sh symlinks /opt/nanoNodeMonitor/config.php +.htaccess (root, modules/, scripts/) Apache access rules (see Security conventions) ``` ## Architecture / request flow @@ -57,6 +58,10 @@ Dockerfile, entry.sh Docker image; entry.sh symlinks /opt/nanoNodeMonitor/con node version, block counts, peers, confirmation-time percentiles, account balance/representative/weight, host system load/memory/uptime, telemetry, and sync percentage. Cache TTL (default 30 s) shields the node from request bursts. + `FileCache` and `ApcuCache` override `fetch()` single-flight: only one request + rebuilds an expired entry (flock on `.lock` / `apcu_add` lock), the + others wait up to 10 s and re-read; if locking fails they fall back to plain + read-callback-write. 4. **RPC layer** — `functions_rpc.php` has one function per RPC action; all go through `postCurl()`, which POSTs JSON to `http://$nanoNodeRPCIP:$nanoNodeRPCPort` and dies with an HTTP 503 (`myError()`) when the node is unreachable. @@ -74,7 +79,8 @@ Configs carry a `$configVersion` schema marker (`0` for legacy files, `CONFIG_VERSION` in `constants.php` is current). `scripts/migrate-config.php` upgrades old configs (run automatically by `entry.sh` on container start): it loads defaults + the user's config, applies sequential per-version migration -steps, backs up the old file, and writes a minimal config containing only +steps, backs up the old file (as `config.backup-.php` next to it, with +the original's permission bits, so a web server never serves it as text), and writes a minimal config containing only non-default values. When a config change needs migration (renamed setting, retired option value), bump `CONFIG_VERSION` and add a step to the `$migrations` array keyed by the new version — steps run once per config, so @@ -102,6 +108,27 @@ These were deliberate hardening decisions — do not regress them: - **Never use `unserialize()` on cache or request data.** `FileCache` stores JSON (a malicious serialized payload in a predictable cache path would otherwise allow PHP object injection). `RedisCache` also uses JSON. +- **`FileCache` only trusts a safe base directory**: a real directory (not a + symlink), owned by the effective user (`posix_geteuid()`) and not world-writable; + cache files that are symlinks are never read or written. Otherwise it behaves + like `NullCache` (logged via `error_log`), never fatal. Mode/owner checks are + skipped on Windows. +- **Web access rules**: `modules/.htaccess` and `scripts/.htaccess` deny all + requests, so nothing the browser needs may live there (the page loads only + `index.php`, `api.php`, `templates/` and `static/`). The root `.htaccess` 404s + `.git/`, `.github/`, dotfiles, `*.md/yml/yaml/sh/ps1` and `Dockerfile` using only + `mod_alias` (keep it to `AllowOverride FileInfo` directives so it cannot 500 the + page on restricted hosts). `.dockerignore` must keep `!.htaccess`. +- `index.php` sends `X-Content-Type-Options: nosniff` and + `Referrer-Policy: strict-origin-when-cross-origin`. No `X-Frame-Options` / + `frame-ancestors`: operators embed the page. +- Client-side errors are shown via `textContent` (`showError()` in `index.js`), + never by assigning response data to `innerHTML`. +- Docker: `entry.sh` only adds traverse (`o+x`) to `/opt` and makes the config dir + 755; never chmod or broaden mounts beyond `/opt/nanoNodeMonitor` (legacy + `-v ~:/opt` mounts make `/opt` the operator's home directory). +- Workflow actions are pinned to full commit SHAs (`# vX.Y.Z` comment); + Dependabot updates them. - **Escape all config/host-derived values in templates** with the `e()` helper (`htmlspecialchars`, ENT_QUOTES). Account values embedded in third-party image URLs are additionally `rawurlencode()`d. Values echoed into inline JS use `(int)` casts or diff --git a/modules/constants.php b/modules/constants.php index ccb37bb..c6c9fc7 100644 --- a/modules/constants.php +++ b/modules/constants.php @@ -1,7 +1,7 @@