Skip to content

Do not use JSON_UNESCAPED_SLASHES in format_json_encode() since it may be unsafe in HTML context#22913

Merged
vraja-pro merged 1 commit intoYoast:trunkfrom
rob006:json-encode
Mar 10, 2026
Merged

Do not use JSON_UNESCAPED_SLASHES in format_json_encode() since it may be unsafe in HTML context#22913
vraja-pro merged 1 commit intoYoast:trunkfrom
rob006:json-encode

Conversation

@rob006
Copy link
Copy Markdown
Contributor

@rob006 rob006 commented Jan 26, 2026

Context

Using JSON_UNESCAPED_SLASHES when embedding JSON inside a <script> tag is unsafe because it allows unescaped </script> sequences to appear in the output. HTML parsers terminate <script> tags before JavaScript parsing, even if the sequence appears inside a string literal. This can lead to XSS if any JSON value is user-controlled.

The default escaping in json_encode() prevents this and should not be disabled in this context. While this does not appear to be an issue in the plugin's default setup, the schema can be altered by filters, and the current JSON encoding settings are a footgun that can lead to security issues if misused.

Steps to reproduce:

add_filter('wpseo_schema_webpage', static function ($graphPiece) {
	$graphPiece['@id'] = 'https://example.com/?s=</script>';
	$graphPiece['url'] = 'https://example.com/?s=</script>';
	return $graphPiece;
});

Note that https://example.com/?s=</script> is a perfectly valid URL, but </script> in the URL prematurely closes the <script> tag.

Summary

This PR can be summarized in the following changelog entry:

  • Improves security of format_json_encode() by removing JSON_UNESCAPED_SLASHES, which could allow a user to control tags in the schema JSON-LD output. Props to @rob006.

Relevant technical choices:

  • JSON_UNESCAPED_SLASHES is removed from format_json_encode(), keeping only JSON_UNESCAPED_UNICODE. As a result, forward slashes in URLs and other values will now be escaped as \/ in all JSON output produced by this function. This is valid JSON per the spec and semantically equivalent — all JSON parsers and search engines handle it correctly.
  • The change affects all call sites of format_json_encode(), most notably the schema JSON-LD <script> block and the open_graph_image_meta field stored in the indexables table. Existing DB records retain the old encoding; json_decode() handles both formats transparently.

Test instructions

Test instructions for the acceptance test before the PR gets merged

This PR can be acceptance tested by following these steps:

  1. Install the plugin and create a few posts/pages with standard WordPress URLs containing slashes.
  2. Visit the frontend of those pages and view the page source — find the <script type="application/ld+json" class="yoast-schema-graph"> block.
  3. Verify the schema is valid JSON (paste it into jsonlint.com). URLs will now appear with escaped slashes (\/) — this is expected and correct.
  4. Verify the <script> tag is intact (not split) and no JS errors appear in the browser console.
  5. To test the security fix, add the following snippet to your theme's functions.php:
    add_filter( 'wpseo_schema_webpage', static function ( $graphPiece ) {
        $graphPiece['@id'] = 'https://example.com/?s=</script><script>alert(1)</script>';
        $graphPiece['url'] = 'https://example.com/?s=</script><script>alert(1)</script>';
        return $graphPiece;
    } );
  6. Visit any frontend page and verify:
    • No alert box appears (XSS is not triggered).
    • The <script> block is not broken — the schema renders as a single intact block in the page source.
    • The browser console shows no errors.
  7. Remove the snippet from functions.php after testing.
  8. Navigate to a post with an Open Graph image set and verify the OG image still renders correctly in a social preview tool (e.g. opengraph.xyz).

Relevant test scenarios

  • Changes should be tested with the browser console open
  • Changes should be tested on different posts/pages/taxonomies/custom post types/custom taxonomies
  • Changes should be tested on different editors (Default Block/Gutenberg/Classic/Elementor/other)
  • Changes should be tested on different browsers
  • Changes should be tested on multisite

Test instructions for QA when the code is in the RC

  • QA should use the same steps as above.

QA can test this PR by following these steps:

Impact check

This PR affects the following parts of the plugin, which may require extra testing:

  • Schema JSON-LD output — all <script type="application/ld+json"> blocks on the frontend. URLs will contain \/ instead of /.
  • Open Graph image meta — the open_graph_image_meta field in the indexables table is encoded by format_json_encode(). Existing records are unaffected; newly written records will use the new encoding.
  • Any feature using format_json_encode() — this includes admin AJAX responses, Wincher API calls, HelpScout beacon, and notification storage. All consumers parse the JSON with json_decode() or JSON.parse(), both of which handle \/ correctly.

Other environments

  • This PR also affects Shopify. I have added a changelog entry starting with [shopify-seo], added test instructions for Shopify and attached the Shopify label to this PR.
  • This PR also affects Yoast SEO for Google Docs. I have added a changelog entry starting with [yoast-doc-extension], added test instructions for Yoast SEO for Google Docs and attached the Google Docs Add-on label to this PR.

Documentation

  • I have written documentation for this change. For example, comments in the Relevant technical choices, comments in the code, documentation on Confluence / shared Google Drive / Yoast developer portal, or other.

Quality assurance

  • I have tested this code to the best of my abilities.
  • During testing, I had activated all plugins that Yoast SEO provides integrations for.
  • I have added unit tests to verify the code works as intended.
  • If any part of the code is behind a feature flag, my test instructions also cover cases where the feature flag is switched off.
  • I have written this PR in accordance with my team's definition of done.
  • I have checked that the base branch is correctly set.
  • I have run grunt build:images and commited the results, if my PR introduces new images or SVGs.

Innovation

  • No innovation project is applicable for this PR.
  • This PR falls under an innovation project. I have attached the innovation label.
  • I have added my hours to the WBSO document.

@vraja-pro vraja-pro self-assigned this Mar 10, 2026
@vraja-pro
Copy link
Copy Markdown
Contributor

vraja-pro commented Mar 10, 2026

Functional regressions: No — but there are two behavioral changes worth noting:

  1. Schema JSON-LD output changes (cosmetic)
    In schema-presenter.php:47, the JSON-LD output in <script type="application/ld+json"> will now have / instead of / in URLs. E.g.:
  • Before: "url":"https://example.com/page/"
  • After: "url":"https:\/\/example.com\/page\/"

This is valid JSON, semantically equivalent, and Google/search engines handle both fine.

  1. open_graph_image_meta DB field encoding drift (minor)
    indexable-social-image-trait.php:113 stores image meta as JSON in the indexables table. Existing DB records have unescaped slashes; new/updated records will have escaped slashes. json_decode() handles both correctly, so reads are fine. However, when an indexable is rebuilt, the ORM will detect the string differs from what's in DB → could trigger spurious saves for all indexables on their next rebuild. This is a one-time migration effect, not a persistent issue.

  2. format_json_encode is used broadly
    The function is used in 30+ files including Wincher API calls, admin AJAX, HelpScout beacon, etc. All of these consume the JSON via JSON.parse() or API clients that handle / correctly — no regression there.

Verdict:
No meaningful regression. The security fix is correct and necessary. The only real concern is the one-time DB encoding drift for open_graph_image_meta causing extra saves on next indexable rebuild, but that's harmless. The PR is safe to merge.

@vraja-pro vraja-pro added this to the 27.3 milestone Mar 10, 2026
@vraja-pro vraja-pro added changelog: bugfix Needs to be included in the 'Bugfixes' category in the changelog changelog: other Needs to be included in the 'Other' category in the changelog and removed changelog: bugfix Needs to be included in the 'Bugfixes' category in the changelog labels Mar 10, 2026
Copy link
Copy Markdown
Contributor

@vraja-pro vraja-pro left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CR & AC ✅

@vraja-pro vraja-pro merged commit 9619b64 into Yoast:trunk Mar 10, 2026
31 of 36 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog: other Needs to be included in the 'Other' category in the changelog community-patch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants