Security
4 min read
Expressions are typed by people. The engine treats them as untrusted input and is built so that the worst an expression can do is print data that its writer was already allowed to see. This page lists each protection and the switch that adjusts it.
1. No code runs
- Expressions are parsed by a small hand-written parser and evaluated by a fixed evaluator. There is no
eval, no dynamic function call and no way to name a PHP function. - Filters and functions are looked up by name in a registry.
{{ fn.system('ls') }}finds nothing and renders empty. - Namespaces are resolvers registered by name. Only the paths documented in Namespaces read data.
2. Who may run expressions in a value
Expressions can read Global Data and other fields, so a stored value only runs them when the person who wrote it is trusted:
Value belongs to | Runs expressions? |
|---|---|
A post | If the post author has |
A user profile | If that user has |
A comment | Never |
A term, a widget, an options page | Yes |
Global Data | Yes (administrators only edit it) |
unfiltered_html is the capability that already lets someone put raw HTML into content: administrators and editors on a single site, and only super administrators on multisite. A contributor or an author cannot make a post print another field's value or a site option by typing {{ }}. Their text prints as stored.
The decision is made per value, on read, so changing a user's role changes the result immediately.
Change the rule with the wpmcf/field/resolve_variables filter:
// Let authors use expressions in one group only.
add_filter( 'wpmcf/field/resolve_variables', function ( $trusted, $group, $field, $object_id, $context ) {
return $group === 'product' && $context === 'post' ? true : $trusted;
}, 10, 5 );
// Never evaluate stored values on this site.
add_filter( 'wpmcf/field/resolve_variables', '__return_false' );Direct calls to wpmcf_resolve_variables() are not subject to this check: they are theme or plugin code, which is trusted. Do not pass visitor input to it.
3. Passwords never leave
Source | Behaviour in an expression |
|---|---|
| Empty |
A password sub-field inside a group or repeater read through | Removed from the value |
| Empty |
A template's output is public, so a stored secret must never become part of it. To opt in on a site, return true from wpmcf/field/the_password (fields) or wpmcf/global/the_password (Global Data). The same filters govern printing with wpmcf_the_field() and wpmcf_the_global(), and the hashed storage mode is never revealed. See Password helpers and security.
4. fn.option() reads an allow-list
Only these options can be read: blogname, blogdescription, siteurl, home, date_format, time_format, timezone_string, gmt_offset, start_of_week. Anything else, such as admin_email, active_plugins or an API key option, returns nothing. The name is cleaned with sanitize_key, and an option that holds an array or object returns nothing.
Extend it only with options that are safe to print on a public page:
add_filter( 'wpmcf/variables/allowed_options', function ( array $options ): array {
$options[] = 'my_plugin_public_phone';
return $options;
} );5. Results are data, never code
- The text a block returns is never evaluated again. A Global Data value
{{ fn.option('blogname') }}read through{{ glb.key }}prints those characters, not the blog name. - A result passed into another expression through a nested block (
{{ fn.upper({{ glb.x }}) }}) becomes a quoted string, with its quotes and backslashes escaped. A value such asa') }} {{ fn.option('blogname') }} ('cannot break out of the string and run a second expression. - In Programmatic SEO, imported data has every
{{and}}split by a space so that a CSV cell can never become a template, and shortcode brackets are encoded.
6. Hard limits
Limit | Value |
|---|---|
Length of one block | 1000 characters |
Nesting of blocks inside blocks | 5 |
Depth of parentheses and arguments | 32 |
| 100 repeats, 10,000 characters |
Decimal places in | 10 |
An expression past a limit renders empty. The limits exist so that a template cannot exhaust memory or stack.
7. Escaping is yours at the output
The engine returns plain text, unescaped. Who escapes depends on the entry point:
Entry point | Escaping |
|---|---|
| Escaped for the output format after evaluation |
| Raw result: escape it ( |
| Raw result: escape it |
Elementor settings | Each result is inserted as escaped plain text; a link is sanitized as a URL |
Programmatic SEO | By position: text, HTML ( |
Inside an expression, use the escape, esc_attr, esc_url or strip_tags filters when the value is going somewhere specific.
8. Viewer data and page caches
wp.user_id, wp.user_name, wp.user_email, wp.user_login, wp.user_first_name and wp.user_last_name describe the signed-in viewer. When a front-end page prints one, the plugin defines DONOTCACHEPAGE and sends no-cache headers, so a page cache does not serve one person's details to everyone. Inside wp-admin nothing is changed. Avoid these names on pages meant to be cached.
9. Failures stay local
A broken expression renders empty and the page keeps loading. The engine never throws into your template: wpmcf_resolve_variables() runs through the plugin's fault guard and returns the template unchanged on an unexpected error (and before the plugin has booted). With WP_DEBUG on, the reason goes to the PHP error log. See Troubleshooting.
Checklist for developers
- Escape the result of
wpmcf_get_field()andwpmcf_resolve_variables()at output. - Never feed visitor input, comment text or request parameters to
wpmcf_resolve_variables(). - Keep
wpmcf/field/resolve_variablesas narrow as you can; each author you trust can read any Global Data value and any field of the same post. - Add to
wpmcf/variables/allowed_optionsonly public options. - Avoid
wp.user_*on cacheable pages. wp.post_*is empty outside post contexts, so it cannot leak another post through a term, user or comment ID.
Still stuck? Contact support