Skip to content

Login Screen Designer Developer Guide

Ed Mozley edited this page Sep 1, 2026 · 2 revisions

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 rule this feature exists to enforce

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.

⚠️ And the line not to cross

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.

Files

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

πŸ”‘ One table drives save, render and the form

$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).

Validation runs at RENDER, not only at save

/**
 * 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'): array

Saving 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.

The validator, type by type

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.

Anything invalid becomes the default, never an error

/**
 * 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.

Scopes: three screens, one implementation

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.

⭐ bg_style => 'theme' on the landing page

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.

⭐ Two maxima, never two fixed dimensions

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.

πŸ”΄ The size control worked on one screen out of three

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.

⚠️ The sprintf bug worth knowing about

// ⚠️ %% β€” 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.

The live preview

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.

πŸ”‘ Lockout is a real hazard

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.

Adding a control

  1. Add one row to $fields in brandingLoginFields() with its type and bounds.
  2. Add the input to system/branding/index.php.
  3. If it affects appearance, emit it from brandingLoginCss().
  4. 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.

Testing it

  • 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 login rather 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_style is left at theme.

Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally