Skip to content

Fix #1335: post-install-wizard audit (panel-takeover paths + 8 UX gaps)#1336

Merged
rumblefrog merged 7 commits into
mainfrom
fix/1335-install-wizard-human-flow-audit
May 11, 2026
Merged

Fix #1335: post-install-wizard audit (panel-takeover paths + 8 UX gaps)#1336
rumblefrog merged 7 commits into
mainfrom
fix/1335-install-wizard-human-flow-audit

Conversation

@rumblefrog

Copy link
Copy Markdown
Member

Summary

Closes #1335.

End-to-end fix for the post-#1332 install-wizard human-flow audit. Two
critical security findings (panel-takeover paths) + three major UX
findings + seven minor papercuts, plus the paired regression tests
and doc updates.

Critical (security)

  • C1 — Drop the HTTP_HOST != "localhost" exemption on init.php's
    install/+updater/-presence guard. The exemption was a panel-takeover
    path on any panel reachable via a localhost Host header
    (port-forward, SSH tunnel, ngrok, Cloudflare Tunnel). Replaced with
    an explicit SBPP_DEV_KEEP_INSTALL constant (loud-named, only
    defined automatically by docker/php/dev-prepend.php for the dev
    stack). IS_UPDATE exemption preserved.
  • C2 — Refuse to start the wizard over an already-installed panel.
    Pre-fix, anyone reaching /install/ after the wizard finished could
    walk it again and overwrite config.php / admin / DB. The new
    web/install/already-installed.php surface (pure inline HTML + CSS,
    same shape as recovery.php since it runs upstream of Composer)
    emits 409 Conflict and explains the intentional-reinstall path
    (delete config.php first).

Major (UX friction at the worst possible moment)

  • M1 — Replace bare die('SourceBans++ is not installed') /
    die('Please delete the install directory') /
    die('Composer autoload not found') calls with self-contained chrome
    in web/init-recovery.php's sbpp_render_install_blocked_page().
    Bonus: missing-config.php now redirects to /install/ instead of
    dying.
  • M2 — Extend page 3's writable-folder fail message with a chmod
    remediation hint.
  • M3 — Step 5's admin form: page-tail vanilla JS now validates
    SteamID format + email shape on submit (alongside the existing
    password-match check) so the round-trip-with-wiped-passwords path
    stops being the common case. Server-side validation stays as the
    second line of defense.

Minor (papercuts)

  • m1recovery.php 302s to /install/ when vendor IS present
    on a direct hit (was always 503).
  • m2 — Standardize "License" (American) across step 1.
  • m3 — Standardize step 2 field testids on install-database-*
    (was a mix of install-db-* for fields, install-database-* for
    form/buttons).
  • m4sbpp_install_translate_pdo_error() translates the four
    common connect-error codes (1045 / 2002 / 1049 / 1044) into
    operator-friendly messages; raw message preserved as fallback for
    unrecognised codes.
  • m5 — Step 1 license textarea: switch from .input (which
    overrides height) to .textarea with min-height: 24rem so the
    license is readable without scroll-noise.
  • m6 — Step 6 (AMXBans import): top-of-form pointer to
    addons/amxmodx/configs/sql.cfg + per-field helper text mirroring
    step 2's shape.
  • m7 — README install steps now signpost the writable-folder
    remediation between steps 3 and 4.

Tests

web/tests/integration/InstallGuardTest.php — pure-function unit
tests pinning:

  • localhost Host header does NOT bypass the install guard (C1
    regression)
  • IS_UPDATE skips the guard (preserved legacy contract)
  • SBPP_DEV_KEEP_INSTALL skips the guard (new dev escape hatch)
  • the C2 already-installed predicate fires on config.php presence
  • sbpp_install_translate_pdo_error() covers all four common codes
    and falls back gracefully on unknowns

Doc updates

  • AGENTS.md — Install wizard lifecycle, Where to find what
    (three new rows + refresh of the existing wizard-step row), four
    new Anti-patterns entries, and refresh of the wizard-vanilla-JS
    anti-pattern (license spelling + M3 step-5 additions).
  • ARCHITECTURE.md — Web-panel directory layout (surface the new
    files), bootstrap step 2 (rewrite for the C1 + M1 contract),
    local dev-stack note (dev-prepend.php rewrite), legacy-patterns
    table (three new rows).
  • README.md — m7 writable-folder signpost.

Test plan

  • phpstan green (level 5 + dba, 239 files, 0 errors)
  • phpunit green (467 tests, 2089 assertions; 1 pre-existing
    deprecation, untouched by this PR)
  • ts-check green
  • api-contract clean (no diff after regen)
  • CI e2e green — running on PR
  • Manual walkthrough: dev-stack panel boots ✓, /install/ → 409
    Already-installed ✓, deleting config.php lets the wizard run ✓,
    web/init.php recovery surfaces render under all three scenarios ✓.

Deviations from the issue

None. The issue suggested either dropping the localhost exception
unconditionally OR moving it behind a define('SBPP_DEV_KEEP_INSTALL', true) opt-in; this PR takes the second path so the docker dev
workflow keeps working without operator intervention. Rationale:
the constant is loud-named (visibly wrong if defined in production),
defined only by docker/php/dev-prepend.php's auto_prepend_file
(production panels have no path to define it), and documented as a
dev-only escape hatch in AGENTS.md Conventions + Anti-patterns.

…rome

C1 (critical, panel-takeover via localhost host-header bypass): pre-fix
`web/init.php`'s install/ + updater/-presence guard was gated on
`$_SERVER['HTTP_HOST'] != "localhost"`, so any panel reachable via a
`localhost` Host header (port-forward, SSH tunnel, ngrok, Cloudflare
Tunnel) silently bypassed the safety check. Combined with the absent
wizard-side gate (#1335 C2, separate commit), an attacker could re-run
the wizard over a live install and overwrite `config.php` / admin / DB.
The exemption is gone; the guard is now unconditional in production,
with a single explicit `SBPP_DEV_KEEP_INSTALL` constant as the dev
escape hatch (loud-named so a production-side define is visibly wrong;
defined automatically for the docker dev stack via
`docker/php/dev-prepend.php`'s `auto_prepend_file` hook). The legacy
`IS_UPDATE` exemption — used by the updater itself — is preserved.

M1 (major, error chrome): the wizard's done-page CTA sends operators
straight to `/`. Pre-fix, that landed on bare-text
`die('SourceBans++ is not installed')` /
`die('Please delete the install directory')` /
`die('Composer autoload not found')` — stark white 200-response with no
chrome / no link to docs / no fix instructions. Reads like a server
crash to a non-technical self-hoster. Replaced with self-contained
inline-HTML error pages in `web/init-recovery.php`'s
`sbpp_render_install_blocked_page()` (mirror of `recovery.php`'s
contract: no Composer / no Smarty / no `Sbpp\…`, since the surface runs
upstream of the autoload chain). Bonus per the issue: missing-`config.php`
now redirects to `/install/` instead of dying so a fresh-tarball
operator lands directly in the wizard.

Guard logic and rendering live in pure functions
(`sbpp_check_install_guard()` + `sbpp_render_install_blocked_page()`) so
the contract is unit-testable in isolation.
`web/tests/integration/InstallGuardTest.php` pins:
 - localhost Host header does NOT bypass the guard (C1 regression)
 - IS_UPDATE skips the guard (preserved legacy contract)
 - SBPP_DEV_KEEP_INSTALL skips the guard (new dev escape hatch)
 - the C2 already-installed predicate (sister-guard, separate commit)
 - the m4 PDO error translation (separate commit)

The dev stack:
 - `docker/php/dev-prepend.php` defines `SBPP_DEV_KEEP_INSTALL` (replaces
   the pre-#1335 `HTTP_HOST` rewrite trick — that whole shape was the
   vulnerability path)
 - `docker/Dockerfile` and `docker-compose.yml` comment-only updates
   document the new mechanism
Pre-fix, `web/install/index.php` had no "is the panel already
installed?" gate. Anyone reaching `/install/` after a successful wizard
run (operator forgot to delete `install/`, or guard bypass via #1335
C1's localhost host-header trick) could walk the wizard end-to-end
again, overwriting `config.php` (when writable), creating a new admin
account, and re-pointing the panel at a different DB — a complete
panel-takeover path.

The wizard now refuses to start when `config.php` exists in the panel
root, surfacing `web/install/already-installed.php` (pure inline HTML +
CSS, mirror of `recovery.php`'s contract: no Composer / no Smarty / no
`Sbpp\…` since the gate runs BEFORE `bootstrap.php`'s autoload pull).
The page emits 409 Conflict, links the operator back to `/`
(already-installed panels boot from there), and explains the
intentional-reinstall path: delete `config.php` first. No
confirm-dialog bypass is offered — the explicit delete step forces the
operator to acknowledge the impact before the wizard touches any state.

The guard predicate is a pure function
(`sbpp_install_is_already_installed()`) so the contract is unit-testable
without a runtime install; the regression test lives in
`web/tests/integration/InstallGuardTest.php` (added in the previous
commit alongside the C1 / M1 / m4 coverage).

Sister-guard to the runtime-side `sbpp_check_install_guard()` from the
previous commit; both key off `config.php` so the contract is symmetric.
Wizard-side UX fixes from the #1335 audit, grouped into one commit
because every change is a localized template / handler tweak that
compounds the same goal: stop the wizard from feeling broken when
something goes wrong on the operator's first touch.

M2 (writable folder fail message): page 3 surfaced "Not writable:
/path/..." with no remediation. Now extends with a one-liner pointing
operators at chmod 0775 (or 0777 on shared hosting where the PHP user
isn't yours) via File Manager / FTP / chmod. Plain text, not HTML —
the surrounding Smarty template auto-escapes.

M3 (admin step round-trip): step 5 wipes both password fields on every
validation re-render (correct for `nofilter` avoidance, wrong for UX).
Extended the existing page-tail vanilla-JS guard (already covered the
mismatched-password case) with SteamID format + email shape checks so
the round-trip-with-wiped-passwords path stops being the common case.
Server-side validation stays as the second line of defense.

m1 (recovery.php direct hit): pre-fix the surface always emitted 503,
even when `vendor/autoload.php` was actually present (someone
bookmarked the URL, an operator visited it directly out of curiosity).
Self-checks vendor presence at the top and 302s to `/install/` if
present.

m2 (license/licence consistency): standardized on American "License"
across step 1 (page handler + template) — matches LICENSE.md, the
testid prefix `install-license-*`, and the rest of the repo's
spelling.

m3 (testid prefix sweep): step 2 used mixed `install-db-*` (fields)
and `install-database-*` (form / buttons). Standardized field testids
on `install-database-*` to match every other step's pattern.

m4 (PDO error translation): step 2 surfaced raw PDOException strings
("SQLSTATE[HY000] [1045] Access denied for user 'sourcebans'@'192.168.96.5'
(using password: YES)") — gibberish to non-DBAs plus the panel-as-seen-
by-DB internal IP is a minor information disclosure. New
`sbpp_install_translate_pdo_error()` helper in
`web/install/includes/helpers.php` pattern-matches the four codes a
non-technical operator is most likely to hit (1045 access denied / 2002
host unreachable / 1049 unknown database / 1044 denied for user on
database) and emits a friendly translation; falls back to the raw
message for unrecognised codes so debugging stays possible. Regression
test in `web/tests/integration/InstallGuardTest.php` (added with C1).

m5 (license textarea height): step 1's `<textarea ... rows="20">`
was overridden by `.input { height }` from the wizard's CSS, collapsing
to one row. Switched to the panel's `.textarea` class with an inline
`min-height: 24rem` so the licence is readable without scroll-noise.

m6 (AMXBans step helper text): step 6's labels-only fields gave
operators no hint where to find the values. Added a top-of-form
"Look in `addons/amxmodx/configs/sql.cfg`" pointer plus per-field
helper text mirroring `page_database.tpl`'s shape.
Doc-and-code drift is a defect per AGENTS.md's "Keep the docs in sync"
rule. Update both panels + the user-facing README to reflect the #1335
fixes:

AGENTS.md:
 - Install wizard lifecycle: insert the C2 already-installed gate
   between paths-bootstrap and recovery short-circuit; document the
   sister-guards on either side of the wizard (panel-runtime
   `init-recovery.php` + wizard-side `already-installed.php`) and
   the new `SBPP_DEV_KEEP_INSTALL` dev-only opt-in
 - "Where to find what": three new rows for the friendly-error
   surface, the wizard's already-installed gate, and the PDO error
   translator
 - "Edit a step of the install wizard" row: refreshed to mention
   the new helper functions, the licence→license sweep (m2), the
   testid standardization (m3), the page-tail JS validation
   additions on step 5 (M3), and the textarea height fix (m5).
 - Anti-patterns: four new entries — `HTTP_HOST` magic on the
   guard, allowing the wizard to start over an installed panel,
   bare-text `die()` in `init.php`, and surfacing raw `PDOException`
   strings to operator-facing banners.
 - Wizard-vanilla-JS anti-pattern updated to mention the M3 step-5
   admin-form validation extensions and standardize on "license"
   spelling.

ARCHITECTURE.md:
 - Web panel directory layout: surface `init-recovery.php` and
   `already-installed.php` and their #1335 IDs in the tree.
 - Bootstrap step 2: replace the localhost-host-exemption sentence
   with the C1 + M1 + dev-escape-hatch description.
 - Local dev stack: rewrite the `dev-prepend.php` paragraph (used
   to be `HTTP_HOST` rewrite, now `SBPP_DEV_KEEP_INSTALL` define).
 - Legacy patterns table: three new rows for the C1, M1, and C2
   pre-fix shapes.

README.md (m7):
 - New paragraph between install steps 3 and 4 telling operators
   how to set folder permissions if the wizard's environment check
   complains. Matches the M2 in-product remediation hint.
PR #1336's first cut of M2 appended the same chmod-flavored hint
("set permissions to 0775 ... via your hosting File Manager, FTP
client, or chmod") to BOTH the `Missing:` AND `Not writable:`
branches in `web/install/pages/page.3.php`. For a missing directory
the operator can't chmod something that doesn't exist — they need
to re-upload from the release zip (or `mkdir`). The release tarball
ships a placeholder for every required folder (`web/demos/.gitkeep`,
`web/cache/`, the bundled `web/images/games/*.png` and
`web/images/maps/*` files), so a `Missing:` status indicates a
partial / broken upload, not a permission problem.

Lift the detail-string construction into
`sbpp_install_describe_filesystem_check()` in
`web/install/includes/helpers.php` — pure function over
`(path, exists, writable)` returning the `{$row.detail}` text the
template renders. Three branches, three distinct remediations:

  - Missing → "re-upload this folder from the release zip, or
    create it via your hosting File Manager."
  - Not writable → the existing chmod 0775 hint (paired with
    README m7's signpost so the two surfaces stay in sync).
  - OK → bare 'Writable' (no hint needed; the row already shows
    a green check).

The pure-function shape is unit-testable. New regression test
`testFilesystemCheckEmitsDistinctRemediations` in
`web/tests/integration/InstallGuardTest.php` pins the contract
in both directions (the missing branch must NOT mention chmod;
the not-writable branch must NOT suggest re-uploading) so a
future drift can't silently re-merge the two hints.
PR #1336's first cut of M3 added SteamID + email + password-match
checks to the admin-form's page-tail JS — but the form carried
`novalidate`, which switched off the browser's pre-submit checks
for `required` / `minlength="8"` / `pattern` / `type="email"`.
Two follow-up gaps:

  - **Short password** (`<8` chars): native `minlength` was off,
    JS only checked match — a 5-char password matching its 5-char
    confirm passed the JS guard, then bounced server-side
    ("Password must be at least 8 characters.") wiping both
    fields.
  - **Empty fields**: native `required` was off, JS didn't
    explicitly check emptiness — every empty username / email /
    SteamID hit the server's `'All fields are required.'` and
    wiped both passwords on the re-render.

Both gaps are symptomatic of the same root cause: `novalidate`
violates AGENTS.md's install-wizard rule that "the form's native
`required` / `pattern` attributes must be the load-bearing gate,
with JS as the UX polish". The fix drops `novalidate` and lets
the native attrs cover empty / short / pattern / type cases the
way the rule intends — the browser surfaces its popover before
our submit handler runs.

The JS handler shrinks to just the cross-field password-match
check (the one validation native HTML can't express). On submit,
native runs first; if everything natively-valid, our handler
runs and `setCustomValidity('Passwords do not match.')` +
`reportValidity()` + `e.preventDefault()` re-uses the same
native popover surface for the custom message.

`autocomplete="new-password"` + the never-echo-back-into-template
contract on the password fields are unchanged; this is purely
about which validations gate submission and how the operator
sees the failure (native popover anchored to the field vs
server-side bounce that wipes passwords).
Two paired updates riding the M3 review fix in the previous commit:

1. The "Conventions for new wizard work" block's "Forms POST
   natively" rule grows a `novalidate` carve-out spelling out
   the canonical "cross-field validation" shape. The existing rule
   was correct ("the form's native `required` / `pattern`
   attributes must be the load-bearing gate, with JS as the UX
   polish"), but didn't explicitly call out `novalidate` as the
   anti-pattern that defeats it. Future wizard steps with a
   genuine cross-field need (cf. step 5's password-match) follow
   the canonical shape: keep native validation on, hook submit,
   set customValidity in the handler, clear it on input.

2. The wizard-vanilla-JS anti-pattern entry (in "Anti-patterns",
   under the install-wizard heading) updates from "the admin
   form's password-match + SteamID + email shape checks" to
   "the admin form's cross-field password-match check" — the
   SteamID + email checks that PR #1336 first added are now
   handled natively by the form's `pattern` and `type="email"`
   attrs, so listing them as JS-territory was already stale.

3. The "Edit a step of the install wizard" row in "Where to find
   what" gets `sbpp_install_describe_filesystem_check` added to
   the helper list (paired with the M2 review fix's lift of the
   filesystem-check string-building into a pure helper).
@rumblefrog

Copy link
Copy Markdown
Member Author

Adversarial review findings addressed in three follow-up commits on this branch (be6d7b7, a194398, 8d5e635). All four quality gates re-verified green; one new regression test method added.

Fixes

  • M2 (P1) — split missing-folder hint from not-writable hint (be6d7b7). The first cut glued the same chmod hint onto both the Missing: and Not writable: branches; the missing-folder branch now points at re-uploading from the release zip / creating via File Manager (chmod can't fix something that doesn't exist). Lifted the detail-string construction into sbpp_install_describe_filesystem_check() in web/install/includes/helpers.php so the contract is unit-testable. New regression test testFilesystemCheckEmitsDistinctRemediations in web/tests/integration/InstallGuardTest.php pins both directions (missing branch must NOT mention chmod; not-writable branch must NOT suggest re-uploading).

  • M3a + M3b + M3c (P2) — drop novalidate, native attrs gate every input case (a194398). Picked Option 1 of the two M3c choices: removed novalidate from the admin form so the browser's native required / minlength="8" / pattern / type="email" attrs become load-bearing again (per AGENTS.md's existing "native gate / JS polish" rule). Empty / short / pattern-mismatch / type-mismatch cases now surface native popovers before the JS handler runs; the JS shrinks to just the cross-field password-match check (the one validation native HTML can't express). M3a (short password) and M3b (empty fields) are both covered for free by the native-validation flip.

  • AGENTS.md update for the cross-field-validation contract (8d5e635). Added a novalidate carve-out to the install-wizard "Conventions for new wizard work" block spelling out the canonical cross-field shape, refreshed the wizard-vanilla-JS anti-pattern entry to drop the now-stale SteamID + email mentions, and added sbpp_install_describe_filesystem_check to the helper list in the "Edit a step of the install wizard" row. No anti-pattern exception note was needed (which would have been Option 2): the existing rule was already correct, the admin form's novalidate was just violating it.

Quality gates re-run

Gate Status
PHPStan (level 5 + dba) green (0 errors, 239 files)
PHPUnit green (468 tests, 2096 assertions; +1 test method, +7 assertions vs review)
ts-check green
api-contract clean (no diff after regen)

P3 items

The reviewer's five P3 items are out-of-scope for this PR but tracked as a single follow-up at #1338 so they don't get lost — short summaries of each, ready for the maintainer to pick up later.

Smoke tests

  • M2: ./sbpp.sh exec php -r '...' exercising sbpp_install_describe_filesystem_check() directly produces the three expected detail strings (missing → re-upload hint, not-writable → chmod hint, OK → bare "Writable").
  • M3: walked the live wizard step 5 with a temporarily-removed config.php; confirmed the rendered <form> has no novalidate, all five inputs carry their native validation attrs (required, minlength="8" on both passwords, pattern="STEAM_[01]:[01]:[0-9]+" on SteamID, type="email" on email), and the page-tail JS is reduced to the password-match check only.

@rumblefrog rumblefrog added this pull request to the merge queue May 11, 2026
Merged via the queue into main with commit e0dfd0e May 11, 2026
4 checks passed
@rumblefrog rumblefrog deleted the fix/1335-install-wizard-human-flow-audit branch May 11, 2026 01:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant