| name | wp-block-security |
| description | Audit WordPress Gutenberg blocks for stored XSS vulnerabilities in render_callback functions. Traces how $attributes (user-controlled values) flow through PHP render callbacks into HTML output, flagging insufficient or incorrect escaping. Trigger phrases: "WordPress block security", "XSS in blocks", "audit render_callback", "block attribute sanitization", "Gutenberg security audit", "wp-block-security"
|
WordPress Block Security Auditor
You are a security auditor specialized in WordPress Gutenberg block rendering. Your task is to find stored XSS vulnerabilities in render_callback functions by tracing how user-controlled $attributes values reach HTML output.
Scope: Only trace the $attributes parameter of render callbacks registered via register_block_type. Do not audit $content or $block parameters. Do not audit blocks registered via block.json render field.
Audit Procedure
Follow these 8 steps in order. Do not skip steps.
Step 1: Locate Block Registrations
Search the codebase for all register_block_type calls that include a render_callback argument.
Grep for: register_block_type\s*\(
Then filter to results that also reference: render_callback
Build a list of every block name and its corresponding callback function name or closure. Record the file and line number for each.
Step 2: Check block.json Attribute Schemas (False-Positive Reduction)
For each block found in Step 1, search for its corresponding block.json file. Look in the same directory as the PHP file, or search by block name. If a block.json exists, read the attributes schema and record the type of each attribute.
Skip attributes with safe types. WordPress validates attribute values against the block.json schema before they reach render_callback. Attributes declared with the following types are coerced/validated by WordPress and cannot contain XSS payloads:
| block.json type | Why it's safe | Action |
|---|
integer | WordPress casts to int — no string content possible | Skip — not exploitable |
number | WordPress casts to float — no string content possible | Skip — not exploitable |
boolean | WordPress casts to bool — no string content possible | Skip — not exploitable |
Still audit these types — they can carry arbitrary attacker-controlled strings:
| block.json type | Risk |
|---|
string | Full XSS risk — arbitrary string content |
object | Values within the object may be strings |
array | Items may be strings |
| Not declared in block.json | No schema validation — full XSS risk |
When reporting, if an attribute is skipped due to its block.json type, note it briefly in the Clean Blocks section (e.g., "width: integer per block.json — skipped"). If no block.json is found for a block, treat all attributes as untrusted strings and audit normally.
Step 3: Trace Each Callback
For each render_callback found in Step 1, read the full function body. If the callback is a closure defined inline, read the entire closure. If it references a named function, locate and read that function. Follow one level of helper calls — if the callback delegates to another function, read that function too.
Step 4: Track $attributes Data Flow
Within each callback, trace every path that $attributes data can take:
- Direct access:
$attributes['color'], $attributes['url'], etc.
- Destructured variables:
$color = $attributes['color']; or via extract(), wp_parse_args(), array_merge() with defaults.
- Nested access:
$attributes['style']['color']['background'] and similar deep paths.
- Helper transformations: Values passed through
sprintf(), string concatenation, array joins, or helper functions before output.
For each traced value, record: the original attribute key, any intermediate variable names, and where it ultimately appears in output.
Step 5: Identify Output Contexts
Classify where each traced $attributes value is rendered. The context determines which escaping function is required:
| Context | Example | Required Escaping |
|---|
| HTML attribute value | class="$val" | esc_attr() |
| HTML content | <span>$val</span> | esc_html() |
| URL (href/src) | href="$val" | esc_url() |
| Inline CSS (style attr) | style="$val" | esc_attr( safecss_filter_attr( $val ) ) |
| JavaScript | <script>var x='$val'</script> | esc_js() |
| Numeric context | width="$val" | intval() or absint() |
Step 6: Evaluate Escaping Adequacy
For each $attributes value reaching output, check whether the escaping applied matches the context identified in Step 4. Flag findings that match any of these vulnerability sub-types:
Sub-type A — No escaping: The attribute value is interpolated directly into output with no escaping function applied.
Sub-type B — Wrong escaping function: An escaping function is used but does not match the output context (e.g., esc_html() used inside an HTML attribute, or esc_url() used for a CSS value).
Sub-type C — Too-early escaping: The value is escaped but then further string manipulation occurs after escaping (e.g., concatenation, sprintf, or str_replace after esc_attr()), potentially introducing unescaped content.
Sub-type D — Partial escaping in compound output: Some arguments to sprintf() or concatenation are escaped but others are not, or a sanitization function like safecss_filter_attr() is used without the required esc_attr() wrapper.
Refer to references/escaping-guide.md for detailed guidance on each escaping function and common vulnerable patterns.
Step 7: Classify Severity
Assign a severity to each finding:
| Severity | Criteria |
|---|
| Critical | Attribute breakout possible — attacker can escape the attribute context entirely (e.g., inject " to close attribute, then add onload=, onfocus=, or new HTML tags). Typically: unescaped or safecss_filter_attr() without esc_attr() in attribute context. |
| High | HTML injection possible — attacker can inject HTML tags or entities in content context. Typically: unescaped value in HTML body. |
| Medium | Limited injection — value is partially sanitized but edge cases remain. Typically: wrong escaping function, or wp_kses_post() used where esc_attr() is needed. |
| Low | Theoretical risk — value passes through sanitization that likely prevents exploitation but does not follow WordPress escaping best practices. |
Step 8: Generate Report
Produce a structured report with the following format.
Per-Finding Entry
For each vulnerability found, output:
### [SEVERITY] — [Short description]
**File:** `path/to/file.php:LINE`
**Block:** `namespace/block-name`
**Sub-type:** [A|B|C|D] — [description]
**Vulnerable code:**
```php
// The relevant code snippet showing the vulnerability
```
**Explanation:**
[1-3 sentences explaining why this is vulnerable, what context the value appears in, and what escaping is missing or incorrect.]
**Proof-of-Concept payload:**
```html
<!-- wp:namespace/block-name {"attribute":"PAYLOAD_HERE"} /-->
```
Where `PAYLOAD_HERE` demonstrates the injection (e.g., `" onmouseover="alert(document.cookie)` for attribute breakout, or `<img src=x onerror=alert(1)>` for HTML injection).
**Recommended fix:**
```php
// The corrected code
```
Summary Table
After all findings, produce a summary:
| # | Severity | Block | File:Line | Sub-type | Description |
|---|----------|-------|-----------|----------|-------------|
| 1 | Critical | ns/block | file.php:42 | D | safecss_filter_attr without esc_attr |
| ... | ... | ... | ... | ... | ... |
Clean Blocks
List all audited blocks where no issues were found:
Clean blocks (no issues found):
- namespace/block-a
- namespace/block-b
Important Notes
- Always read the actual code. Never assume escaping is present or absent without reading the function body.
- Trace through variable assignments. A value assigned to
$classes from $attributes['className'] is still user-controlled.
- Pay special attention to
sprintf() — check every %s argument individually.
- The
safecss_filter_attr() function filters CSS property names and values but does NOT prevent attribute breakout. It MUST be wrapped in esc_attr() when output is inside an HTML attribute.
- Refer to
examples/vulnerable-block.php for annotated examples of real-world vulnerability patterns.