Skip to content

Landing Page Developer Guide

Ed Mozley edited this page Aug 12, 2026 · 1 revision

Landing page β€” Developer Guide

Why the per-analyst override had to be a cookie, why the stored value is a key and never a path, and the two adjacent bugs the work uncovered. The plain-language version is Landing page.

Asked for in discussion #63. Shipped in f1673644.


1. πŸ“ The files involved

🟒 The decision

File Role
includes/landing.php The whole feature: key↔URL map, validation, cookie read/write, login refresh
index.php Redirects an unauthenticated visitor to the resolved target

🟠 Writing the choice

File Role
system/branding/index.php The admin <select>
api/system/save_branding.php Validates and stores the install-wide default
api/system/get_branding.php Returns the current choice
system/preferences/index.php The per-analyst <select>
api/system/set_user_preference.php Saves the preference, and mirrors this one into the cookie

πŸ”΅ Keeping the cookie fresh

File Role
auth/login.php Password login β€” two call sites (trusted-device and no-MFA)
api/myaccount/verify_login_otp.php After MFA
api/auth/oidc_callback.php After SSO

πŸ”΄ Storage

Key Where Meaning
default_landing_page system_settings Install-wide default. Absent means analyst
default_landing_page user_preferences Per-analyst override. Empty string means "follow the install default"
freeitsm_landing Cookie Pre-auth cache of the preference

No schema change: both tables already existed.


2. 🧠 Why the override had to be a cookie

The interesting constraint is when the decision happens. index.php redirects before rendering anything and before anyone has authenticated. That rules out the two obvious mechanisms:

user_preferences alone cannot do it. The column is analyst_id INT NOT NULL. At the moment the decision is made there is no analyst β€” that is the entire point of the page being requested.

localStorage cannot do it at all. PHP cannot see it. Using it would mean serving a blank page, reading the value in JavaScript, and bouncing β€” a flash of nothing and an extra round trip, on the first page anyone sees.

A cookie is the only store that arrives with the request.

So the cookie is a cache, not the setting

user_preferences remains the source of truth, because it belongs to the person. The cookie exists solely to make that value readable pre-auth, and is re-issued from the preference at every point a session becomes authenticated:

landingRefreshCookieFromPreference($conn, (int)$analyst['id']);

Four call sites, because there are four ways to become authenticated: password with a trusted device, password without MFA, after an OTP, and after SSO. Miss one and that route silently stops honouring the preference.

This is what makes the preference behave like a preference rather than a browser setting:

  • set it on your desktop, and your laptop picks it up on first sign-in
  • clear your cookies and it returns at next sign-in
  • it degrades safely β€” if the refresh does not run, the worst case is landing on the install default once

The refresh swallows its own exceptions on purpose. A preference lookup must never be the reason a login fails.

The three-state preference

The preference has a state the setting does not: empty string means "follow the install default". That is why landingIsValid('') is false and the save path treats it as a clear:

if (landingIsValid($value)) {
    landingSetCookie($value);
} elseif ($value !== false) {
    // Explicitly saved as "use the install default" β€” clear any stale cookie.
    landingSetCookie(null);
}

Without that elseif, an analyst switching back to "use the site default" would keep an old cookie and their choice would appear not to take.


3. πŸ”’ The stored value is a key, never a path

This is the part to preserve if the feature is ever touched again.

landingTargets() is the only place in the codebase where a landing URL exists.

function landingTargets(): array
{
    return [
        'analyst' => 'login.php',
        'portal'  => 'self-service/login.php',
    ];
}

Everything from outside β€” the database row, the cookie, the POST field β€” is a key validated against that map. The setting drives a redirect on /, the single most-visited URL in the product. Storing a path would put an open redirect on the front door of every FreeITSM installation, and a cookie-supplied path would let anyone who can set a cookie choose where the front door points.

Verified as an attack rather than assumed:

Cookie value Result
portal Location: self-service/login.php βœ… positive control
https://evil.example.com ignored β€” falls back to the install default
../../etc/passwd ignored β€” falls back to the install default

And on the write path, a path is rejected outright rather than silently coerced, so a broken integration surfaces instead of quietly changing where every user lands:

{"success":false,"error":"'landing_page' must be one of: analyst, portal"}

Rejecting rather than falling back matters here: a silent fallback would make a misconfiguration look like it had worked.

Cookie flags

HttpOnly, because nothing in the browser needs to read it β€” the decision is made in PHP before a page exists. SameSite=Lax, Secure when the request is HTTPS, one year, path /.


4. πŸ› Two adjacent bugs, both fallout from the security round

A rejected Branding save blanked the header and footer

save_branding.php has no transaction, and it wrote the six text slots before it finished validating the request. So a save rejected for any reason β€” including a bad landing_page β€” still committed six blank slots on its way to throwing.

Found the hard way: a negative test rejected as intended and wiped the branding text at the same time.

Everything is now validated before anything is written:

$landing = null;
if (array_key_exists('landing_page', $_POST)) {
    $landing = (string)$_POST['landing_page'];
    if (!landingIsValid($landing)) {
        throw new Exception(...);   // before any upsert
    }
}
foreach ($values as $k => $v) { $upsert($conn, 'branding_' . $k, $v); }

⚠️ The general point: an endpoint that writes several rows without a transaction must finish validating before it starts writing. Anything else makes a rejected request partially destructive.

The logo picker offered a file type the server refuses

SVG was dropped from uploads in the security round β€” an SVG is XML that can carry <script>, and the logo is served from our own origin. But two things were never updated to match:

  • the file input still had accept=".png,.jpg,.jpeg,.svg,…,image/svg+xml"
  • system.branding.logo_desc still said "PNG, JPG, or SVG… SVG is recommended for crisp print and export"

So the UI actively recommended a format the server would reject. Both corrected.

⚠️ When a server-side rule is tightened, grep for the client-side copies of it β€” the accept attribute and the help text are each an independent restatement of the same rule, and neither is enforced by anything.


5. Extending it

To add a third destination (a knowledge base, say), add one entry to landingTargets() and one <option> to each of the two selects. Validation, the cookie, and the redirect all follow automatically, because they only ever deal in keys.

Do not add a "custom URL" option. That is the open redirect this design exists to prevent.


6. πŸ”΄ Outstanding

  • 23 locales. The new strings are English-only and fall back silently.
  • End users cannot override the choice β€” the portal has no preferences area. Deliberate for now: the setting exists to point end users at the portal, and analysts are who needs the escape hatch.

Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally