Skip to content

Commit bcd9a2b

Browse files
authored
fix(chrome): fall back to favicon shield when template.logo points at a missing file (#1480)
* fix(chrome): fall back to favicon shield when template.logo points at a missing file The navbar (`core/header.php`) and login (`page.login.php`) chrome read `Config::get('template.logo')` and concatenated it into `<img src="themes/<theme>/<value>">` with no existence check. Three reachable input shapes silently render a broken `<img>`: 1. **`logos/sb-large.png`** — the v1.x default. Migration `web/updater/data/809.php` forward-converts existing installs whose row still matches this literal, but installs that missed the hop (RC versions on `config.version >= 810` when #1235 landed, restore-from-v1.x-backup, hand-edited setting) stay on the broken path. The file never shipped in the v2.0 default theme. 2. **Empty / `null` / row missing** — the settings page accepts an empty string into the input; an upgrade path that lost the row surfaces as `Config::get` returning `null` → `<img src="themes/default/">`. 3. **Custom path that no longer exists** — admin typo'd a filename or the file was deleted during a theme refresh. New `Sbpp\View\BrandLogo` helper consolidates the resolution: - `resolve()` returns a theme-relative path; `resolveUrl()` returns the public URL form for the login page (the navbar already has `{$theme_url}` in scope). - Fallback ladder: trim → empty check → leading-slash strip → reject `..` / `\\` (path-traversal) → reject the v1.x default (defense-in-depth against the migration not having run) → `is_file()` against `SB_THEMES.<config.theme>/<value>` → fall back to `BrandLogo::DEFAULT_PATH` (`images/favicon.svg`). - The admin Settings page deliberately renders the RAW configured value so operators can see + fix a broken pointer — only the chrome render paths consume the resolver. `web/tests/integration/BrandLogoTest.php` pins every branch (12 tests, 17 assertions): fresh-install round-trip, v1.x default fallback, empty / whitespace / missing-row fallback, non-existent custom path, valid custom path round-trip, leading-slash strip, path-traversal rejection, backslash rejection, theme-name honoured in URL. `AGENTS.md` carries paired updates: the `Sbpp\View\BrandLogo` row in the Namespacing table and a "Where to find what" row that documents the fallback ladder + the three paired surfaces (`data.sql` seed, `updater/data/809.php` migration, `BrandLogo::DEFAULT_PATH` literal) that need to stay in lockstep. * fix(chrome): address adversarial review findings on template.logo fallback Adversarial review of the initial BrandLogo fix surfaced six follow-ups that each addressed a specific bug class or test-coverage gap: - **Null-byte injection** rejected in `BrandLogo::resolve()`. Null bytes in path components can short-circuit `is_file()` checks downstream (the C runtime treats them as terminators), so a hostile `logos/foo.png\0../../../../etc/passwd` would pass the dot-dot guard but truncate at the null byte for downstream filesystem APIs. The guard rejects on `\0` AND `\\` (Windows path separator on case-insensitive filesystems) in addition to the prior `..` check. - **Case-insensitive v1.x default rejection**. macOS / Windows filesystems are case-insensitive, so `Logos/SB-Large.PNG` (or any case-variant) would slip past a case-sensitive `===` comparison while still resolving to the missing file on a Linux-hosted panel. `strcasecmp()` closes the gap. - **Fail-closed on missing `SB_THEMES`**. `themesRoot()` now raises `LogicException` rather than silently returning the fallback when the constant isn't defined. `init.php` sets it; calling `BrandLogo` without `init.php` is a programmer error and must surface loudly rather than silently substituting the shield (which would mask the missing-bootstrap bug class). - **`V1_DEFAULT_PATH` widened to public const** so paired surfaces (migration 809, fresh-install seed) can reference the same literal. New `BrandLogoTest` cases (`testMigration809PinsV1AndDefaultLiterals`, `testFreshInstallSeedPinsDefaultLiteral`) pin the literal-equivalence contract — a future "let's hard-code `logos/sb-large.png` in the migration" sweep would fail at PR time. - **`LoginView::$brand_logo_url` docblock rewritten** to reference `BrandLogo::resolveUrl()` and its fallback ladder, removing stale references to the pre-fix raw-Config concatenation that no longer reflects how the value is built. - **Silent-fallback indicator on admin Settings** (the marquee finding): pre-fix the chrome silently substituted the fallback shield when `template.logo` was broken, leaving the operator's customisation visible in the settings input field while the chrome rendered something else entirely. The Settings page now surfaces a red warning chip (`[data-testid="setting-warning-template.logo"]`) next to the input when the configured value is non-empty AND the resolver is falling back, computed in `admin.settings.php` as `trim($rawLogo) !== '' && trim($rawLogo) !== BrandLogo::DEFAULT_PATH && BrandLogo::resolve() === BrandLogo::DEFAULT_PATH` and surfaced via `AdminSettingsView::$config_logo_using_fallback`. The chip isn't flagged on the empty / default / valid-custom-path cases (preserving signal-to-noise — see the dataProvider matrix in `BrandLogoChromeWiringTest`). Test coverage additions: - `BrandLogoTest`: 4 new cases (null-byte, case-insensitive v1.x, migration 809 literal pin, fresh-install seed literal pin). - `BrandLogoChromeWiringTest` (new file): pins the wiring of `BrandLogo::resolve()` into `web/pages/core/header.php` (the navbar render path) and `BrandLogo::resolveUrl()` into `web/pages/page.login.php` (the sign-in card render path), AND drives the `$config_logo_using_fallback` indicator matrix through 6 cases (v1.x default, deleted file, valid custom, empty, default, traversal). Mirrors the `LostPasswordChromeTest` stub-Smarty + process-isolation shape. - `brand-mark-resolution.spec.ts` (new E2E spec): launches a real browser against both the navbar (logged-in chrome) and the sign-in card (logged-out chrome) and asserts the rendered `<img>` actually loads via `naturalWidth > 0` — the canonical browser-side test for "this image is not broken". Covers both the default-value scenario AND the broken-template.logo scenario (seeded via `setSettingE2e`, reverted in `afterAll`). 4 chromium + 4 mobile-chromium tests; all pass. The new `data-testid="brand-mark"` hook on both `<img>` elements follows AGENTS.md's "selectors must use testability hooks" rule (anchored on the testid, never the `.sidebar__brand-mark` CSS class). AGENTS.md's "paired surfaces" enumeration extended from the original three sites to seven (every site in the codebase that hardcodes a SourceBans++ brand-mark image path is now enumerated): the SQL seed, the migration, `core/header.tpl` (favicon links), `install/_chrome.tpl` (install wizard favicon + brand img), `updater.tpl` (updater wizard brand img), `page_admin_settings_settings.tpl` (help-text reference), and the `LoginView` docblock. Quality gates: PHPStan clean; ts-check clean; api-contract clean (no drift); 27 BrandLogo PHPUnit tests pass; 8 brand-mark E2E tests pass; login + sidebar-sticky sibling specs continue to pass. The 6 pre-existing `PluginVersionResolveTest` failures are environmental (missing `game/` mount in the docker stack) and reproduce on `main`; not introduced by this PR.
1 parent 476f4b1 commit bcd9a2b

13 files changed

Lines changed: 1342 additions & 23 deletions

File tree

‎AGENTS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,7 @@ matching its directory. PSR-4 autoloads from `web/includes/` →
425425
| `Sbpp\Api\Api` | JSON API dispatcher |
426426
| `Sbpp\Api\ApiError` | structured API error |
427427
| `Sbpp\View\AdminTabs` | admin sub-route sidebar mounter |
428+
| `Sbpp\View\BrandLogo` | `template.logo` resolver with `is_file()` fallback to `images/favicon.svg` — single source for the navbar + login chrome brand-mark renders; rejects path-traversal + null-bytes + the v1.x default (case-insensitive); fail-closed on missing `SB_THEMES` |
428429
| `Sbpp\View\Toast` | server-side toast emitter (`emit(kind, title, body, ?redirect)` — replaces v1.x `ShowBox(...)`) |
429430
| `Sbpp\View\*` | view DTOs (page-level + partials) |
430431
| `Sbpp\Servers\SourceQueryCache` | per-`(ip, port)` on-disk cache around the xPaw A2S probe (#1311) |
@@ -4894,6 +4895,7 @@ contributions without contacting every contributor individually.
48944895
| Generate a presigned S3 PUT URL for the panel's "Full data export" S3 mode | Operator-side workflow — the panel never generates the URL itself, only consumes it. AWS: `aws s3 presign s3://bucket/key --http-method PUT --expires-in 3600` (the `--http-method PUT` flag is load-bearing — defaults to GET). Cloudflare R2: same `aws` CLI configured against an R2 token + `--endpoint-url https://<account-id>.r2.cloudflarestorage.com`; or `wrangler r2 object put` with `--presign`; or the R2 dashboard's "Generate URL" tool. MinIO: `mc share upload --expire 1h myminio/bucket/key` (note `share upload`, NOT `share download` — distinct presign flavours). The presigned URL is a single-use write credential; use a short expiry (≤1 hour) and never paste the URL into chat / public logs / issue trackers (anyone who sees it can PUT arbitrary content to your bucket until it expires). Operator-facing docs at `docs/src/content/docs/configuring/data-export.mdx` carry the worked examples per provider. |
48954896
| Add a cross-repo JSON contract (vendored schema lock + reader + extractor parity test) | `web/includes/Telemetry/Schema1.php` is the reference shape (`payloadFieldNames(): list<string>` over a Draft-7 JSON Schema lock file). Pair with one PHPUnit extractor parity test (collect() vs. lock file in both directions). The schema lock file is the single source of truth — don't mirror the field list into a markdown doc paired with a separate parity test, that pattern was tried for telemetry and removed because the duplication paid for the drift risk it created. Sync via a manual `make sync-<subsystem>-schema` target — no scheduled auto-PR. See "Cross-repo JSON contracts" under Conventions. |
48964897
| Display a user's own permission flags grouped by category | `Sbpp\View\PermissionCatalog::groupedDisplayFromMask($mask)` (`web/includes/View/PermissionCatalog.php`). Adding a new flag to `web/configs/permissions/web.json` requires a paired entry in `WEB_CATEGORIES`; `PermissionCatalogTest` enforces it. |
4898+
| Resolve the operator-configurable `template.logo` setting to a brand-mark path for the chrome (`core/navbar.tpl` sidebar + `page_login.tpl` sign-in card) | `Sbpp\View\BrandLogo::resolve()` (theme-relative path) and `Sbpp\View\BrandLogo::resolveUrl()` (public URL) in `web/includes/View/BrandLogo.php`. Single source for the fallback ladder: trim → empty? → defaults to `BrandLogo::DEFAULT_PATH` (`images/favicon.svg`, the SourceBans++ shield from the favicon set); leading-`/` strip; reject `..` / `\` / `\0` (path-traversal + null-byte injection); reject the v1.x default `logos/sb-large.png` case-insensitively (defense-in-depth against installs that missed migration 809 — case-insensitive because Windows + macOS filesystems would otherwise let `Logos/SB-Large.PNG` slip through and still resolve to the missing file on Linux-hosted panels); `is_file()` against `SB_THEMES.<config.theme>/<value>` (theme tree on disk) — fall back on miss; otherwise pass through. **Fail-closed on missing `SB_THEMES`**: `themesRoot()` raises `LogicException` if the constant isn't defined (init.php sets it; anything calling `BrandLogo` without `init.php` is a programmer error and must surface loudly rather than silently returning the fallback). The admin Settings page (`page_admin_settings_settings.tpl`) deliberately renders the RAW configured value so operators can see + fix a broken pointer; only the render paths (`core/header.php` + `page.login.php`) consume the resolver. **Silent-fallback indicator** (#1480 review finding 5): the admin Settings page also surfaces a red warning chip (`[data-testid="setting-warning-template.logo"]`) next to the input when the configured value is non-empty AND the resolver is falling back — without this an operator who customised the path has no signal that their customisation is inactive. The indicator is computed in `web/pages/admin.settings.php` as `trim($rawLogo) !== '' && trim($rawLogo) !== BrandLogo::DEFAULT_PATH && BrandLogo::resolve() === BrandLogo::DEFAULT_PATH` and passed to the template via `AdminSettingsView::$config_logo_using_fallback`. Pre-fix the chrome read `Config::get('template.logo')` directly and shipped a broken `<img>` on three reachable input shapes (the v1.x default that never shipped in the v2.0 default theme, empty / null / missing row, custom paths to deleted files). **Paired surfaces (every site that hardcodes a SourceBans++ brand-mark image path)**: (1) `web/install/includes/sql/data.sql` (fresh-install seed for `template.logo`); (2) `web/updater/data/809.php` (upgrade-path forward-conversion from the v1.x default; pinned to `BrandLogo::V1_DEFAULT_PATH` / `BrandLogo::DEFAULT_PATH` literals at the call site); (3) `web/themes/default/core/header.tpl` (favicon `<link rel="icon">` tags — `{$theme_url}/images/favicon.svg` for the panel chrome); (4) `web/themes/default/install/_chrome.tpl` (favicon `<link rel="icon">` + the install wizard's brand `<img>` in the header — `../themes/default/images/favicon.svg`, hardcoded because the wizard runs against an unconfigured panel with no `Config::get`); (5) `web/themes/default/updater.tpl` (the updater wizard's brand `<img>`, hardcoded for the same reason as the install wizard); (6) `web/themes/default/page_admin_settings_settings.tpl` (the inline `<code>` reference in the help-text under the input — informational only); (7) `web/includes/View/LoginView.php` (docblock reference describing the resolver's fallback). If you move the shield to a new path, update (1) + (2) + (3) + (4) + (5) + (6) + (7) AND `BrandLogo::DEFAULT_PATH` in the same PR — and bump (2)'s migration number if the change ships in a release (per "Updater migrations" rules). The install wizard + updater hardcodes (4) + (5) are intentional and stay hardcoded — those surfaces have no `Config::get` available. Regression guards: `web/tests/integration/BrandLogoTest.php` covers every fallback branch + the v1.x-default rejection + leading-slash strip + path-traversal rejection + null-byte rejection + case-insensitive v1.x default rejection + migration 809 literal-pinning + fresh-install seed literal-pinning + valid round-trip. `web/tests/integration/BrandLogoChromeWiringTest.php` pins the wiring of `BrandLogo::resolve()` into `web/pages/core/header.php` (`$theme->assign('logo', …)` ships the resolved value, not the raw `Config::get`) AND `BrandLogo::resolveUrl()` into `web/pages/page.login.php` (the `LoginView::$brand_logo_url` property carries the resolved URL, not the pre-fix `themes/<theme>/<raw>` concatenation), AND covers the admin Settings page's `$config_logo_using_fallback` indicator matrix (6 cases: v1.x default flips warning, deleted file flips warning, valid custom path does not, empty does not, default itself does not, traversal indicator flips warning). |
48974899
| Live-preview Markdown in a settings textarea | `system.preview_intro_text` JSON action + `web/themes/default/page_admin_settings_settings.tpl` (`.dash-intro-editor` / `.dash-intro-preview`) |
48984900
| Regex-read `theme.conf.php` metadata for the admin Settings → Themes picker (without executing the manifest) | `Sbpp\Theme\ThemeConf` (`web/includes/Theme/ThemeConf.php`) — `parseDefine()` + `sanitizeLink()` / `sanitizeScreenshotFilename()`; wired from `web/pages/admin.settings.php` discovery loop (#1466). JSON theme preview (`api_system_sel_theme`) still `include`s the manifest. Regression: `web/tests/integration/ThemeConfParseTest.php`. |
48994901
| Build an empty-state surface (first-run vs filtered, primary/secondary CTAs) | `.empty-state` rules in `web/themes/default/css/theme.css` + reference shapes in `page_servers.tpl`, `page_dashboard.tpl`, `page_bans.tpl`, `page_comms.tpl`, `page_admin_audit.tpl`, `page_admin_bans_protests.tpl`, `page_admin_bans_submissions.tpl` |

‎web/includes/View/AdminSettingsView.php‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,28 @@ final class AdminSettingsView extends View
3434
public function __construct(
3535
public readonly string $config_title,
3636
public readonly string $config_logo,
37+
/**
38+
* True when `template.logo` is non-empty but the chrome is
39+
* currently rendering the fallback shield (i.e.
40+
* {@see \Sbpp\View\BrandLogo::resolve()} returned
41+
* {@see \Sbpp\View\BrandLogo::DEFAULT_PATH} for a non-default
42+
* configured value — the file doesn't exist on disk, the
43+
* value points at the v1.x default, or the value contains a
44+
* traversal indicator). The template surfaces a warning chip
45+
* next to the input so an operator who customised the path
46+
* gets a visible signal that their customisation is
47+
* inactive — without this the silent fallback would leave the
48+
* operator believing their saved value is what the chrome
49+
* renders (#1480 review finding 5).
50+
*
51+
* Stays false when the configured value EQUALS the resolved
52+
* value (the happy path) AND when the configured value is
53+
* empty / unset (no customisation in flight to flag as
54+
* broken). The "configured is empty + fallback is being
55+
* used" case isn't worth flagging because the empty input
56+
* itself reads as "no customisation".
57+
*/
58+
public readonly bool $config_logo_using_fallback,
3759
public readonly int $config_min_password,
3860
public readonly string $config_dateformat,
3961
public readonly string $config_dash_title,

0 commit comments

Comments
 (0)