Psalm’s taint engine can follow this flow just fine when the source is explicit:
user input --> - method param / array element --> sprintf(...) --> returned string --> outer echo
I verified that with the playground:
direct param --> sprintf --> echo: https://psalm.dev/r/28a080ab63
That reports TaintedHtml / TaintedTextWithQuotes as expected.
The problem is the WordPress shortcode case. In real code, shortcode attributes arrive via the framework and end up in the callback param $atts, but Psalm does not seem to model that as tainted input.
This leads to a false negative on a real CVE:
- CVE-2025-6257
- WordPress plugin:
wp_eurofxref
- stored XSS via shortcode attribute
append
Vulnerable pattern:
public function currency_shortcode( $atts ) {
extract( shortcode_atts( array(
'append' => ' *',
), $atts, 'currency' ) );
if ( $append ) {
$output .= sprintf(
'<span class="eurofxref-append-string">%s</span>',
$append
);
}
return $output;
}
Fixed version escapes shortcode-controlled values before output:
foreach( array( 'from', 'to', 'between', 'append', 'round_append' ) as $var ) {
$$var = esc_html( $$var );
}
$to_style = esc_attr( $to_style );
So this looks like a framework-modeling gap, not a general taint-tracking limitation.
What seems to be missing
At least one of these:
- WordPress shortcode callback params should be treated as user-controlled input.
- Returned HTML from shortcode callbacks should be modeled as flowing into HTML output.
- WordPress sanitizers like
esc_html() / esc_attr() should be modeled as taint escapes.
Why I think this is a real gap
Psalm already handles the equivalent simplified flow when the source is explicit.
In other words, this is not “Psalm can’t track sprintf” or “Psalm can’t track returned strings”. It can. The missing piece is the WordPress-specific source/sink/sanitizer modeling around shortcode callbacks.
Expected behavior
In taint mode, code like the vulnerable example above should produce an HTML taint issue when shortcode-controlled values are returned as HTML without escaping.
Actual behavior
No issue is reported for the real shortcode case.
Minimal control reproduction
This playground example shows the core taint flow works when the source is explicit:
https://psalm.dev/r/28a080ab63
If WordPress shortcode params were modeled as tainted input, I’d expect the real case to behave similarly.
If built-in WordPress support is out of scope, even documenting the recommended stub/plugin approach for shortcode sources and esc_html / esc_attr would help.
Psalm’s taint engine can follow this flow just fine when the source is explicit:
user input --> - method param / array element -->
sprintf(...)--> returned string --> outerechoI verified that with the playground:
direct param -->
sprintf-->echo: https://psalm.dev/r/28a080ab63That reports
TaintedHtml/TaintedTextWithQuotesas expected.The problem is the WordPress shortcode case. In real code, shortcode attributes arrive via the framework and end up in the callback param
$atts, but Psalm does not seem to model that as tainted input.This leads to a false negative on a real CVE:
wp_eurofxrefappendVulnerable pattern:
Fixed version escapes shortcode-controlled values before output:
So this looks like a framework-modeling gap, not a general taint-tracking limitation.
What seems to be missing
At least one of these:
esc_html()/esc_attr()should be modeled as taint escapes.Why I think this is a real gap
Psalm already handles the equivalent simplified flow when the source is explicit.
In other words, this is not “Psalm can’t track
sprintf” or “Psalm can’t track returned strings”. It can. The missing piece is the WordPress-specific source/sink/sanitizer modeling around shortcode callbacks.Expected behavior
In taint mode, code like the vulnerable example above should produce an HTML taint issue when shortcode-controlled values are returned as HTML without escaping.
Actual behavior
No issue is reported for the real shortcode case.
Minimal control reproduction
This playground example shows the core taint flow works when the source is explicit:
https://psalm.dev/r/28a080ab63
If WordPress shortcode params were modeled as tainted input, I’d expect the real case to behave similarly.
If built-in WordPress support is out of scope, even documenting the recommended stub/plugin approach for shortcode sources and
esc_html/esc_attrwould help.