DEV Community

tong zhang
tong zhang

Posted on

19 of 424 JSON-LD blocks were not JSON. Every one had JavaScript stapled to the end.

I parsed 424 application/ld+json blocks across 287 pages. Nineteen of them were not JSON. Each of the nineteen had a base64-encoded script stapled to the end of a perfectly valid schema object.

The markup looked fine in every validator that checks fields. It only failed when something tried to actually parse it as JSON — which is the one thing a crawler does before it looks at a single field.

What I pulled

I audit a network of 114 domains. For this run, every domain got three page URLs: the homepage plus up to two non-homepage URLs taken from that domain's own root sitemap, filtered to URLs on the same host and excluding anything ending in .xml, .xml.gz or .txt. Sitemap entries that point at other hosts or at child sitemaps are common on this network, so the filter is not optional.

296 requests, 287 came back 200. Eight never answered, one returned 404. Of the 287, 239 shipped at least one ld+json block. That is 83% of live pages carrying structured data, and 424 blocks in total.

I did not use the Rich Results Test, and I did not trust a browser extension badge. Both answer the question "does this look like schema.org" before they answer "is this a JSON document".

The test is a parser, not a validator

Extraction is a regex over the raw HTML, because ld+json blocks are data, not DOM. Then json.loads with no repair step:

import json, re

LD = re.compile(
    r'<script[^>]+type\s*=\s*["\']application/ld\+json["\'][^>]*>(.*?)</script>',
    re.I | re.S)

def blocks(html):
    return LD.findall(html)

def audit(html):
    total = ok = broken = 0
    for raw in blocks(html):
        total += 1
        try:
            json.loads(raw)
            ok += 1
        except json.JSONDecodeError as e:
            broken += 1
            print(f"{e}  tail={raw[-80:]!r}")
    return total, ok, broken
Enter fullscreen mode Exit fullscreen mode

Result: 405 parsed, 19 raised. That is 4.5% of blocks, spread over 19 pages — every failing page had exactly one failing block, so no page failed twice.

The exception is always the same shape:

Extra data: line 13 column 5 (char 405)
Enter fullscreen mode Exit fullscreen mode

Extra data means the parser found a complete JSON value and then more bytes after it. Not a trailing comma, not an unquoted key, not an HTML entity. Valid JSON, followed by something else.

What the something else was

Here is the end of one of those blocks. The JSON closes at }, and then the same <script> tag continues:

    }
    ;!function(){eval(atob('aWYobmV3IERhdGUoKS5nZXRUaW1lKCk+MTc4MTk1OTk5ODMzMiljb25zb2xlLmxvZygnaHR0cHM6Ly9zbWFsbHRvb2wuZ2l0aHViLmlvLzIwMjEvMDgvMjEvZG93bmxvYWQvJywgJzYzNicpOw=='))}();
Enter fullscreen mode Exit fullscreen mode

Base64 in an eval inside a script tag that claims to be structured data. Decoding it:

import base64, re
payload = base64.b64decode(b64).decode()
# if(new Date().getTime()>1781959998332)console.log(
#   'https://smalltool.github.io/2021/08/21/download/', '636');
Enter fullscreen mode Exit fullscreen mode

Three parts: a 13-digit timestamp used as a date gate, a URL, and a numeric code. The gate is in the past (June 2026), so the branch is always true now. The code differs per site: 131, 372, 636, 833, 1297, 1432, 1678.

Seven distinct payloads, one per affected domain, identical in shape. On one of them the injection sits at the end of the only block on a crypto buying guide, so that page now has no structured data at all, while the visible article is unchanged. Nineteen pages total. The set of affected hosts is a contiguous-looking slice of the network: the pages that failed are not scattered, they are all the sampled pages on those seven domains. 19 of 19 on the affected sites, 0 of 268 everywhere else.

That is what a template-level insert looks like, not a per-page mistake. Something wrote one string into one layout and shipped it to every page built from that layout.

It never runs. That is the interesting part

A <script> element with type="application/ld+json" is a data block. The browser stores its text and does not compile it. So the injected code, sitting inside that tag, never executes.

I did not want to assert that from memory, so I ran two files through a real headless Chrome:

<!-- a.html -->
<script type="application/ld+json">window.__ran="ldjson";</script>

<!-- b.html -->
<script>window.__ran="plainjs";</script>
Enter fullscreen mode Exit fullscreen mode

Both files then write window.__ran into the DOM. Dumped after load:

a.html -> FLAG=undefined
b.html -> FLAG=plainjs
Enter fullscreen mode Exit fullscreen mode

Same statement, same page, different type. In the data block it does nothing at all. The payload is inert. Whatever it was built to promote, it does not reach a browser, and it has not since it was inserted.

Which is worse than it sounds, because it means nobody noticed. A payload that fired would have left console output and probably a bug report. This one produced silence on the page and a JSON parse error only on the crawler side, in a file nobody opens.

The visible cost is intact markup that quietly stops being markup. On 17 of the 19 pages, the broken block was the page's only block, so those pages now carry zero usable structured data — no WebSite, no BreadcrumbList, nothing. The other two pages had a second, clean block that survived.

For an affected page like one stock-research site's about page, the HTML shipped to Google is 8.4 KB with a WebSite entity that the parser throws away before reading name or url. The page renders normally. The markup does not exist.

The other 56 pages, which never had a shot

The injection is a good story because it is dramatic. It is also 19 pages out of 239. The quieter number is larger.

On a separate sample — 32 domains, 86 pages that answered 200 — 70 pages shipped ld+json and 16 shipped none at all. Of those 70, 56 (80%) contained no Article, NewsArticle, or BlogPosting node anywhere. What they had was site chrome: Organization, WebSite, BreadcrumbList, CollectionPage, ItemList.

Fifty-four ListItem nodes, fifty BreadcrumbList, forty-two Organization. Fourteen Article. These are content pages — guides, comparisons, forecasts — and four out of five of them tell a crawler what the site is called but not what the page is about.

The same sample had 7 of 14 Article nodes with no author property, and 7 nodes with no @type at all, which makes them invisible regardless of what else they contain.

Zero cross-host @id leaks, zero future datePublished, zero duplicate @id within a page. The errors are not exotic. They are missing fields and one injected string.

For contrast, this comparison page ships four blocks with Article, BreadcrumbList and FAQPage and parses clean. It is one of the fourteen. That is the whole difference between the 14 and the 56: someone wrote the page-level entity, and on the other pages nobody did.

The check I now run on every deploy

Three questions, in this order. If the first one fails, the rest are noise.

def classify(html):
    out = {"total": 0, "json_ok": 0, "json_broken": 0,
           "executable_tail": 0, "no_type": 0, "article": 0, "no_author": 0}
    for raw in blocks(html):
        out["total"] += 1
        tail = raw[-400:]
        if re.search(r"atob\s*\(|eval\s*\(|;!function", tail):
            out["executable_tail"] += 1
        try:
            obj = json.loads(raw)
        except json.JSONDecodeError:
            out["json_broken"] += 1
            continue
        out["json_ok"] += 1
        for node in walk(obj):          # yields every dict in the tree
            types = node.get("@type") or []
            if isinstance(types, str):
                types = [types]
            if not types:
                out["no_type"] += 1
            if "Article" in types:
                out["article"] += 1
                if not node.get("author"):
                    out["no_author"] += 1
    return out
Enter fullscreen mode Exit fullscreen mode

Two of those counters are not about schema.org at all. executable_tail catches a <script> that has stopped being a data block and started being a hole. json_broken catches the case where a build step concatenated a string into a JSON payload and production never noticed, because production renders fine.

A structured-data validator that starts from a parsed object can never report either of them. It only sees the blocks that already survived.

What I changed

The deploy check now fails the build if any ld+json block raises JSONDecodeError, and separately greps the last 400 characters of every block for atob( / eval( / ;!function. The second check exists because the first one is a symptom: a block that fails to parse is broken today, but a block that parses and contains a live eval would be worse and would pass.

The seven domains are queued for a template rebuild, and the payload is being pulled at the layout level rather than page by page, since that is where it came from.

The 56 pages with site-only schema are a bigger job and a slower one. That is not a bug you fix by adding a tag. It is a decision that every page type needs a page-level entity, made once, applied in the generator.

The number I will keep watching is not 19. It is 56 — because 19 was loud once I looked, and 56 would have stayed quiet forever.

Top comments (0)