Back to scan results
Check 24 of 24

JSON Hijacking

A historical attack with a long tail. We discover JSON endpoints referenced from your homepage, sample a few, and check whether they return a top-level JavaScript array — the executable shape — without one of the standard "unhijackable" guards.

What this check probes

Two phases:

  1. Discovery — we look at your homepage HTML for hints at API endpoints: fetch( / axios / $.ajax / XMLHttpRequest calls in inline JS, paths in data-* attributes that look JSON-y, links to .json files.
  2. Sampling — for each candidate URL we send a GET (no cookies, no auth — public response only). If the response Content-Type is JSON-ish and the body parses as JSON, we examine the top-level shape.

We flag a finding when the response is a top-level JSON array ([...]) with no anti-hijack prefix:

  • No )]}',\n prefix (the Google convention).
  • No while(1); or for(;;); infinite-loop prefix (Facebook convention).
  • Top-level is [ rather than {.

An object response ({ "items": [...] }) is not vulnerable; a top-level array with one of the prefixes is also not vulnerable.

Why this matters for PCI DSS

The classic JSON hijacking attack worked like this in the late 2000s:

  1. Your authenticated user visits evil.com.
  2. evil.com includes <script src="https://your-bank.com/api/transactions.json"></script>.
  3. The browser fetches the URL with the user's cookies. The response is a top-level JS array.
  4. evil.com has overridden the global Array constructor (or the array index setter) to log every value, then sends them to the attacker.

Modern browsers fixed the Array-constructor trick around 2011, but new variants still appear (see the AngularJS-era variant, JSONP-injection variants, and SOP edge cases in the Securitum research). For an attacker investing time on a high-value target, "old" attack classes are very much alive.

PCI DSS 4.0 Requirement 6.2.4 covers cross-site data leakage broadly. The fix is cheap, so there's no reason not to apply it.

How to fix it

Pick any one — they all defeat JSON hijacking:

1. Wrap top-level arrays in an object. Smallest code change with the broadest compatibility:

// BAD
[{ "id": 1, "amount": 100 }, { "id": 2, "amount": 200 }]

// GOOD
{ "transactions": [{ "id": 1, "amount": 100 }, { "id": 2, "amount": 200 }] }

2. Prefix the response with an unparseable token that your client strips before JSON.parse. The Google convention:

)]}',
[{ "id": 1, "amount": 100 }]

Client side: JSON.parse(response.slice(5)).

This makes the response invalid JavaScript when loaded as a <script> (so the attack fails) but trivial to strip when loaded as JSON via fetch/XHR.

3. Require a custom header. Reject requests that don't carry X-Requested-With: XMLHttpRequest or a custom header your frontend sets. <script src> can't set custom headers, so the attack request gets a 400 instead of the data:

// Server (Express):
app.get('/api/transactions', (req, res) => {
    if (req.get('X-Requested-With') !== 'XMLHttpRequest')
        return res.status(403).end();
    res.json(transactions);
});

4. Require POST instead of GET for any data fetch. <script src> only issues GET, so a POST-only endpoint is immune.

5. SameSite cookies (covered in Check 14) — SameSite=Lax or Strict stop authentication cookies from being sent on the cross-origin <script src> request. The attack's data, if it leaks, is unauthenticated — usually harmless. This is the cheapest defense and a good belt-and-suspenders measure even if you also do (1) or (2).

The combination of "wrap arrays in objects" plus "SameSite=Lax cookies" eliminates JSON hijacking as a practical concern with essentially zero engineering effort.

Fixed it? Re-run the scan to confirm.

Run scan again