Skip to content

fix(chrome): fall back to favicon shield when template.logo points at a missing file - #1480

Merged
rumblefrog merged 2 commits into
mainfrom
fix/brand-logo-default-fallback
May 27, 2026
Merged

rumblefrog merged 2 commits into
mainfrom
fix/brand-logo-default-fallback

Conversation

@rumblefrog

Copy link
Copy Markdown
Member

Summary

The navbar (core/header.php) and the login page (page.login.php) read Config::get('template.logo') and concatenated the raw value 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 the v1.x literal, but installs that missed the hop (RC versions on config.version >= 810 when Replace favicon with the 2.0.0 brand mark + ship the modern icon/theme-color set #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 from disk during a theme refresh.

Fix

New Sbpp\View\BrandLogo helper consolidates the resolution. Both chrome render paths consume it; the admin Settings page deliberately keeps rendering the RAW configured value so operators can see and fix a broken pointer (the input is the operator-facing "what's saved"; the helper is the chrome-facing "what gets rendered").

Resolution ladder in BrandLogo::resolve():

  1. trim() + empty check → BrandLogo::DEFAULT_PATH (images/favicon.svg)
  2. Strip a single leading / (mirrors what page.login.php did inline pre-fix)
  3. Reject values containing .. or \\ (path-traversal) → default
  4. Reject the v1.x default logos/sb-large.png (defense-in-depth against migration 809 not having run) → default
  5. is_file() against SB_THEMES.<config.theme>/<value> → default on miss
  6. Otherwise pass through

BrandLogo::resolveUrl() joins the active theme name into the public URL prefix (themes/<theme>/) for page.login.php's LoginView::brand_logo_url.

Paired surfaces

If BrandLogo::DEFAULT_PATH ever changes, three surfaces need the same edit in the same PR:

  • web/install/includes/sql/data.sql — fresh-install seed for template.logo
  • web/updater/data/809.php — upgrade-path forward-conversion target
  • BrandLogo::DEFAULT_PATH itself

AGENTS.md carries paired updates: the Sbpp\View\BrandLogo row in the Namespacing table and a new "Where to find what" row documenting the fallback ladder + the three paired surfaces.

Test plan

  • ./sbpp.sh phpstan — clean (level 5)
  • ./sbpp.sh test tests/integration/BrandLogoTest.php — 12 tests, 17 assertions pass
  • Full ./sbpp.sh test — only pre-existing failures in PluginVersionResolveTest (environment-specific, fails identically on main — the test reads dirname(__DIR__, 3)/game/... which lands outside the container's ./web bind mount; unrelated to this PR)
  • BrandLogoTest covers every branch: DEFAULT_PATH constant pins, fresh-install round-trip, v1.x default fallback, empty / whitespace / missing-row fallback, non-existent custom path fallback, valid custom path round-trip, leading-slash strip, path-traversal rejection, backslash rejection, theme-name honoured in URL prefix

… 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.
…lback

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.
@rumblefrog
rumblefrog added this pull request to the merge queue May 27, 2026
Merged via the queue into main with commit bcd9a2b May 27, 2026
7 checks passed
@rumblefrog
rumblefrog deleted the fix/brand-logo-default-fallback branch May 27, 2026 22:30
@github-actions github-actions Bot locked and limited conversation to collaborators May 27, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant