CVE-2026-79483: FastGPT NoSQL Injection — Unauthenticated Access to Chat History Titles of All Users Across the Platform
Vulnerability Overview
Here is a brief introduction to the vulnerability
The affected product is https://github.com/labring/FastGPT, which has 29.5K stars as of now. The affected version range is >= 4.10.0 and <= 4.14.0. The POST /api/core/chat/getHistories endpoint contains a NoSQL injection vulnerability. An unauthenticated attacker can inject NoSQL operators ($ne, $regex, $gt, etc.) via a crafted JSON payload to bypass the outLink permission check, thereby gaining unauthorized access to the chat history titles of all users across the entire platform
To be honest, this was not originally found through white-box code auditing. At the very beginning, it was actually discovered through black-box vulnerability hunting, and the code audit only started after I noticed the target was running FastGPT
The following content mainly covers discovering the vulnerability through code auditing, comparing old and new versions, and real-world cases
Code Audit
The code audit uses FastGPT version 4.13.0 as an example
FastGPT-4.13.0/ ├── packages/# Shared libraries │ ├── global/# Global types and constants │ ├── service/# Backend service layer │ │ ├── support/permission/# ← Permission authentication logic (key audit area) │ │ │ ├── publish/authLink.ts# ← outLink existence validation │ │ │ └── ... │ │ └── core/chat/# ← Chat data models │ └── ... ├── projects/ │ └── app/# Main Next.js application │ └── src/ │ ├── pages/api/# ← Next.js API routes (entry layer) │ │ └── core/chat/ │ │ └── getHistories.ts # ← The endpoint we are auditing │ └── service/ │ └── support/ │ └── permission/ │ └── auth/ │ └── outLink.ts # ← Authentication logic (root cause of the vulnerability) └── ...
Audit Tip ①: In a Next.js project, every file under the pages/api/ directory is an API endpoint. The file path is the route path. This is your audit entry checklist
In a Mongoose application, the data flow is typically:
In the code before const { offset, pageSize } = parsePaginationRequest(req);, several rules are defined: URL parameter rules, request body rules, response data rules, handler rules, and data extraction rules. The line const { offset, pageSize } = parsePaginationRequest(req); just parses the two parameters offset and pageSize, nothing worth dwelling on
If shareId and outLinkUid exist, the authOutLink function is executed and returns uid, and then the value of outLinkUid in the match object is replaced with the uid just obtained
Having audited up to here, some problems become quite obvious
Problem 1: req.body is destructured directly with no Zod/schema validation, so the user can pass arbitrary JSON types (objects, arrays, etc.)
Problem 2: shareId is written directly into the match object. If shareId is {"$ne": ""}, MongoDB will interpret it as an operator
Problem 3: outLinkUid: uid — uid comes from authOutLink(), so we need to trace whether the uid returned by this function is safe
So we start auditing the authOutLink function
Tracing the Authentication Chain: authOutLink
authOutLink is called to validate shareId and outLinkUid. Open FastGPT-4.13.0/projects/app/src/service/support/permission/auth/outLink.ts:
defines that the incoming shareId, outLinkUid have the data type ShareChatAuthProps, and the return type is a Promise
Then we go straight to the handling logic for the existence of outLinkUid
const result = await authOutLinkValid({ shareId }); — this line clearly shows that shareId is passed to the authOutLinkValid function for processing, and after processing we get the result variable
The authOutLinkInit function is called with outLinkUid (the outLink credential provided by the user) and hookUrl (the validation API address), and uid (the real user ID) is extracted from the returned result
So for this piece of code, are there two validations — one is authOutLinkValid and the other is authOutLinkInit? Their validations are closely intertwined: authOutLinkValid takes shareId and outputs result, while authOutLinkInit takes outLinkUid and values related to result and outputs the final uid we need
Having audited up to here, several more problems were found
Problem 1: The emptiness check on outLinkUid, if (!outLinkUid) — if the input is an object, the truthy check can be bypassed
Problem 2: We need to specifically audit the handling logic of authOutLinkValid and authOutLinkInit next
Tracing the Authentication Chain: authOutLinkValid
This code is very straightforward: in const outLinkConfig = await MongoOutLink.findOne({ shareId }).lean<OutLinkSchema<T>>();, the precondition that shareId must not be empty can certainly be satisfied, and after that findOne is executed to query the database. The critical point is that there is no restriction on shareId, which allows an attacker to directly construct MongoDB operators
Finally, it returns an object containing two things:
appId: the application ID extracted from outLinkConfig (a string)
outLinkConfig: the complete share link configuration object (containing all fields)
exportfunctionauthOutLinkInit(data: AuthOutLinkInitProps): Promise<AuthOutLinkResponse> { // 🔴 Vulnerable code! if (!global.feConfigs?.isPlus) returnPromise.resolve({ uid: data.outLinkUid }); // ^^^^^^^^^^^^^^^^ // data.outLinkUid is exactly what the attacker passed in: // the {"$ne": ""} object, returned as-is!
// Only the commercial edition goes through remote authentication returnPOST<AuthOutLinkResponse>('/support/outLink/authInit', data); }
In the community edition, return Promise.resolve({ uid: data.outLinkUid }) directly puts data.outLinkUid as-is into the uid field and returns it — no type validation, no length check, no character filtering whatsoever. Since outLinkUid is directly controlled by the user, this results in NoSQL injection
1 2 3 4 5 6 7 8 9 10 11
Attacker sends: { "outLinkUid": {"$ne": ""} } ↓ authOutLinkInit receives: data.outLinkUid = {"$ne": ""} (a MongoDB operatorobject) ↓ Community edition returns directly: { uid: {"$ne": ""} } (no filtering at all!) ↓ getHistories.ts receives: uid = {"$ne": ""} ↓ MongoChat.find({ outLinkUid: {"$ne": ""} }) ↓ MongoDB interprets: match all documents where outLinkUid ≠ "" = all chat records across the platform
Finally, back to gethistories.ts
Building the match object
match = {shareId: {"$ne":""},outLinkUid: {"$ne":""},updateTime: { $gte: ... }}
Finally the query is executed based on match
And it returns the chat data of all users across the entire platform
Audit Summary
Step 1: The attacker sends a malicious request
text
1 2 3 4 5
POST /api/core/chat/getHistories { "shareId": {"$ne":""}, "outLinkUid": {"$ne":""} }
At this point: shareId = {"$ne":""} (an object, not a string) outLinkUid = {"$ne":""} (an object, not a string)
Step 2: getHistories.ts destructures the parameters
MongoOutLink.findOne({ shareId: {"$ne":""} }) → MongoDB interprets: find any outLink recordwith shareId ≠ "" → As long as the platform has anyshare link, this query returns a result → ✅ Check passed