From 900f14d37cc026d5c9a2eadd71eeaf3ca5457642 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 29 May 2025 19:17:31 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=90=9B=20fix:=20fixed=20a=20potential?= =?UTF-8?q?=20vulnerability=20in=20/api/chat/get=5Ffile=20endpoint.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I have fixed a potential vulnerability in the `/api/chat/get_file` endpoint that could allow unauthorized access to files by ensuring the request has a jwt token. --- astrbot/dashboard/routes/chat.py | 18 +++- astrbot/dashboard/server.py | 4 +- dashboard/src/views/ChatPage.vue | 136 +++++++++++++++++++++---------- 3 files changed, 109 insertions(+), 49 deletions(-) diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py index 17e8b115..b61a60f4 100644 --- a/astrbot/dashboard/routes/chat.py +++ b/astrbot/dashboard/routes/chat.py @@ -60,11 +60,23 @@ class ChatRoute(Route): if not filename: return Response().error("Missing key: filename").__dict__ + # Prevent path traversal attacks by extracting just the basename + filename_ = os.path.basename(filename) + + # Check if the filename contains suspicious patterns + if ( + filename_ != filename + or ".." in filename + or "/" in filename + or "\\" in filename + ): + return Response().error("Invalid filename").__dict__ + try: - with open(os.path.join(self.imgs_dir, filename), "rb") as f: - if filename.endswith(".wav"): + with open(os.path.join(self.imgs_dir, filename_), "rb") as f: + if filename_.endswith(".wav"): return QuartResponse(f.read(), mimetype="audio/wav") - elif filename.split(".")[-1] in self.supported_imgs: + elif filename_.split(".")[-1] in self.supported_imgs: return QuartResponse(f.read(), mimetype="image/jpeg") else: return QuartResponse(f.read()) diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index d8c1a1dd..acdc8a49 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -70,13 +70,13 @@ class AstrBotDashboard: for api in registered_web_apis: route, view_handler, methods, _ = api if route == f"/{subpath}" and request.method in methods: - return await view_handler(*args, **kwargs) + return await view_handler(*args, **kwargs) return jsonify(Response().error("未找到该路由").__dict__) async def auth_middleware(self): if not request.path.startswith("/api"): return - allowed_endpoints = ["/api/auth/login", "/api/chat/get_file", "/api/file"] + allowed_endpoints = ["/api/auth/login", "/api/file"] if any(request.path.startswith(prefix) for prefix in allowed_endpoints): return # claim jwt diff --git a/dashboard/src/views/ChatPage.vue b/dashboard/src/views/ChatPage.vue index ad149bcd..da803010 100644 --- a/dashboard/src/views/ChatPage.vue +++ b/dashboard/src/views/ChatPage.vue @@ -168,7 +168,7 @@ marked.setOptions({ @@ -218,7 +218,8 @@ export default { messages: [], conversations: [], currCid: '', - stagedImagesUrl: [], + stagedImagesName: [], // 用于存储图片**文件名**的数组 + stagedImagesUrl: [], // 用于存储图片的blob URL数组 loadingChat: false, inputFieldLabel: '聊天吧!', @@ -236,7 +237,9 @@ export default { // Ctrl键长按相关变量 ctrlKeyDown: false, ctrlKeyTimer: null, - ctrlKeyLongPressThreshold: 300 // 长按阈值,单位毫秒 + ctrlKeyLongPressThreshold: 300, // 长按阈值,单位毫秒 + + mediaCache: {}, // Add a cache to store media blobs } }, @@ -265,9 +268,31 @@ export default { // 移除keyup事件监听 document.removeEventListener('keyup', this.handleInputKeyUp); + + // Cleanup blob URLs + this.cleanupMediaCache(); }, methods: { + async getMediaFile(filename) { + if (this.mediaCache[filename]) { + return this.mediaCache[filename]; + } + + try { + const response = await axios.get('/api/chat/get_file', { + params: { filename }, + responseType: 'blob' + }); + + const blobUrl = URL.createObjectURL(response.data); + this.mediaCache[filename] = blobUrl; + return blobUrl; + } catch (error) { + console.error('Error fetching media file:', error); + return ''; + } + }, async startListeningEvent() { const response = await fetch('/api/chat/listen', { @@ -328,17 +353,19 @@ export default { if (chunk_json.type === 'image') { let img = chunk_json.data.replace('[IMAGE]', ''); + const imageUrl = await this.getMediaFile(img); let bot_resp = { type: 'bot', - message: `` + message: `` } this.messages.push(bot_resp); } else if (chunk_json.type === 'record') { let audio = chunk_json.data.replace('[RECORD]', ''); + const audioUrl = await this.getMediaFile(audio); let bot_resp = { type: 'bot', message: `` } @@ -411,7 +438,7 @@ export default { const audio = response.data.data.filename; console.log('Audio uploaded:', audio); - this.stagedAudioUrl = `/api/chat/get_file?filename=${audio}`; + this.stagedAudioUrl = audio; // Store just the filename } catch (err) { console.error('Error uploading audio:', err); } @@ -436,7 +463,8 @@ export default { }); const img = response.data.data.filename; - this.stagedImagesUrl.push(`/api/chat/get_file?filename=${img}`); + this.stagedImagesName.push(img); // Store just the filename + this.stagedImagesUrl.push(URL.createObjectURL(file)); // Create a blob URL for immediate display } catch (err) { console.error('Error uploading image:', err); @@ -446,6 +474,7 @@ export default { }, removeImage(index) { + this.stagedImagesName.splice(index, 1); this.stagedImagesUrl.splice(index, 1); }, @@ -462,28 +491,30 @@ export default { getConversationMessages(cid) { if (!cid[0]) return; - axios.get('/api/chat/get_conversation?conversation_id=' + cid[0]).then(response => { + axios.get('/api/chat/get_conversation?conversation_id=' + cid[0]).then(async response => { this.currCid = cid[0]; let message = JSON.parse(response.data.data.history); for (let i = 0; i < message.length; i++) { if (message[i].message.startsWith('[IMAGE]')) { let img = message[i].message.replace('[IMAGE]', ''); - message[i].message = `` + const imageUrl = await this.getMediaFile(img); + message[i].message = `` } if (message[i].message.startsWith('[RECORD]')) { let audio = message[i].message.replace('[RECORD]', ''); + const audioUrl = await this.getMediaFile(audio); message[i].message = `` } if (message[i].image_url && message[i].image_url.length > 0) { for (let j = 0; j < message[i].image_url.length; j++) { - message[i].image_url[j] = `/api/chat/get_file?filename=${message[i].image_url[j]}`; + message[i].image_url[j] = await this.getMediaFile(message[i].image_url[j]); } } if (message[i].audio_url) { - message[i].audio_url = `/api/chat/get_file?filename=${message[i].audio_url}`; + message[i].audio_url = await this.getMediaFile(message[i].audio_url); } } this.messages = message; @@ -534,32 +565,41 @@ export default { await this.newConversation(); } - this.messages.push({ + // Create a message object with actual URLs for display + const userMessage = { type: 'user', message: this.prompt, - image_url: this.stagedImagesUrl, - audio_url: this.stagedAudioUrl - }); + image_url: [], + audio_url: null + }; + // Convert image filenames to blob URLs for display + if (this.stagedImagesName.length > 0) { + for (let i = 0; i < this.stagedImagesName.length; i++) { + // If it's just a filename, get the blob URL + if (!this.stagedImagesName[i].startsWith('blob:')) { + const imgUrl = await this.getMediaFile(this.stagedImagesName[i]); + userMessage.image_url.push(imgUrl); + } else { + userMessage.image_url.push(this.stagedImagesName[i]); + } + } + } + + // Convert audio filename to blob URL for display + if (this.stagedAudioUrl) { + if (!this.stagedAudioUrl.startsWith('blob:')) { + userMessage.audio_url = await this.getMediaFile(this.stagedAudioUrl); + } else { + userMessage.audio_url = this.stagedAudioUrl; + } + } + + this.messages.push(userMessage); this.scrollToBottom(); - // images - let image_filenames = []; - for (let i = 0; i < this.stagedImagesUrl.length; i++) { - let img = this.stagedImagesUrl[i].replace('/api/chat/get_file?filename=', ''); - image_filenames.push(img); - } - - // audio - let audio_filenames = []; - if (this.stagedAudioUrl) { - let audio = this.stagedAudioUrl.replace('/api/chat/get_file?filename=', ''); - audio_filenames.push(audio); - } - this.loadingChat = true; - fetch('/api/chat/send', { method: 'POST', headers: { @@ -569,20 +609,19 @@ export default { body: JSON.stringify({ message: this.prompt, conversation_id: this.currCid, - image_url: image_filenames, - audio_url: audio_filenames - }) // 发送请求体 - }) - .then(response => { - this.prompt = ''; - this.stagedImagesUrl = []; - this.stagedAudioUrl = ""; - - this.loadingChat = false; + image_url: this.stagedImagesName, // Already contains just filenames + audio_url: this.stagedAudioUrl ? [this.stagedAudioUrl] : [] // Already contains just filename }) - .catch(err => { - console.error(err); - }); + }) + .then(response => { + this.prompt = ''; + this.stagedImagesName = []; + this.stagedAudioUrl = ""; + this.loadingChat = false; + }) + .catch(err => { + console.error(err); + }); }, scrollToBottom() { this.$nextTick(() => { @@ -623,6 +662,15 @@ export default { } } }, + + cleanupMediaCache() { + Object.values(this.mediaCache).forEach(url => { + if (url.startsWith('blob:')) { + URL.revokeObjectURL(url); + } + }); + this.mediaCache = {}; + }, }, } From c2f12277003c326fa72288193e8869de4a332738 Mon Sep 17 00:00:00 2001 From: Raven95676 Date: Thu, 29 May 2025 19:57:11 +0800 Subject: [PATCH 2/4] fix: add authorization header to file download request in ChatPage.vue --- dashboard/src/views/ChatPage.vue | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dashboard/src/views/ChatPage.vue b/dashboard/src/views/ChatPage.vue index da803010..d260c076 100644 --- a/dashboard/src/views/ChatPage.vue +++ b/dashboard/src/views/ChatPage.vue @@ -282,7 +282,10 @@ export default { try { const response = await axios.get('/api/chat/get_file', { params: { filename }, - responseType: 'blob' + responseType: 'blob', + headers: { + 'Authorization': 'Bearer ' + localStorage.getItem('token') + } }); const blobUrl = URL.createObjectURL(response.data); From 39aea11c22f6ae9b5b72d44f7f0f817db3551802 Mon Sep 17 00:00:00 2001 From: Raven95676 Date: Thu, 29 May 2025 21:03:51 +0800 Subject: [PATCH 3/4] perf: enhance file access security in get_file method Co-authored-by: anka-afk <1350989414@qq.com> --- astrbot/dashboard/routes/chat.py | 51 ++++++++++++++------------------ 1 file changed, 23 insertions(+), 28 deletions(-) diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py index b61a60f4..95ac98da 100644 --- a/astrbot/dashboard/routes/chat.py +++ b/astrbot/dashboard/routes/chat.py @@ -60,29 +60,26 @@ class ChatRoute(Route): if not filename: return Response().error("Missing key: filename").__dict__ - # Prevent path traversal attacks by extracting just the basename - filename_ = os.path.basename(filename) - - # Check if the filename contains suspicious patterns - if ( - filename_ != filename - or ".." in filename - or "/" in filename - or "\\" in filename - ): - return Response().error("Invalid filename").__dict__ - try: - with open(os.path.join(self.imgs_dir, filename_), "rb") as f: - if filename_.endswith(".wav"): + file_path = os.path.join(self.imgs_dir, os.path.basename(filename)) + real_file_path = os.path.realpath(file_path) + real_imgs_dir = os.path.realpath(self.imgs_dir) + + if not real_file_path.startswith(real_imgs_dir): + return Response().error("Invalid file path").__dict__ + + with open(real_file_path, "rb") as f: + filename_ext = os.path.splitext(filename)[1].lower() + + if filename_ext == ".wav": return QuartResponse(f.read(), mimetype="audio/wav") - elif filename_.split(".")[-1] in self.supported_imgs: + elif filename_ext[1:] in self.supported_imgs: return QuartResponse(f.read(), mimetype="image/jpeg") else: return QuartResponse(f.read()) - except FileNotFoundError: - return Response().error("File not found").__dict__ + except (FileNotFoundError, OSError): + return Response().error("File access error").__dict__ async def post_image(self): post_data = await request.files @@ -138,17 +135,15 @@ class ChatRoute(Route): self.curr_user_cid[username] = conversation_id - await web_chat_queue.put( - ( - username, - conversation_id, - { - "message": message, - "image_url": image_url, # list - "audio_url": audio_url, - }, - ) - ) + await web_chat_queue.put(( + username, + conversation_id, + { + "message": message, + "image_url": image_url, # list + "audio_url": audio_url, + }, + )) # 持久化 conversation = self.db.get_conversation_by_user_id(username, conversation_id) From cf5a4af6236594e305a364e7d8965f8ac3807c6d Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 29 May 2025 21:19:39 +0800 Subject: [PATCH 4/4] chore: remove duplicated auth header --- dashboard/src/views/ChatPage.vue | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/dashboard/src/views/ChatPage.vue b/dashboard/src/views/ChatPage.vue index d260c076..7069a81f 100644 --- a/dashboard/src/views/ChatPage.vue +++ b/dashboard/src/views/ChatPage.vue @@ -282,10 +282,7 @@ export default { try { const response = await axios.get('/api/chat/get_file', { params: { filename }, - responseType: 'blob', - headers: { - 'Authorization': 'Bearer ' + localStorage.getItem('token') - } + responseType: 'blob' }); const blobUrl = URL.createObjectURL(response.data); @@ -433,8 +430,7 @@ export default { try { const response = await axios.post('/api/chat/post_file', formData, { headers: { - 'Content-Type': 'multipart/form-data', - 'Authorization': 'Bearer ' + localStorage.getItem('token') + 'Content-Type': 'multipart/form-data' } }); @@ -460,8 +456,7 @@ export default { try { const response = await axios.post('/api/chat/post_image', formData, { headers: { - 'Content-Type': 'multipart/form-data', - 'Authorization': 'Bearer ' + localStorage.getItem('token') + 'Content-Type': 'multipart/form-data' } });