Skip to content

Security & Anti-XSS Protection

JSON-LD is data, but its <script> element is still parsed by the HTML parser. Embedding an unescaped closing script tag from CMS or user content can end the data block and create a Cross-Site Scripting (XSS) path.

unschema-graph prevents this script-breakout path by default across all packages.

Render with <Schema /> or serialize with serializeJsonLd(). Do not insert the result of plain JSON.stringify() into an HTML script element. Continue to validate and escape the same untrusted content separately when it is rendered as visible HTML.


Standard JSON.stringify() does not escape <, >, or &.

Consider this malicious CMS input:

{
"headline": "A great post</script><script>alert('XSS')</script>"
}

If inserted directly into an HTML template using JSON.stringify():

<!-- ❌ DANGEROUS: The HTML parser stops reading at the first </script> -->
<script type="application/ld+json">
{"headline":"A great post</script><script>alert('XSS')</script>"}
</script>

The browser’s HTML parser closes the LD+JSON block at </script> and immediately executes the injected <script> tag.


The serializeJsonLd() function—used by <Schema /> in both Astro and Svelte—escapes sensitive characters into standard JSON Unicode sequences:

Sensitive Character Sanitized Unicode Form Protection Provided
< \u003c Prevents opening </script> or <script> tags
> \u003e Prevents HTML tag termination
& \u0026 Prevents entity confusion
\u2028 (Line Separator) \u2028 Fixes JavaScript parsing issues in legacy engines
\u2029 (Paragraph Separator) \u2029 Fixes JavaScript parsing issues in legacy engines

When serialized with unschema-graph:

<!-- ✅ SAFE: HTML parser sees pure text without any tag delimiters -->
<script type="application/ld+json">
{"headline":"A great post\u003c/script\u003e\u003cscript\u003ealert('XSS')\u003c/script\u003e"}
</script>

JSON parsers decode \u003c back to < while the HTML parser never sees a literal tag delimiter.


TypeScript types only exist at compile time. External CMS APIs or database responses can return unexpected shapes or missing required fields.

To safeguard your build:

  1. Use builder.safeParse() for untrusted endpoints:
    const result = Article.safeParse(cmsPayload);
    if (!result.success) {
    console.error('Schema rejected:', result.error.message);
    }
  2. Set onError: 'throw' in production builds: Catch invalid schema structures during CI before broken SEO metadata reaches search engines.

4. Controlled extension with withAdditionalProperties()

Section titled “4. Controlled extension with withAdditionalProperties()”

When you need custom Schema.org properties not present in the typed builders, use withAdditionalProperties():

import { Article, withAdditionalProperties } from '@unschema-graph/core';
const article = withAdditionalProperties(
Article({
headline: 'Secure Structured Data',
image: 'https://example.com/cover.jpg',
datePublished: 'today',
author: 'Ada Lovelace',
}),
{
customTrackingId: 'xyz-123',
}
);

withAdditionalProperties() strictly prevents overriding core @type or @id values.

  • Treat CMS, API, database and user-authored values as untrusted at runtime.
  • Parse uncertain payloads with safeParse() before they reach a page component.
  • Keep onError: 'throw' for production builds and CI.
  • Extend a validated entity only through withAdditionalProperties() or withAdditionalTypes(); never spread an unchecked payload into JSON-LD.
  • Build the final site and run the audit CLI against the emitted HTML.
  • Review the rendered structured data whenever the CMS model or mapping changes.

Next: use the audit CLI to inspect the compiled HTML, open the troubleshooting guide, or review known limitations.