Repository navigation
Login Screen Designer Developer Guide
How an administrator restyles three unauthenticated screens without ever being able to inject anything into them.
User-facing page: Login Screen Designer.
THE ADMINISTRATOR SUPPLIES VALUES, NEVER SYNTAX.
The login page is the one page an attacker can view anonymously, and the one page where every user types a password. Stored XSS there is the worst kind there is: it runs unauthenticated, for everybody, on the credential form.
So nothing an administrator types is ever treated as HTML or as CSS. Every setting declares a type and its permitted values; the page turns those values into CSS; the administrator never writes any.
No "custom CSS" field and no "custom HTML" field, however often it is asked for. Every guarantee on this page is void the moment one exists β and it would be void for anyone who ever compromises an administrator account, not just for the administrator. If something cannot be expressed, the answer is another structured control, not an escape hatch.
| File | What it holds |
|---|---|
includes/branding.php |
The validation table, the scopes, the CSS builder, presets |
includes/branding_preview.php |
The shared live-preview script |
system/branding/index.php |
The designer |
auth/login.php Β· self-service/login.php Β· index.php
|
The three rendered screens |
$fields = [
// ---- layout ----
'form_position' => ['type' => 'enum', 'default' => 'centre', 'values' => ['left', 'centre', 'right']],
'card_style' => ['type' => 'enum', 'default' => 'solid', 'values' => ['solid', 'glass', 'flat']],
β¦
'bg_from' => ['type' => 'colour', 'default' => '#667eea'],
'bg_dim' => ['type' => 'int', 'default' => 30, 'min' => 0, 'max' => 80],
'heading' => ['type' => 'text', 'default' => '', 'max' => 80],
'bg_image_path' => ['type' => 'upload', 'default' => ''],
];The shape is the point. A control cannot be added to the screen and forgotten in validation, because the same array generates the form, checks the save and filters the render. An unvalidated field on the login page is precisely the hole everything else here is about β so the design makes it unreachable rather than relying on remembering.
Five types: enum (one of values), colour (#rrggbb), int (clamped),
text (plain, trimmed, cut to max β never HTML), upload (a path inside
the branding directory that still exists).
/**
* The whole design, validated. Safe to call on the login page: it swallows any
* database failure and returns the defaults.
*/
function brandingLoginDesign(?PDO $conn = null, string $scope = 'login'): arraySaving is guarded, but a value that reached the row by some other route β a
restored backup, a direct UPDATE, an injection elsewhere in the application β
still cannot reach the page. The stored value is treated as untrusted input every
single time it is read.
This mirrors includes/landing.php, which took the same position for the same
reason: the stored value is a KEY, never a path; anything unrecognised falls back
to the default. That is the doctrine for anything the front door reads.
case 'colour':
// Strict. "Starts with #" is not validation β `#fff; background:url(β¦)`
// starts with # too.
return preg_match('/^#[0-9a-fA-F]{6}$/', $raw) ? strtolower($raw) : $default;That comment is the whole lesson. A permissive colour check is a CSS injection: the
value is interpolated into a stylesheet, so anything after a ; is a new
declaration.
case 'text':
// Control characters stripped so a stored newline cannot break out of
// an attribute even if a future caller forgets to escape.
$clean = preg_replace('/[\x00-\x1F\x7F]/u', '', (string)$raw);
return mb_substr($clean, 0, $spec['max']);Defence in depth: the page escapes on output anyway. This is insurance against a future caller who does not.
case 'upload':
return (brandingPathIsSafe((string)$raw) && file_exists(__DIR__ . '/../' . $raw)) ? $raw : $default;Path and existence. A path that passes the safety check but names a deleted file would render a broken image on the login screen.
/**
* One value, validated against its declared type. Anything that does not fit
* becomes the default β never an error, because this runs on the login page.
*/A validation exception on the login page is an outage. Falling back is always the right answer here.
function brandingScopes(): array
{
return [
'login' => ['prefix' => 'branding_login_', 'page' => 'auth/login.php'],
'portal' => ['prefix' => 'branding_portal_', 'page' => 'self-service/login.php'],
// No form on this one, so no form position and no panel style.
'home' => ['prefix' => 'branding_home_', 'page' => 'index.php',
'omit' => ['form_position', 'card_style'],
// β¦and it keeps the theme's own background unless asked otherwise.
'defaults' => ['bg_style' => 'theme']],
];
}
function brandingScopeValid(string $scope): string
{
return isset(brandingScopes()[$scope]) ? $scope : 'login';
}A scope is a key, never a prefix taken from a request β brandingScopeValid()
funnels anything unknown to login, so a crafted scope parameter cannot be used
to read or write arbitrary system_settings rows.
theme means emit no background at all. The landing page already has a
theme-aware background with a dark-mode variant, and defaulting it to a gradient
would have broken dark mode for every existing install as a side effect of adding
a setting. Opt in, do not opt out.
The same reasoning fixes the other defaults: #667eea β #764ba2 at diagonal,
logo at 250 β those are exactly what auth/login.php used to hardcode, so an install
that never opens the designer looks precisely as it always did. logo_height
defaults to 0, meaning no limit, for the same reason: the height was never
constrained before, so nothing moves.
logo_size and logo_height are both maxima, and neither width nor height
is set:
width: auto;
height: auto;
max-width: min(var(--login-logo-size, 250px), 100%);
max-height: var(--login-logo-height, none);This is not a stylistic choice. A width with a max-height beside it distorts
the image β the used width stays as declared while the height is cut, so the
logo is squashed. Leaving both dimensions auto and constraining the box lets the
browser keep the aspect ratio, and whichever limit binds first wins.
The min(β¦, 100%) is a separate guarantee: the sign-in card is 400px but only
about 296px of it is usable at 360px wide, and the logo is a file the customer
supplies, so a 600px upload must not push the card wider than the phone.
--login-logo-height is emitted as the literal none when the stored value is
0, because that is CSS's own word for no limit and the page reads the token
straight into max-height. The slider stores 0; the screen shows no limit
rather than 0px, which would describe a logo that is not drawn at all.
One control cannot serve both logo shapes, which is why there are two. The bundled logo is 1124Γ301, so its width is the binding dimension and its height never matters. A square logo asked for 250px wide is 250px tall and swallows the card β reported by a customer whose logo is square. Width is the wrong handle for that logo and no range on it would have helped.
Worth knowing before you add the next token, because both failures are ordinary and neither produced an error.
| Screen | What was wrong |
|---|---|
auth/login.php |
nothing β this is the one that worked, and the one that got tested |
self-service/login.php |
still had a hardcoded width: 250px. The token was never read |
index.php |
read the token, then a second .company-logo { width: 300px } further down the same stylesheet set it again. Same selector, same specificity, later in the file, so it won every time |
A token that is emitted correctly and a token that is used are different claims, and only the second one is worth anything. The designer offered that control on all three tabs the whole time. When you add a setting to a shared field table, verify it on every scope the table serves β measuring the rendered element, not reading the CSS, because the landing page's stylesheet said the right thing one rule before it said the wrong one.
// β οΈ %% β a literal percent has to be escaped in a sprintf template.
// Written as `30% 30%` the second one was eaten ('% 3' reads as a format
// spec) and the server produced `circle at 30%,` while the browser preview,
// which builds the same string without sprintf, produced the full one.
// Still valid CSS either way, which is exactly why it would have gone
// unnoticed β and a preview that disagrees with the page is the one thing
// this design is supposed to rule out.
'radial' => 'radial-gradient(circle at 30%% 30%%, %1$s, %2$s)',Two lessons. Valid-but-wrong output is the hardest kind to catch β no error, no warning, just a slightly different gradient. And a preview built by different code from the page will eventually disagree with it; that is the failure mode the whole preview design exists to prevent.
includes/branding_preview.php is shared by all three rendered pages, so the
preview is the real page rather than a mock-up of it.
Transport, and why each part is safe:
π Why this is not a hole:
Β· BroadcastChannel is SAME-ORIGIN. No other site can post to it.
Β· It changes colours and layout in ONE browser and writes nothing.
Persisting still goes through the guarded, validated save endpoint.
Β· The values are re-checked here anyway β a colour must match
#rrggbb and a layout must be one of the known words β so this path
keeps the same discipline as the server even where it need not. */
Two mechanisms because the preview runs both in a tab of its own (which
BroadcastChannel handles) and in an iframe beside the controls (which needs
postMessage). The origin check on the second is not optional β an unchecked
message listener on the login page would let any page that can frame it push
values in.
The preview applies design values only, exactly as the renderer does. It never receives markup.
White text on a white background, or a logo scaled over the form, and nobody can sign in to undo it β including the administrator who did it.
/* ?nobranding=1 renders the stock screen. π A safety valve, not a β¦ */
$brandNone = isset($_GET['nobranding']);Present on all three pages. Document it next to the settings, because a safety valve nobody knows about is not one.
It is not a bypass. It changes appearance only β no authentication step is skipped and no access is granted. Treating it as a secret would be security theatre; treating it as a documented escape hatch is the honest design.
brandingContrastRatio() also warns in the designer before a combination gets
that far.
- Add one row to
$fieldsinbrandingLoginFields()with its type and bounds. - Add the input to
system/branding/index.php. - If it affects appearance, emit it from
brandingLoginCss(). - If a scope should not have it, add it to that scope's
omit.
Steps 1 and 3 are the only ones that touch anything security-relevant, and step 1 is what makes it validated everywhere at once.
Do not add a field whose value is interpolated as syntax. If you find yourself
wanting 'type' => 'css', re-read the top of this page.
-
Save a hostile value directly β
UPDATE system_settings SET setting_value = '#fff; background:url(//evil)' WHERE setting_key = 'branding_login_bg_from';β then load the login page. It must render the default colour. This is the render-time validation, and it is the test that matters most. -
Craft a bad scope and confirm it falls back to
loginrather than reaching another prefix. - Compare preview against page. Set a radial gradient, then load the real page and diff the emitted CSS β that is the bug documented above.
-
Lock yourself out on purpose (white on white) and recover with
?nobranding=1. -
Check all three scopes independently, and confirm the landing page still
honours dark mode when
bg_styleis left attheme.
- Login Screen Designer β the user-facing page
- Landing Page β Developer Guide β the same "stored value is a key" doctrine
- Security
- Content Security Policy Hardening
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: Projects
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
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
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- 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: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
- π Projects
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- 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
- β³ π’ One board across every company
- β³ 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)
- What this is
-
π Bugs resolved
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96