Skip to content

Commit cceadf2

Browse files
authored
Merge pull request #1676 from AstrBotDevs/fix-chat-get-file-bug
Fix: fixed a potential vulnerability in `/api/chat/get_file` endpoint.
2 parents 2fc0ec0 + cf5a4af commit cceadf2

File tree

3 files changed

+116
-63
lines changed

3 files changed

+116
-63
lines changed

astrbot/dashboard/routes/chat.py

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -61,16 +61,25 @@ async def get_file(self):
6161
return Response().error("Missing key: filename").__dict__
6262

6363
try:
64-
with open(os.path.join(self.imgs_dir, filename), "rb") as f:
65-
if filename.endswith(".wav"):
64+
file_path = os.path.join(self.imgs_dir, os.path.basename(filename))
65+
real_file_path = os.path.realpath(file_path)
66+
real_imgs_dir = os.path.realpath(self.imgs_dir)
67+
68+
if not real_file_path.startswith(real_imgs_dir):
69+
return Response().error("Invalid file path").__dict__
70+
71+
with open(real_file_path, "rb") as f:
72+
filename_ext = os.path.splitext(filename)[1].lower()
73+
74+
if filename_ext == ".wav":
6675
return QuartResponse(f.read(), mimetype="audio/wav")
67-
elif filename.split(".")[-1] in self.supported_imgs:
76+
elif filename_ext[1:] in self.supported_imgs:
6877
return QuartResponse(f.read(), mimetype="image/jpeg")
6978
else:
7079
return QuartResponse(f.read())
7180

72-
except FileNotFoundError:
73-
return Response().error("File not found").__dict__
81+
except (FileNotFoundError, OSError):
82+
return Response().error("File access error").__dict__
7483

7584
async def post_image(self):
7685
post_data = await request.files
@@ -126,17 +135,15 @@ async def chat(self):
126135

127136
self.curr_user_cid[username] = conversation_id
128137

129-
await web_chat_queue.put(
130-
(
131-
username,
132-
conversation_id,
133-
{
134-
"message": message,
135-
"image_url": image_url, # list
136-
"audio_url": audio_url,
137-
},
138-
)
139-
)
138+
await web_chat_queue.put((
139+
username,
140+
conversation_id,
141+
{
142+
"message": message,
143+
"image_url": image_url, # list
144+
"audio_url": audio_url,
145+
},
146+
))
140147

141148
# 持久化
142149
conversation = self.db.get_conversation_by_user_id(username, conversation_id)

astrbot/dashboard/server.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,13 @@ async def srv_plug_route(self, subpath, *args, **kwargs):
7070
for api in registered_web_apis:
7171
route, view_handler, methods, _ = api
7272
if route == f"/{subpath}" and request.method in methods:
73-
return await view_handler(*args, **kwargs)
73+
return await view_handler(*args, **kwargs)
7474
return jsonify(Response().error("未找到该路由").__dict__)
7575

7676
async def auth_middleware(self):
7777
if not request.path.startswith("/api"):
7878
return
79-
allowed_endpoints = ["/api/auth/login", "/api/chat/get_file", "/api/file"]
79+
allowed_endpoints = ["/api/auth/login", "/api/file"]
8080
if any(request.path.startswith(prefix) for prefix in allowed_endpoints):
8181
return
8282
# claim jwt

dashboard/src/views/ChatPage.vue

Lines changed: 91 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ marked.setOptions({
168168
<template v-slot:activator="{ props }">
169169
<v-btn v-bind="props" @click="sendMessage" class="send-btn" icon="mdi-send"
170170
variant="text" color="deep-purple"
171-
:disabled="!prompt && stagedImagesUrl.length === 0 && !stagedAudioUrl" />
171+
:disabled="!prompt && stagedImagesName.length === 0 && !stagedAudioUrl" />
172172
</template>
173173
</v-tooltip>
174174

@@ -218,7 +218,8 @@ export default {
218218
messages: [],
219219
conversations: [],
220220
currCid: '',
221-
stagedImagesUrl: [],
221+
stagedImagesName: [], // 用于存储图片**文件名**的数组
222+
stagedImagesUrl: [], // 用于存储图片的blob URL数组
222223
loadingChat: false,
223224
224225
inputFieldLabel: '聊天吧!',
@@ -236,7 +237,9 @@ export default {
236237
// Ctrl键长按相关变量
237238
ctrlKeyDown: false,
238239
ctrlKeyTimer: null,
239-
ctrlKeyLongPressThreshold: 300 // 长按阈值,单位毫秒
240+
ctrlKeyLongPressThreshold: 300, // 长按阈值,单位毫秒
241+
242+
mediaCache: {}, // Add a cache to store media blobs
240243
}
241244
},
242245
@@ -265,9 +268,31 @@ export default {
265268
266269
// 移除keyup事件监听
267270
document.removeEventListener('keyup', this.handleInputKeyUp);
271+
272+
// Cleanup blob URLs
273+
this.cleanupMediaCache();
268274
},
269275
270276
methods: {
277+
async getMediaFile(filename) {
278+
if (this.mediaCache[filename]) {
279+
return this.mediaCache[filename];
280+
}
281+
282+
try {
283+
const response = await axios.get('/api/chat/get_file', {
284+
params: { filename },
285+
responseType: 'blob'
286+
});
287+
288+
const blobUrl = URL.createObjectURL(response.data);
289+
this.mediaCache[filename] = blobUrl;
290+
return blobUrl;
291+
} catch (error) {
292+
console.error('Error fetching media file:', error);
293+
return '';
294+
}
295+
},
271296
272297
async startListeningEvent() {
273298
const response = await fetch('/api/chat/listen', {
@@ -328,17 +353,19 @@ export default {
328353
329354
if (chunk_json.type === 'image') {
330355
let img = chunk_json.data.replace('[IMAGE]', '');
356+
const imageUrl = await this.getMediaFile(img);
331357
let bot_resp = {
332358
type: 'bot',
333-
message: `<img src="/api/chat/get_file?filename=${img}" style="max-width: 80%; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);"/>`
359+
message: `<img src="${imageUrl}" style="max-width: 80%; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);"/>`
334360
}
335361
this.messages.push(bot_resp);
336362
} else if (chunk_json.type === 'record') {
337363
let audio = chunk_json.data.replace('[RECORD]', '');
364+
const audioUrl = await this.getMediaFile(audio);
338365
let bot_resp = {
339366
type: 'bot',
340367
message: `<audio controls class="audio-player">
341-
<source src="/api/chat/get_file?filename=${audio}" type="audio/wav">
368+
<source src="${audioUrl}" type="audio/wav">
342369
您的浏览器不支持音频播放。
343370
</audio>`
344371
}
@@ -403,15 +430,14 @@ export default {
403430
try {
404431
const response = await axios.post('/api/chat/post_file', formData, {
405432
headers: {
406-
'Content-Type': 'multipart/form-data',
407-
'Authorization': 'Bearer ' + localStorage.getItem('token')
433+
'Content-Type': 'multipart/form-data'
408434
}
409435
});
410436
411437
const audio = response.data.data.filename;
412438
console.log('Audio uploaded:', audio);
413439
414-
this.stagedAudioUrl = `/api/chat/get_file?filename=${audio}`;
440+
this.stagedAudioUrl = audio; // Store just the filename
415441
} catch (err) {
416442
console.error('Error uploading audio:', err);
417443
}
@@ -430,13 +456,13 @@ export default {
430456
try {
431457
const response = await axios.post('/api/chat/post_image', formData, {
432458
headers: {
433-
'Content-Type': 'multipart/form-data',
434-
'Authorization': 'Bearer ' + localStorage.getItem('token')
459+
'Content-Type': 'multipart/form-data'
435460
}
436461
});
437462
438463
const img = response.data.data.filename;
439-
this.stagedImagesUrl.push(`/api/chat/get_file?filename=${img}`);
464+
this.stagedImagesName.push(img); // Store just the filename
465+
this.stagedImagesUrl.push(URL.createObjectURL(file)); // Create a blob URL for immediate display
440466
441467
} catch (err) {
442468
console.error('Error uploading image:', err);
@@ -446,6 +472,7 @@ export default {
446472
},
447473
448474
removeImage(index) {
475+
this.stagedImagesName.splice(index, 1);
449476
this.stagedImagesUrl.splice(index, 1);
450477
},
451478
@@ -462,28 +489,30 @@ export default {
462489
getConversationMessages(cid) {
463490
if (!cid[0])
464491
return;
465-
axios.get('/api/chat/get_conversation?conversation_id=' + cid[0]).then(response => {
492+
axios.get('/api/chat/get_conversation?conversation_id=' + cid[0]).then(async response => {
466493
this.currCid = cid[0];
467494
let message = JSON.parse(response.data.data.history);
468495
for (let i = 0; i < message.length; i++) {
469496
if (message[i].message.startsWith('[IMAGE]')) {
470497
let img = message[i].message.replace('[IMAGE]', '');
471-
message[i].message = `<img src="/api/chat/get_file?filename=${img}" style="max-width: 80%; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);"/>`
498+
const imageUrl = await this.getMediaFile(img);
499+
message[i].message = `<img src="${imageUrl}" style="max-width: 80%; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);"/>`
472500
}
473501
if (message[i].message.startsWith('[RECORD]')) {
474502
let audio = message[i].message.replace('[RECORD]', '');
503+
const audioUrl = await this.getMediaFile(audio);
475504
message[i].message = `<audio controls class="audio-player">
476-
<source src="/api/chat/get_file?filename=${audio}" type="audio/wav">
505+
<source src="${audioUrl}" type="audio/wav">
477506
您的浏览器不支持音频播放。
478507
</audio>`
479508
}
480509
if (message[i].image_url && message[i].image_url.length > 0) {
481510
for (let j = 0; j < message[i].image_url.length; j++) {
482-
message[i].image_url[j] = `/api/chat/get_file?filename=${message[i].image_url[j]}`;
511+
message[i].image_url[j] = await this.getMediaFile(message[i].image_url[j]);
483512
}
484513
}
485514
if (message[i].audio_url) {
486-
message[i].audio_url = `/api/chat/get_file?filename=${message[i].audio_url}`;
515+
message[i].audio_url = await this.getMediaFile(message[i].audio_url);
487516
}
488517
}
489518
this.messages = message;
@@ -534,31 +563,40 @@ export default {
534563
await this.newConversation();
535564
}
536565
537-
this.messages.push({
566+
// Create a message object with actual URLs for display
567+
const userMessage = {
538568
type: 'user',
539569
message: this.prompt,
540-
image_url: this.stagedImagesUrl,
541-
audio_url: this.stagedAudioUrl
542-
});
543-
544-
this.scrollToBottom();
570+
image_url: [],
571+
audio_url: null
572+
};
545573
546-
// images
547-
let image_filenames = [];
548-
for (let i = 0; i < this.stagedImagesUrl.length; i++) {
549-
let img = this.stagedImagesUrl[i].replace('/api/chat/get_file?filename=', '');
550-
image_filenames.push(img);
574+
// Convert image filenames to blob URLs for display
575+
if (this.stagedImagesName.length > 0) {
576+
for (let i = 0; i < this.stagedImagesName.length; i++) {
577+
// If it's just a filename, get the blob URL
578+
if (!this.stagedImagesName[i].startsWith('blob:')) {
579+
const imgUrl = await this.getMediaFile(this.stagedImagesName[i]);
580+
userMessage.image_url.push(imgUrl);
581+
} else {
582+
userMessage.image_url.push(this.stagedImagesName[i]);
583+
}
584+
}
551585
}
552586
553-
// audio
554-
let audio_filenames = [];
587+
// Convert audio filename to blob URL for display
555588
if (this.stagedAudioUrl) {
556-
let audio = this.stagedAudioUrl.replace('/api/chat/get_file?filename=', '');
557-
audio_filenames.push(audio);
589+
if (!this.stagedAudioUrl.startsWith('blob:')) {
590+
userMessage.audio_url = await this.getMediaFile(this.stagedAudioUrl);
591+
} else {
592+
userMessage.audio_url = this.stagedAudioUrl;
593+
}
558594
}
559595
560-
this.loadingChat = true;
596+
this.messages.push(userMessage);
597+
this.scrollToBottom();
561598
599+
this.loadingChat = true;
562600
563601
fetch('/api/chat/send', {
564602
method: 'POST',
@@ -569,20 +607,19 @@ export default {
569607
body: JSON.stringify({
570608
message: this.prompt,
571609
conversation_id: this.currCid,
572-
image_url: image_filenames,
573-
audio_url: audio_filenames
574-
}) // 发送请求体
575-
})
576-
.then(response => {
577-
this.prompt = '';
578-
this.stagedImagesUrl = [];
579-
this.stagedAudioUrl = "";
580-
581-
this.loadingChat = false;
610+
image_url: this.stagedImagesName, // Already contains just filenames
611+
audio_url: this.stagedAudioUrl ? [this.stagedAudioUrl] : [] // Already contains just filename
582612
})
583-
.catch(err => {
584-
console.error(err);
585-
});
613+
})
614+
.then(response => {
615+
this.prompt = '';
616+
this.stagedImagesName = [];
617+
this.stagedAudioUrl = "";
618+
this.loadingChat = false;
619+
})
620+
.catch(err => {
621+
console.error(err);
622+
});
586623
},
587624
scrollToBottom() {
588625
this.$nextTick(() => {
@@ -623,6 +660,15 @@ export default {
623660
}
624661
}
625662
},
663+
664+
cleanupMediaCache() {
665+
Object.values(this.mediaCache).forEach(url => {
666+
if (url.startsWith('blob:')) {
667+
URL.revokeObjectURL(url);
668+
}
669+
});
670+
this.mediaCache = {};
671+
},
626672
},
627673
}
628674
</script>

0 commit comments

Comments
 (0)