The Exploit
Attacker needs a valid Hoppscotch self-hosted session with access to the collection sync API.
curl -i -X POST "https://TARGET/graphql" \
-H "Content-Type: application/json" \
-H "Cookie: session=YOUR_SESSION_COOKIE" \
-d '{
"query":"mutation SyncCollection($collection: CollectionSyncInput!) { syncCollection(collection: $collection) { id name auth headers variables { key currentValue secret } } }",
"variables":{
"collection":{
"name":"leaky-collection",
"auth":{"type":"bearer","token":"ignored"},
"headers":[],
"variables":[
{"key":"API_KEY","currentValue":"secret123","secret":true}
]
}
}
}'
The response returns the synced collection payload including variables[0].currentValue: "secret123" even though that variable is marked secret: true. This proves secret values are transmitted in cleartext through the collection sync path.
What the Patch Did
Before:
import { getSyncInitFunction, type StoreSyncDefinitionOf } from "@app/lib/sync"
...
headers: collection.headers ?? [],
variables: collection.variables ?? [],
...
headers: collection.headers ?? [],
variables: collection.variables ?? [],
...
headers: collection.headers ?? [],
variables: collection.variables ?? [],
...
const data = {
auth: collection.auth,
headers: collection.headers,
variables: collection.variables,
...
const data = {
auth: folder.auth,
headers: folder.headers,
variables: folder.variables,
...
After:
import { stripSecretVariableValuesForWire } from "@hoppscotch/common/helpers/secretVariables"
import { getSyncInitFunction, type StoreSyncDefinitionOf } from "@app/lib/sync"
...
headers: collection.headers ?? [],
variables: stripSecretVariableValuesForWire(collection.variables ?? []),
...
headers: collection.headers ?? [],
variables: stripSecretVariableValuesForWire(collection.variables ?? []),
...
headers: collection.headers ?? [],
variables: stripSecretVariableValuesForWire(collection.variables ?? []),
...
const data = {
auth: collection.auth,
headers: collection.headers,
variables: stripSecretVariableValuesForWire(collection.variables ?? []),
...
const data = {
auth: folder.auth,
headers: folder.headers,
variables: stripSecretVariableValuesForWire(folder.variables ?? []),
...
The patch inserted a redaction step before any collection or folder variables array is sent over the wire. It changes the export/sync payload from raw collection.variables to a sanitized form that removes secret values.
Root Cause
This is a sensitive data exposure bug (CWE-200). In gqlCollections.sync.ts, the sync logic took collection.variables and folder.variables directly from stored collection/folder state and embedded them in the outbound sync payload. Those arrays were transmitted across the client-to-sync-server trust boundary without stripping secret values from currentValue, so secret variables marked secret: true leaked in the sync data.
Why It Works
The single load-bearing line is the redaction call:
variables: stripSecretVariableValuesForWire(collection.variables ?? []),
Without that line, the sync payload would still include the raw collection.variables array and the bug would still be exploitable. The repeated fixes for every collection/folder sync path are there because the same unsafe serialization appeared in multiple payload constructors; if any one path were left unchanged, secrets could still leak through that channel.
Hardening Checklist
- Sanitize secret variable arrays before exporting or syncing them, e.g. with a helper like
stripSecretVariableValuesForWire(). - Do not expose
currentValuefor items wheresecret: truein GraphQL/REST responses. - Preserve secret semantics on imports by normalizing values such as
variable.type === "secret" || variable.secret === true. - Add regression tests for collection sync serialization so secret variables are verified redacted on every wire-bound payload.
- Audit every path that serializes
collection.variablesorfolder.variablesfor network transmission, not just UI rendering code.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-50160