Security
How BlueTheme protects what it adds to a WordPress site: who may call each of its REST routes and admin actions, how input is cleaned, and how the checks are tested. Reviewed before 1.0 (see quality checks).
Reporting a vulnerability
If you find a security problem in BlueTheme, please report it privately to BlueMint Group through the contact page at https://bluemint.pro. Don't open a public issue. Include the theme version, what an attacker can do, and the steps to reproduce it. We confirm reports, fix them in a new version, and credit the reporter if they wish.
General rules
- Capabilities. Every route and action checks a WordPress capability for what it does (
edit_theme_optionsfor Theme Options,edit_poston the very post for the builder and Page Options, and so on). Routes open to visitors are listed below, each with its own limits. - Nonces. REST calls from the theme's screens send the
wp_restnonce: with a login cookie, WordPress refuses a call that lacks it. Admin forms and downloads (admin-post.php) check their own nonces. - Input. Everything saved is cleaned against a schema: Theme Options and Page Options by
Options\Sanitizer, builder documents byBuilder\Data\Sanitizer(each element's fields from itsconfig.json), and template conditions byTemplates\Conditions. HTML, JavaScript and raw code are kept only for users withunfiltered_html; for others HTML goes throughwp_kses_post()and scripts are dropped. Custom CSS cannot close its<style>element. - Output. Output is escaped where it is printed. PHPCS (WordPress Coding Standards,
WordPress.Security) checks this; where a value was escaped earlier (by an element part, the icon registry, and so on), the line says where. - Files. Uploads are limited by type and checked by content: fonts (woff, woff2) only for Theme Options managers; Lottie animations (
.json) only when they parse as Lottie; icon packs as zip files of 50 MB or less, extracted by WordPress and with SVG markup reduced to shapes (no scripts, links, styles or event attributes). Demo packages may only read files inside their own folder. - Remote requests. Use
wp_safe_remote_get()/download_url(), which refuse local and private addresses: Google Fonts (fonts.googleapis.com and fonts.gstatic.com only), the demo catalog and packages, and the license server. - No
eval(), and no shell commands.
REST routes
All under /wp-json/bluetheme/v1.
| Route | Methods | Who may call it | Notes |
|---|---|---|---|
/options |
GET, POST | edit_theme_options (filter bluetheme_options_capability) |
Values cleaned by the options schema. Code fields need unfiltered_html. |
/options/reset, /options/import, /css/regenerate |
POST | Same | An import keeps the site's secrets (API keys); export files never contain them. |
/icons |
GET, POST | Same | Zip upload, see Files. |
/icons/{id} |
DELETE | Same | Only uploaded packs. |
/fonts/google/download |
POST | Same | Downloads from Google Fonts only. |
/license/activate, /license/deactivate, /license/check-updates |
POST | Same | |
/builder/{id} |
GET, POST | edit_post on that post, of a type the builder is on for |
The document is cleaned before saving. |
/builder/{id}/render, /render-sections, /autosave |
POST | Same | Rendering cleans the posted nodes first; autosave writes only the user's autosave revision. |
/builder/{id}/revisions, /revisions/{rev} |
GET | Same | The revision must belong to that post. |
/page-options/{id} |
GET, POST | edit_post on that post |
|
/templates |
GET | edit_posts |
Published templates (name and type), for inserting global sections. |
/templates |
POST | publish_pages |
|
/templates/{id}/document, /templates/{id}/settings |
GET, POST | edit_post on that template |
|
/templates/rules, /templates/rules/search |
GET | edit_pages |
The search shows private and draft items only to users who may read them. |
/library, /library/{id} |
GET | edit_posts |
The pre-built sections. |
/demo/catalog, /demo/import, /demo/step, /demo/cancel, /demo/remove, /demo/keep, /demo/language |
GET, POST | manage_options |
|
/demo/plugins |
GET, POST | activate_plugins; installing also needs install_plugins |
Only the plugins the wizard offers (its suggestions and those the demos ask for). |
/otp/test-sms, /otp/test-log |
POST | manage_options |
|
/otp/send, /otp/verify |
POST | Visitors (login); signed-in customers (adding a mobile number) | Only when one-time-code login is on. Limits: a wait between codes, codes per number and per IP an hour (Theme Options), 5 wrong tries per code. Redirects stay on the site. |
/contact |
POST | Everyone | Popup contact form. Nonce, hidden honeypot field, at least 3 seconds to fill, length limits (100/100/5000), 5 messages an hour per IP. |
/love/{id} |
POST | Everyone | Published, not password-protected posts of types with the Love button. One vote per post and visitor a day, 30 votes an hour per IP. |
/quick-view/{id} |
GET | Everyone | Published, visible, not password-protected products. |
/wishlist/products |
GET | Everyone | Product cards for a guest's list (WooCommerce shows only visible products). |
/wishlist |
GET, POST | Signed-in users | Up to 200 products, 120 saves in 10 minutes per IP. |
Admin actions
Action (admin-post.php) |
Who may call it |
|---|---|
bluetheme_new_template |
publish_pages, with nonce |
bluetheme_export_template |
edit_post on the template, with nonce |
bluetheme_import_template |
publish_pages, with nonce; JSON files of 4 MB or less, cleaned like a save |
bluetheme_demo_export |
manage_options, with nonce |
The theme has no admin-ajax.php actions.
Rate limits
BlueTheme\Security\RateLimit counts requests per route and IP address in a transient. Two filters adjust it:
// Change a route's limit: [ requests, seconds ]; 0 requests turns it off.
add_filter( 'bluetheme_rate_limit', function ( $limit, $route ) {
return 'love' === $route ? array( 60, HOUR_IN_SECONDS ) : $limit;
}, 10, 2 );
// Behind a proxy or CDN: the visitor's address from its trusted header.
add_filter( 'bluetheme_client_ip', function ( $ip ) {
return $_SERVER['HTTP_CF_CONNECTING_IP'] ?? $ip;
} );
The route names are love, contact and wishlist. The one-time-code login has its own limits in Theme Options, and its own IP filter, bluetheme_otp_ip.
Checks
pnpm qa:security # both dev sites
pnpm qa:security -- --site=en # one site
composer run analyse # PHPStan
tools/qa-security.mjs:
- Reads the theme's routes from the site's own route index and calls each route and method as a visitor, as a subscriber and as an administrator with a wrong nonce. Only the routes listed above as open to everyone (or to signed-in users) may answer the first two; every call with a wrong nonce must be refused. The private GET routes must answer an administrator, which shows the calls got past WordPress's argument checks and reached the permission check. A new route counts as private until it is added to the script's public list.
- Finds the theme's
admin-postandadmin-ajaxactions in its source and checks that visitors and subscribers are refused. - Runs
tools/wp-env/xss-check.php: as an Author (nounfiltered_html), every field of every element, section and wrap gets script-injection payloads (script tags, event attributes,javascript:links,</style>). The document is cleaned the way a save cleans it and rendered, and none of the payloads may reach the HTML or the CSS. Its self-test repeats this as an administrator, whose raw HTML is kept on purpose, and must report findings.
It creates a subscriber, a draft page and a template for the run and removes them afterwards.
PHPStan (level 5, with the WordPress and WooCommerce stubs) checks the theme's classes in bluetheme/inc and runs in CI. Element renderers and templates receive their variables from the theme, which PHPStan cannot follow; PHPCS covers them.
Fixed in 10d
- Condition search showed private titles. The template conditions search needed only
edit_posts, and listed the titles of other people's private and draft posts. It now needsedit_pages(template editing), and lists private and draft items only to users who may read them. - The wizard could install any plugin. Its install route took any WordPress.org plugin name. It now installs only the plugins the wizard offers.
- Rate limits. The Love button only stopped repeat votes on one post, and wishlist saves had no limit. Both now have per-IP limits, and the contact form uses the same shared limiter.
- Contact form. The field lengths were only limited in the browser; the server now enforces them too. The sender's name goes into the mail's Reply-To header without characters that have a meaning there (
< > , ; : " \). - PHPStan's findings:
- Variable-product prices are read only from variable products.
- A list filter no longer uses
strlenas its callback. - The plugin installer reads WordPress.org's answer whether it comes as an object or an array.
- A broken
useline in the menu walker was removed (harmless: the correct line followed it). - Several doc types were corrected.