Security hardening
The theme ships a small hardening layer: security response headers, HSTS on WP Engine, breached-password rejection via the Have I Been Pwned range API, login-enumeration protection on password reset, and removal of version-disclosing markup. Admin login itself is delegated to Google Workspace SSO by plugin.
Why it exists
Section titled “Why it exists”This is a law firm site holding enquiry data, so the admin surface matters more than the front end. The password check exists because editors previously chose weak passwords; the header set is described in code as “those recommended by securityheaders.com”.
Note the scope: this is theme-level hardening only. Platform hardening (WAF, rate limiting, patching) belongs to WP Engine, and access control belongs to the SSO plugin.
Entry points
Section titled “Entry points”| Path | What it does |
|---|---|
wp-content/themes/bpcollins/inc/security-functions.php:9 |
send_headers hook — the response header set and HSTS |
wp-content/themes/bpcollins/inc/security-functions.php:30-57 |
Hooks that reject breached passwords on profile update and password reset |
wp-content/themes/bpcollins/inc/security-functions.php:73 |
check_pwnedpassword() — the k-anonymity range lookup |
wp-content/themes/bpcollins/inc/security-functions.php:104 |
redirect_password_reset() — prevents username enumeration |
wp-content/themes/bpcollins/inc/security-functions.php:112-122 |
Removes the /admin/ redirect, the generator tag and wlwmanifest |
wp-content/themes/bpcollins/inc/optimisations.php:24 |
gal_set_login_cookie filter for the Google Apps Login plugin |
wp-content/themes/bpcollins/inc/required-plugins/required-plugins.php |
Force-activates the SSO, consent and redirect plugins |
How it works
Section titled “How it works”Response headers are sent on send_headers, each through its own filter so a
child theme or plugin can override or disable it by returning falsy:
| Header | Value | Filter |
|---|---|---|
X-Frame-Options |
DENY |
securityheader_frameoptions |
X-XSS-Protection |
1; mode=block |
securityheader_xssprotection |
X-Content-Type-Options |
nosniff |
securityheader_contenttypeoptions |
Referrer-Policy |
no-referrer-when-downgrade |
securityheader_referrerpolicy |
Strict-Transport-Security |
max-age=2592000 (30 days) |
securityheader_hsts |
HSTS is only sent when $_SERVER['IS_WPE'] is set and $_SERVER['HTTPS'] is
on/1, so local and non-WP-Engine environments never pin HTTPS.
Breached-password rejection. On user_profile_update_errors and
validate_password_reset, the submitted password is passed to
check_pwnedpassword(), which SHA-1s it, sends only the first five characters
of the hash to https://api.pwnedpasswords.com/range/<prefix>, and compares the
remaining suffix against the returned list — the plaintext password never leaves the
server. If the breach count exceeds the threshold (default 100) the update is
rejected with a message telling the user how many times the password has appeared in
breaches.
Three filters control it: pwnedpasswords_active (default true — set false to
disable entirely), pwnedpasswords_threshold (default 100) and
pwnedpasswords_error (the message).
Login enumeration. redirect_password_reset() hooks lostpassword_post and,
when no errors were produced, redirects to
/wp-login.php?checkemail=confirm — so the reset form gives the same response
whether or not the account exists.
Information disclosure. The WordPress generator meta tag and the
wlwmanifest link are removed from wp_head. wp_redirect_admin_locations is
removed so /admin/ and /login/ do not redirect to /wp-admin/.
Admin authentication is handled by the force-activated Google Apps Login
Premium plugin (Google Workspace SSO). my_gal_set_login_cookie()
(inc/optimisations.php:24) restricts that plugin’s login cookie to requests on
wp-login.php.
Related plugin posture: Redirection (URL moves), CookieHub (GDPR consent) and
Fly Dynamic Image Resizer are all force_activation => true, so deactivating them
in wp-admin does not stick.
Uploads: SVG upload is enabled theme-side (inc/template-functions.php:34-49),
which means uploaded SVGs are served as-is. Treat SVG upload capability as
equivalent to trusting the uploader.
flowchart TD A[request] --> B[send_headers: XFO, XCTO, XSS, Referrer-Policy] B --> C{IS_WPE and HTTPS?} C -->|yes| D[HSTS max-age 30 days] E[profile update or password reset] --> F[SHA-1, send 5-char prefix to HIBP range API] F --> G{count > threshold?} G -->|yes| H[reject with WP_Error] I[lost password submit] --> J[always redirect to checkemail=confirm] K[wp-admin login] --> L[Google Apps Login Premium SSO]Configuration
Section titled “Configuration”- Filters listed above:
securityheader_*,pwnedpasswords_active,pwnedpasswords_threshold,pwnedpasswords_error. - Environment:
IS_WPEandHTTPSserver variables gate HSTS. - Google Workspace client configuration lives in the Google Apps Login plugin settings, not in this repository. Nothing credential-shaped is stored in the theme.
- CookieHub’s site id is configured in the plugin.
Invariants and gotchas
Section titled “Invariants and gotchas”- HSTS is 30 days, not the 6–12 months preload requires. Deliberate (the code comment says “Default HSTS to 30 days”) but it means the site is not preload eligible as configured.
X-XSS-Protectionis obsolete — ignored by current browsers. Harmless, but do not treat it as protection.- There is no Content-Security-Policy. The code’s TODO mentions Feature-Policy
but neither is set. Adding CSP would be substantial work:
header.phpembeds several inline scripts and inlines above-the-fold CSS, andsingle-service.phploads Mapbox from a CDN, so a strict policy needs nonces threading through all of it. - The HIBP lookup uses
@file_get_contents()with error suppression (inc/security-functions.php:81). If the API is unreachable the response is empty, no hash matches, and the password is accepted — it fails open. That is the right trade-off for availability, but it means the check cannot be relied on as a hard control. - The lookup is synchronous and happens inside password validation, so a slow HIBP response slows profile saves.
- Removing
wp_redirect_admin_locationsmeans/admin/404s rather than redirecting. If someone reports “the admin link is broken”, that is why. X-Frame-Options: DENYblocks embedding the site anywhere, including in legitimate previews. Override via thesecurityheader_frameoptionsfilter rather than editing the file.force_activation => trueon several plugins means a plugin disabled for debugging re-activates on the next admin page load.- SVG upload is enabled, and the filetype/ext check in
inc/template-functions.php:34returns the values unchanged rather than validating. Restrict who can upload media accordingly. - WP Rocket is declared with an
external_urland a bundled zip pinned to 3.10.5.1 — an old version. Plugin updating is a WP Engine/admin task, not a theme one.
Changing it safely
Section titled “Changing it safely”- Changing a header → use the corresponding
securityheader_*filter from a site-specific plugin orfunctions.phprather than editinginc/security-functions.php, so the base-theme file stays mergeable. - Raising HSTS → increase
securityheader_hstsgradually and only once you are certain every subdomain serves HTTPS. HSTS is not quickly reversible in browsers that have cached it. - Adding CSP → start in
Content-Security-Policy-Report-Only, and expect to deal with the inline scripts inheader.php(GTM, Universal Analytics, Moneypenny), the inline JSON-LD blocks, the inlined above-the-fold CSS, the CF7 inline scripts ininc/cf7.phpandinc/template-functions.php, and the Mapbox and ReviewSolicitors CDNs. - Disabling the password check for a bulk import → filter
pwnedpasswords_activeto false temporarily rather than commenting code out. - Deliberately not abstracted: every header is a separate
apply_filters()call rather than a config array, so individual headers can be overridden without touching the others. Keep that shape. - Verify: check response headers with
curl -Ion production (HSTS should be present) and on local (it should not); attempt a profile password change to a known-breached password and confirm rejection; submit the lost-password form for a non-existent account and confirm the response is identical to a real one.
None. No automated tests exist in this repository. Header changes are verifiable
with curl -I or securityheaders.com; the password check needs a manual attempt.
Vulnerabilities and hardening gaps found during review are tracked in the ticket tracker, not here.