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({
+ :disabled="!prompt && stagedImagesName.length === 0 && !stagedAudioUrl" />
@@ -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'
}
});