-
Notifications
You must be signed in to change notification settings - Fork 15
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.
| 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 |
| 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 |
| 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 |
| 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.
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.
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 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.
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.
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 /.
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); }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_descstill 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.
accept attribute and the help text are each an independent restatement of the same rule, and neither is enforced by anything.
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.
- 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.
- Landing page β the plain-language version
- Self-Service Portal Β· Self-Service β Developer Guide
- Security Hardening 2026-08 β where the SVG upload rule came from
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)