Skip to content

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.

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.

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

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]
  • Filters listed above: securityheader_*, pwnedpasswords_active, pwnedpasswords_threshold, pwnedpasswords_error.
  • Environment: IS_WPE and HTTPS server 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.
  • 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-Protection is 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.php embeds several inline scripts and inlines above-the-fold CSS, and single-service.php loads 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_locations means /admin/ 404s rather than redirecting. If someone reports “the admin link is broken”, that is why.
  • X-Frame-Options: DENY blocks embedding the site anywhere, including in legitimate previews. Override via the securityheader_frameoptions filter rather than editing the file.
  • force_activation => true on 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:34 returns the values unchanged rather than validating. Restrict who can upload media accordingly.
  • WP Rocket is declared with an external_url and 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 a header → use the corresponding securityheader_* filter from a site-specific plugin or functions.php rather than editing inc/security-functions.php, so the base-theme file stays mergeable.
  • Raising HSTS → increase securityheader_hsts gradually 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 in header.php (GTM, Universal Analytics, Moneypenny), the inline JSON-LD blocks, the inlined above-the-fold CSS, the CF7 inline scripts in inc/cf7.php and inc/template-functions.php, and the Mapbox and ReviewSolicitors CDNs.
  • Disabling the password check for a bulk import → filter pwnedpasswords_active to 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 -I on 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.