Skip to content

💥 feat: generated files leave the plugin folder (3.0) - #128

Merged
gfazioli merged 4 commits into
v3from
feat/v3-storage-outside-plugin
Oct 6, 2026
Merged

gfazioli merged 4 commits into
v3from
feat/v3-storage-outside-plugin

Conversation

@gfazioli

@gfazioli gfazioli commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

For 3.0, on v3: the files a plugin generates leave the plugin folder. This is the breaking half of audit S6/S7. 2.1.3 (#126) shipped what 2.x could fix in place.

💥 Where they go

Support\Storage::path($plugin, $folder) returns uploads/wpbones/<plugin folder>/<folder>.

  • It is made with wp_mkdir_p(), with an index.php at every level and a deny-all .htaccess at the top.
  • Fail closed. With no folder, or no rule in place, path() answers null:
    • a Blade view throws a RuntimeException that says why;
    • a single or daily log falls back to error_log().
  • No temp-folder fallback. A shared /tmp would let other local users read the logs and plant compiled views (Codex).
  • The .htaccess:
    • it is written aside and renamed into place;
    • one that already exists is kept, but only if it denies (Deny from all or Require all denied).
  • Storage::delete($plugin) is there for an uninstall.php.
  • The folder is named after the plugin's directory, not its slug: the logger is made at boot, and the slug is only read from the header on init. The first live run, with the slug, wrote into wpbones/plugin/.
2.1.3 3.0
Compiled Blade views <plugin>/.cache/*.php, the folder made by every View uploads/wpbones/<plugin>/views/*.php, made only when a Blade view first renders
single / daily logs, default path <plugin>/storage/logs/debug.log, <date>.log uploads/wpbones/<plugin>/logs/debug-<hash>.log, <date>-<hash>.log
errorlog error_log() only the same
plugin.logging.path its own folder and names the same (made with wp_mkdir_p())

The log file name carries a 12-character HMAC keyed with AUTH_SALT only. ABSPATH is not in the key: it differs between web requests and WP-CLI, and between the releases of an atomic deploy. The HMAC makes the name impossible to guess where the .htaccess is not read (nginx). The key is not wp_hash(): it is pluggable and not loaded when the plugin boots. A Brain Monkey stub hid that; the live run fataled.

Tests

  • composer test: 317 tests and 842 assertions, against 307 and 824 on v3.

    • New: StorageTest, ViewBladeCacheTest, LogServiceProviderTest.
    • GeneratedFilesTest (2.1.3) keeps only its errorlog half; the rest moved.
  • Live, in WPKirk-Developing, with storage-live-smoke.sh, on a 3.0 checkout (11 checks):

    • a default logger() call writes no file;
    • the Blade view renders, with no .bladec and no <plugin>/.cache;
    • the compiled view is under uploads/wpbones/WPKirk-Developing/views, and a direct request for it shows no PHP;
    • the deny-all .htaccess is present;
    • a single log lands in debug-<hash>.log with its message.

    11 ✓. The same smoke on v2.1.2 fails 3 of its 6 common checks.

Review

  • Copilot: the account's quota is exhausted.
  • Codex (gpt-6-sol, high), round 1, three findings:
    • the temp fallback was not private;
    • a deny rule that failed to write was ignored;
    • a test deleted the shared /tmp/wpbones.
  • Codex, round 2, two findings: a partial .htaccess passed for a rule, and render() left its buffer open with a Blade @section open.
  • An independent review (a subagent with a clean context), fixed in the last commit:
    • races on first creation;
    • the Require all denied form;
    • ABSPATH in the HMAC key;
    • BladeOne's showError() closing the page's buffer;
    • no cleanup on uninstall;
    • a test that never defined AUTH_SALT.
  • Open, for the docs:
    • a folder first made by WP-CLI running as root is not writable by PHP-FPM;
    • multisite spreads the logs over one folder per site;
    • ms-files.php networks and remote uploads (S3) are not protected by the .htaccess.
  • composer test: 327 tests and 857 assertions; storage-live-smoke.sh: 11 ✓.

Audit S6/S7. Up to 2.x compiled Blade views (<plugin>/.cache, .bladec) and
log files (<plugin>/storage/logs) were written inside the plugin folder,
created 0777, where the web server serves them as text; and "errorlog", the
default, also wrote a daily file there, against its own documentation.

- Support\Storage::path(plugin, folder): uploads/wpbones/<plugin folder>/
  <folder>, made with wp_mkdir_p, an index.php at every level and a
  deny-all .htaccess at the top (left alone once it exists); the temp folder
  when uploads cannot be written. Named after the plugin's folder: the slug
  is read from the header on init, after the logger is made.
- Blade is made when a Blade view first renders, compiles there, as .php.
- Logs: "errorlog" is error_log() only; "single" and "daily" default to
  Storage, with a hash keyed by AUTH_SALT in the file name (not wp_hash(),
  which is pluggable and not loaded yet at boot); a plugin.logging.path of
  the plugin's own keeps its folder and names.
- no temporary-folder fallback: a shared /tmp would let other local users
  read the logs and plant compiled views. When uploads cannot be written,
  Storage::path() answers null: a Blade view throws a RuntimeException that
  says why, a single/daily log goes to error_log() only;
- a deny rule that cannot be written means no folder, as Apache would serve
  what it holds;
- the test no longer removes a shared /tmp/wpbones;
- View::render(true) closes its buffer when the view throws.
- an .htaccess is accepted only if it denies: one that a short write left
  empty or partial (a full disk) is removed, and an existing one that denies
  nothing means no folder;
- View::render(true) restores the buffer level it found, so a view that
  throws inside a Blade @section or @Push no longer leaves render()'s own
  buffer open.
- a folder another request made first is used (wp_mkdir_p() answers false
  when it loses the race), and the .htaccess is written aside and renamed,
  so no request reads it half written;
- `Require all denied` alone (Apache 2.4) is accepted as the rule;
- the log name is keyed with AUTH_SALT only (or the salt WordPress keeps in
  the options): ABSPATH differed between a web request and WP-CLI, and
  between the releases of an atomic deploy;
- Blade runs with throwOnError: BladeOne's showError() closed a buffer it
  had not opened, the page's own;
- Storage::delete($plugin), for an uninstall.php;
- tests: the log key with a real AUTH_SALT, in a process of its own (the
  suite never defined one, so dropping the salt passed).
@gfazioli
gfazioli merged commit 5bbff80 into v3 Oct 6, 2026
4 checks passed
@gfazioli
gfazioli deleted the feat/v3-storage-outside-plugin branch October 6, 2026 14:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant