Skip to content

Security

This page explains the security architecture of Naluma WordPress --- the layered defenses, their rationale, and how they interact. The approach is defense-in-depth: no single measure is sufficient, but together they significantly reduce the attack surface of a public-facing WordPress site.

Threat Model

A medical information site faces specific risks:

  • Content integrity. Unauthorized modification of health content could harm patients. Write access must be tightly controlled.
  • User data exposure. Even without user accounts for visitors, the admin accounts and API credentials are high-value targets.
  • Platform abuse. WordPress is a common target for automated attacks --- brute force login attempts, XML-RPC exploits, user enumeration, and spam registration.
  • API surface. The REST API is publicly accessible for the Flutter app, creating a controlled-but-real attack surface.

Security Layers

graph TB
    subgraph "Network Layer"
        CDN["Kinsta CDN / Cloudflare<br/>DDoS protection, WAF"]
    end

    subgraph "Application Layer"
        HideLogin["WPS Hide Login<br/>Custom login URL"]
        TwoFactor["Two-Factor Auth<br/>TOTP for all admins"]
        Hardening["security-hardening.php<br/>XML-RPC, enumeration, registration"]
        CORS["cors-headers.php<br/>Origin allowlist"]
        FileMods["DISALLOW_FILE_MODS<br/>No plugin installs from admin"]
    end

    subgraph "API Layer"
        RESTPerms["REST API Permissions<br/>Public read, authenticated write"]
        CORSPreflight["CORS Preflight Handler<br/>OPTIONS request validation"]
    end

    CDN --> HideLogin
    CDN --> Hardening
    HideLogin --> TwoFactor
    TwoFactor --> RESTPerms
    Hardening --> CORS
    CORS --> CORSPreflight
    FileMods --> RESTPerms

Layer 1: Security Hardening (security-hardening.php)

This must-use plugin addresses the three most common WordPress attack vectors:

XML-RPC disabled entirely. The XML-RPC interface (xmlrpc.php) is a legacy API predating the REST API. It supports methods like system.multicall that allow attackers to attempt hundreds of password guesses in a single HTTP request, bypassing rate limiting. Since all legitimate API access uses the REST API, XML-RPC is disabled with no functional loss:

add_filter('xmlrpc_enabled', '__return_false');

REST API user enumeration blocked. By default, the /wp/v2/users endpoint exposes usernames and user IDs to unauthenticated requests. Attackers use this to harvest admin usernames for brute force attacks. The hardening plugin removes the users endpoints entirely for unauthenticated requests, returning a 404 instead:

add_filter('rest_endpoints', function (array $endpoints): array {
    if (! is_user_logged_in()) {
        unset($endpoints['/wp/v2/users']);
        unset($endpoints['/wp/v2/users/(?P<id>[\d]+)']);
    }
    return $endpoints;
});

User registration locked. Self-registration is disabled at the code level by filtering the users_can_register option to always return zero. This prevents registration even if an admin accidentally enables it in Settings, and it blocks automated registration bots:

add_filter('option_users_can_register', '__return_zero');

Cannot be overridden from WP Admin

All three hardening measures are implemented as code-level filters in a must-use plugin. They cannot be disabled from the WordPress admin interface, which is intentional --- security controls should not be toggleable by a compromised or careless admin account.

Layer 2: Login Protection

WPS Hide Login changes the WordPress login URL from the default /wp/wp-admin/ and /wp/wp-login.php to a custom, non-guessable URL. This is security through obscurity --- not a strong defense on its own, but it eliminates the vast majority of automated brute force traffic that targets default WordPress login paths.

Two-Factor authentication (via the Two-Factor plugin) requires a second authentication factor (TOTP) for admin accounts. Even if credentials are compromised through phishing or password reuse, the attacker cannot log in without the time-based one-time password.

Together, these two measures mean an attacker must: (1) discover the hidden login URL, (2) obtain valid credentials, and (3) have access to the admin's authenticator app.

Layer 3: CORS Configuration (cors-headers.php)

The REST API is consumed by the Flutter mobile app and the marketing automation system, both of which make cross-origin requests. CORS headers control which origins are allowed to make these requests from a browser context.

The implementation uses an environment-variable-driven allowlist:

# .env
TINNITUS_CORS_ORIGINS=https://app.naluma.de,https://naluma.de

The TINNITUS_CORS_ORIGINS constant is defined in config/application.php from the environment variable, then consumed by cors-headers.php. The plugin handles two scenarios:

Regular requests. The http_origin filter checks the request's Origin header against the allowlist. Allowed origins get echoed back in the Access-Control-Allow-Origin header; disallowed origins get an empty string, causing WordPress to skip CORS headers entirely.

Preflight requests. Browser-initiated OPTIONS requests to the REST API are intercepted early (on init with priority 1). If the origin is allowed, the plugin responds with 204 and the appropriate CORS headers (Allow-Methods: GET, OPTIONS, Allow-Headers: Content-Type, Authorization, Max-Age: 86400). If not allowed, the request falls through without CORS headers.

Why not allow all origins?

A permissive Access-Control-Allow-Origin: * would allow any website to make authenticated API requests on behalf of a logged-in admin. The allowlist ensures only known, trusted origins can interact with the API from browser contexts. Note that CORS is a browser-enforced policy --- native apps (like the Flutter app making server-side requests) are not constrained by CORS.

Layer 4: Filesystem Protection

Two WordPress constants lock down the filesystem in production:

DISALLOW_FILE_EDIT disables the built-in theme and plugin code editor in WP Admin. This prevents an attacker who gains admin access from injecting malicious PHP code through the editor.

DISALLOW_FILE_MODS goes further: it disables all plugin and theme installation, updating, and deletion from the admin interface. All changes to the codebase must go through Composer and the deployment pipeline. This eliminates an entire class of attacks where a compromised admin account installs a malicious plugin.

Both are set in config/application.php and apply across all environments:

Config::define('DISALLOW_FILE_EDIT', true);
Config::define('DISALLOW_FILE_MODS', true);

Layer 5: REST API Permissions

The custom REST API under /tinnitus/v1/ follows a clear permission model:

Operation Authentication Rationale
GET /tinnitus/v1/articles Public Flutter app needs unauthenticated read access
GET /tinnitus/v1/articles/{id} Public Same --- individual article retrieval
GET /tinnitus/v1/research Public Research summaries for the app
POST /wp/v2/article Application password Content pipeline writes via standard WP API
PUT /wp/v2/article/{id} Application password Content pipeline updates

The custom /tinnitus/v1/ endpoints are read-only by design. Content creation is handled through the standard WordPress REST API (/wp/v2/) which has built-in authentication and capability checks. This minimizes the custom API surface that needs security review.

Rank Math SEO meta fields (rank_math_focus_keyword, rank_math_title, rank_math_description) are exposed in the REST API for the content pipeline but use an auth_callback that requires edit_posts capability --- they cannot be modified by unauthenticated requests.

Platform-Level Security

Kinsta's managed hosting adds infrastructure-level protections that complement the application-level measures:

  • Nginx-based (no .htaccess) with server-level security rules
  • Automatic SSL/TLS with HTTPS enforcement
  • DDoS protection via Cloudflare integration
  • Server-level firewall with IP-based rate limiting
  • Automatic backups for disaster recovery
  • Isolated container architecture preventing cross-site contamination

The bedrock-disallow-indexing mu-plugin adds a non-security but security-adjacent protection: it prevents search engines from indexing non-production environments, reducing the risk of staging content (which may contain test data or unreleased content) appearing in search results.

Security Audit Checklist

When reviewing security, verify these are all in place:

  • [ ] security-hardening.php is present in web/app/mu-plugins/ and unmodified
  • [ ] TINNITUS_CORS_ORIGINS in .env contains only trusted origins
  • [ ] DISALLOW_FILE_MODS is true in config/application.php
  • [ ] WPS Hide Login is configured with a non-default URL
  • [ ] Two-Factor is enabled for all admin accounts
  • [ ] Application passwords are scoped to the content pipeline user only
  • [ ] No REST API endpoints expose write operations without authentication