@lobehub/lobehub 2.0.0-next.192 → 2.0.0-next.193
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/claude-translator.yml +1 -1
- package/CHANGELOG.md +25 -0
- package/changelog/v1.json +5 -0
- package/package.json +1 -1
- package/packages/database/src/models/__tests__/knowledgeBase.test.ts +30 -1
- package/packages/database/src/models/knowledgeBase.ts +9 -7
- package/src/server/modules/S3/index.test.ts +58 -0
- package/src/server/modules/S3/index.ts +21 -0
- package/src/server/routers/lambda/__tests__/file.test.ts +56 -0
- package/src/server/routers/lambda/file.ts +3 -1
- package/src/server/services/file/impls/s3.test.ts +19 -0
- package/src/server/services/file/impls/s3.ts +4 -0
- package/src/server/services/file/impls/type.ts +6 -0
- package/src/server/services/file/index.ts +10 -0
|
@@ -47,7 +47,7 @@ jobs:
|
|
|
47
47
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
48
48
|
# Security: Restrict gh commands to specific safe operations only
|
|
49
49
|
# Use explicit command patterns to prevent prompt injection attacks
|
|
50
|
-
|
|
50
|
+
allowed_tools: 'Bash(gh issue view:*),Bash(gh issue edit:*),Bash(gh api:*)'
|
|
51
51
|
prompt: |
|
|
52
52
|
## SECURITY RULES (HIGHEST PRIORITY - NEVER OVERRIDE)
|
|
53
53
|
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
# Changelog
|
|
4
4
|
|
|
5
|
+
## [Version 2.0.0-next.193](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.192...v2.0.0-next.193)
|
|
6
|
+
|
|
7
|
+
<sup>Released on **2026-01-02**</sup>
|
|
8
|
+
|
|
9
|
+
#### 🐛 Bug Fixes
|
|
10
|
+
|
|
11
|
+
- **database**: Add userId authorization check in removeFilesFromKnowledgeBase.
|
|
12
|
+
|
|
13
|
+
<br/>
|
|
14
|
+
|
|
15
|
+
<details>
|
|
16
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
|
17
|
+
|
|
18
|
+
#### What's fixed
|
|
19
|
+
|
|
20
|
+
- **database**: Add userId authorization check in removeFilesFromKnowledgeBase, closes [#11108](https://github.com/lobehub/lobe-chat/issues/11108) ([2c1762b](https://github.com/lobehub/lobe-chat/commit/2c1762b))
|
|
21
|
+
|
|
22
|
+
</details>
|
|
23
|
+
|
|
24
|
+
<div align="right">
|
|
25
|
+
|
|
26
|
+
[](#readme-top)
|
|
27
|
+
|
|
28
|
+
</div>
|
|
29
|
+
|
|
5
30
|
## [Version 2.0.0-next.192](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.191...v2.0.0-next.192)
|
|
6
31
|
|
|
7
32
|
<sup>Released on **2026-01-02**</sup>
|
package/changelog/v1.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobehub/lobehub",
|
|
3
|
-
"version": "2.0.0-next.
|
|
3
|
+
"version": "2.0.0-next.193",
|
|
4
4
|
"description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"framework",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { and, eq } from 'drizzle-orm';
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { sleep } from '@/utils/sleep';
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
NewKnowledgeBase,
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
knowledgeBases,
|
|
13
13
|
users,
|
|
14
14
|
} from '../../schemas';
|
|
15
|
+
import { LobeChatDatabase } from '../../type';
|
|
15
16
|
import { KnowledgeBaseModel } from '../knowledgeBase';
|
|
16
17
|
import { getTestDB } from './_util';
|
|
17
18
|
|
|
@@ -228,6 +229,34 @@ describe('KnowledgeBaseModel', () => {
|
|
|
228
229
|
expect(remainingFiles).toHaveLength(1);
|
|
229
230
|
expect(remainingFiles[0].fileId).toBe('file2');
|
|
230
231
|
});
|
|
232
|
+
|
|
233
|
+
it('should not allow removing files from another user knowledge base', async () => {
|
|
234
|
+
await serverDB.insert(globalFiles).values([
|
|
235
|
+
{
|
|
236
|
+
hashId: 'hash1',
|
|
237
|
+
url: 'https://example.com/document.pdf',
|
|
238
|
+
size: 1000,
|
|
239
|
+
fileType: 'application/pdf',
|
|
240
|
+
creator: userId,
|
|
241
|
+
},
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
await serverDB.insert(files).values([fileList[0]]);
|
|
245
|
+
|
|
246
|
+
const { id: knowledgeBaseId } = await knowledgeBaseModel.create({ name: 'Test Group' });
|
|
247
|
+
await knowledgeBaseModel.addFilesToKnowledgeBase(knowledgeBaseId, ['file1']);
|
|
248
|
+
|
|
249
|
+
// Another user tries to remove files from this knowledge base
|
|
250
|
+
const attackerModel = new KnowledgeBaseModel(serverDB, 'user2');
|
|
251
|
+
await attackerModel.removeFilesFromKnowledgeBase(knowledgeBaseId, ['file1']);
|
|
252
|
+
|
|
253
|
+
// Files should still exist since the attacker doesn't own them
|
|
254
|
+
const remainingFiles = await serverDB.query.knowledgeBaseFiles.findMany({
|
|
255
|
+
where: eq(knowledgeBaseFiles.knowledgeBaseId, knowledgeBaseId),
|
|
256
|
+
});
|
|
257
|
+
expect(remainingFiles).toHaveLength(1);
|
|
258
|
+
expect(remainingFiles[0].fileId).toBe('file1');
|
|
259
|
+
});
|
|
231
260
|
});
|
|
232
261
|
|
|
233
262
|
describe('static findById', () => {
|
|
@@ -43,13 +43,15 @@ export class KnowledgeBaseModel {
|
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
removeFilesFromKnowledgeBase = async (knowledgeBaseId: string, ids: string[]) => {
|
|
46
|
-
return this.db
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
46
|
+
return this.db
|
|
47
|
+
.delete(knowledgeBaseFiles)
|
|
48
|
+
.where(
|
|
49
|
+
and(
|
|
50
|
+
eq(knowledgeBaseFiles.userId, this.userId),
|
|
51
|
+
eq(knowledgeBaseFiles.knowledgeBaseId, knowledgeBaseId),
|
|
52
|
+
inArray(knowledgeBaseFiles.fileId, ids),
|
|
53
|
+
),
|
|
54
|
+
);
|
|
53
55
|
};
|
|
54
56
|
// query
|
|
55
57
|
query = async () => {
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
DeleteObjectCommand,
|
|
4
4
|
DeleteObjectsCommand,
|
|
5
5
|
GetObjectCommand,
|
|
6
|
+
HeadObjectCommand,
|
|
6
7
|
PutObjectCommand,
|
|
7
8
|
S3Client,
|
|
8
9
|
} from '@aws-sdk/client-s3';
|
|
@@ -304,6 +305,63 @@ describe('FileS3', () => {
|
|
|
304
305
|
});
|
|
305
306
|
});
|
|
306
307
|
|
|
308
|
+
describe('getFileMetadata', () => {
|
|
309
|
+
it('should retrieve file metadata with content length and type', async () => {
|
|
310
|
+
const s3 = new FileS3();
|
|
311
|
+
mockS3ClientSend.mockResolvedValue({
|
|
312
|
+
ContentLength: 1024,
|
|
313
|
+
ContentType: 'image/png',
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const result = await s3.getFileMetadata('test-file.png');
|
|
317
|
+
|
|
318
|
+
expect(HeadObjectCommand).toHaveBeenCalledWith({
|
|
319
|
+
Bucket: 'test-bucket',
|
|
320
|
+
Key: 'test-file.png',
|
|
321
|
+
});
|
|
322
|
+
expect(result).toEqual({
|
|
323
|
+
contentLength: 1024,
|
|
324
|
+
contentType: 'image/png',
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('should return 0 for content length when not provided', async () => {
|
|
329
|
+
const s3 = new FileS3();
|
|
330
|
+
mockS3ClientSend.mockResolvedValue({
|
|
331
|
+
ContentType: 'application/octet-stream',
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const result = await s3.getFileMetadata('test-file.bin');
|
|
335
|
+
|
|
336
|
+
expect(result).toEqual({
|
|
337
|
+
contentLength: 0,
|
|
338
|
+
contentType: 'application/octet-stream',
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('should handle missing content type', async () => {
|
|
343
|
+
const s3 = new FileS3();
|
|
344
|
+
mockS3ClientSend.mockResolvedValue({
|
|
345
|
+
ContentLength: 2048,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const result = await s3.getFileMetadata('test-file.bin');
|
|
349
|
+
|
|
350
|
+
expect(result).toEqual({
|
|
351
|
+
contentLength: 2048,
|
|
352
|
+
contentType: undefined,
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('should handle S3 errors', async () => {
|
|
357
|
+
const s3 = new FileS3();
|
|
358
|
+
const error = new Error('File not found');
|
|
359
|
+
mockS3ClientSend.mockRejectedValue(error);
|
|
360
|
+
|
|
361
|
+
await expect(s3.getFileMetadata('non-existent-file.txt')).rejects.toThrow('File not found');
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
307
365
|
describe('createPreSignedUrl', () => {
|
|
308
366
|
it('should create presigned URL for upload with ACL', async () => {
|
|
309
367
|
const s3 = new FileS3();
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
DeleteObjectCommand,
|
|
3
3
|
DeleteObjectsCommand,
|
|
4
4
|
GetObjectCommand,
|
|
5
|
+
HeadObjectCommand,
|
|
5
6
|
PutObjectCommand,
|
|
6
7
|
S3Client,
|
|
7
8
|
} from '@aws-sdk/client-s3';
|
|
@@ -111,6 +112,26 @@ export class S3 {
|
|
|
111
112
|
return response.Body.transformToByteArray();
|
|
112
113
|
}
|
|
113
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Get file metadata from S3 using HeadObject
|
|
117
|
+
* This is used to verify actual file size from S3 instead of trusting client-provided values
|
|
118
|
+
*/
|
|
119
|
+
public async getFileMetadata(
|
|
120
|
+
key: string,
|
|
121
|
+
): Promise<{ contentLength: number; contentType?: string }> {
|
|
122
|
+
const command = new HeadObjectCommand({
|
|
123
|
+
Bucket: this.bucket,
|
|
124
|
+
Key: key,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const response = await this.client.send(command);
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
contentLength: response.ContentLength ?? 0,
|
|
131
|
+
contentType: response.ContentType,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
114
135
|
public async createPreSignedUrl(key: string): Promise<string> {
|
|
115
136
|
const command = new PutObjectCommand({
|
|
116
137
|
ACL: this.setAcl ? 'public-read' : undefined,
|
|
@@ -19,6 +19,7 @@ function createCallerWithCtx(partialCtx: any = {}) {
|
|
|
19
19
|
|
|
20
20
|
const fileService = {
|
|
21
21
|
getFullFileUrl: vi.fn().mockResolvedValue('full-url'),
|
|
22
|
+
getFileMetadata: vi.fn().mockResolvedValue({ contentLength: 2048, contentType: 'text/plain' }),
|
|
22
23
|
deleteFile: vi.fn().mockResolvedValue(undefined),
|
|
23
24
|
deleteFiles: vi.fn().mockResolvedValue(undefined),
|
|
24
25
|
};
|
|
@@ -114,12 +115,14 @@ vi.mock('@/database/models/file', () => ({
|
|
|
114
115
|
}));
|
|
115
116
|
|
|
116
117
|
const mockFileServiceGetFullFileUrl = vi.fn();
|
|
118
|
+
const mockFileServiceGetFileMetadata = vi.fn();
|
|
117
119
|
|
|
118
120
|
vi.mock('@/server/services/file', () => ({
|
|
119
121
|
FileService: vi.fn(() => ({
|
|
120
122
|
deleteFile: vi.fn(),
|
|
121
123
|
deleteFiles: vi.fn(),
|
|
122
124
|
getFullFileUrl: mockFileServiceGetFullFileUrl,
|
|
125
|
+
getFileMetadata: mockFileServiceGetFileMetadata,
|
|
123
126
|
})),
|
|
124
127
|
}));
|
|
125
128
|
|
|
@@ -160,6 +163,12 @@ describe('fileRouter', () => {
|
|
|
160
163
|
embeddingTaskId: null,
|
|
161
164
|
};
|
|
162
165
|
|
|
166
|
+
// Set default mock for getFileMetadata (security fix for GHSA-wrrr-8jcv-wjf5)
|
|
167
|
+
mockFileServiceGetFileMetadata.mockResolvedValue({
|
|
168
|
+
contentLength: 100,
|
|
169
|
+
contentType: 'text/plain',
|
|
170
|
+
});
|
|
171
|
+
|
|
163
172
|
// Use actual context with default mocks
|
|
164
173
|
({ ctx, caller } = createCallerWithCtx());
|
|
165
174
|
});
|
|
@@ -204,6 +213,53 @@ describe('fileRouter', () => {
|
|
|
204
213
|
url: 'https://lobehub.com/f/new-file-id',
|
|
205
214
|
});
|
|
206
215
|
});
|
|
216
|
+
|
|
217
|
+
it('should use actual file size from S3 instead of client-provided size (security fix)', async () => {
|
|
218
|
+
// Setup: S3 returns actual size of 5000 bytes
|
|
219
|
+
mockFileServiceGetFileMetadata.mockResolvedValue({
|
|
220
|
+
contentLength: 5000,
|
|
221
|
+
contentType: 'text/plain',
|
|
222
|
+
});
|
|
223
|
+
mockFileModelCheckHash.mockResolvedValue({ isExist: false });
|
|
224
|
+
mockFileModelCreate.mockResolvedValue({ id: 'new-file-id' });
|
|
225
|
+
|
|
226
|
+
// Client claims file is only 100 bytes (attempting quota bypass)
|
|
227
|
+
await caller.createFile({
|
|
228
|
+
hash: 'test-hash',
|
|
229
|
+
fileType: 'text',
|
|
230
|
+
name: 'test.txt',
|
|
231
|
+
size: 100, // Client-provided fake size
|
|
232
|
+
url: 'files/test.txt',
|
|
233
|
+
metadata: {},
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Verify getFileMetadata was called to get actual size
|
|
237
|
+
expect(mockFileServiceGetFileMetadata).toHaveBeenCalledWith('files/test.txt');
|
|
238
|
+
|
|
239
|
+
// Verify create was called with actual size from S3, not client-provided size
|
|
240
|
+
expect(mockFileModelCreate).toHaveBeenCalledWith(
|
|
241
|
+
expect.objectContaining({
|
|
242
|
+
size: 5000, // Actual size from S3, not 100
|
|
243
|
+
}),
|
|
244
|
+
true,
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('should handle getFileMetadata errors', async () => {
|
|
249
|
+
mockFileModelCheckHash.mockResolvedValue({ isExist: false });
|
|
250
|
+
mockFileServiceGetFileMetadata.mockRejectedValue(new Error('File not found in S3'));
|
|
251
|
+
|
|
252
|
+
await expect(
|
|
253
|
+
caller.createFile({
|
|
254
|
+
hash: 'test-hash',
|
|
255
|
+
fileType: 'text',
|
|
256
|
+
name: 'test.txt',
|
|
257
|
+
size: 100,
|
|
258
|
+
url: 'files/non-existent.txt',
|
|
259
|
+
metadata: {},
|
|
260
|
+
}),
|
|
261
|
+
).rejects.toThrow('File not found in S3');
|
|
262
|
+
});
|
|
207
263
|
});
|
|
208
264
|
|
|
209
265
|
describe('findById', () => {
|
|
@@ -64,6 +64,8 @@ export const fileRouter = router({
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
const { contentLength: actualSize } = await ctx.fileService.getFileMetadata(input.url);
|
|
68
|
+
|
|
67
69
|
const { id } = await ctx.fileModel.create(
|
|
68
70
|
{
|
|
69
71
|
fileHash: input.hash,
|
|
@@ -72,7 +74,7 @@ export const fileRouter = router({
|
|
|
72
74
|
metadata: input.metadata,
|
|
73
75
|
name: input.name,
|
|
74
76
|
parentId: resolvedParentId,
|
|
75
|
-
size:
|
|
77
|
+
size: actualSize,
|
|
76
78
|
url: input.url,
|
|
77
79
|
},
|
|
78
80
|
// if the file is not exist in global file, create a new one
|
|
@@ -24,6 +24,7 @@ vi.mock('@/server/modules/S3', () => ({
|
|
|
24
24
|
.mockResolvedValue('https://presigned.example.com/test.jpg'),
|
|
25
25
|
getFileContent: vi.fn().mockResolvedValue('file content'),
|
|
26
26
|
getFileByteArray: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3])),
|
|
27
|
+
getFileMetadata: vi.fn().mockResolvedValue({ contentLength: 1024, contentType: 'image/png' }),
|
|
27
28
|
deleteFile: vi.fn().mockResolvedValue({}),
|
|
28
29
|
deleteFiles: vi.fn().mockResolvedValue({}),
|
|
29
30
|
createPreSignedUrl: vi.fn().mockResolvedValue('https://upload.example.com/test.jpg'),
|
|
@@ -152,6 +153,24 @@ describe('S3StaticFileImpl', () => {
|
|
|
152
153
|
});
|
|
153
154
|
});
|
|
154
155
|
|
|
156
|
+
describe('getFileMetadata', () => {
|
|
157
|
+
it('should call S3 getFileMetadata and return metadata', async () => {
|
|
158
|
+
const result = await fileService.getFileMetadata('test.png');
|
|
159
|
+
|
|
160
|
+
expect(fileService['s3'].getFileMetadata).toHaveBeenCalledWith('test.png');
|
|
161
|
+
expect(result).toEqual({ contentLength: 1024, contentType: 'image/png' });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should handle S3 errors', async () => {
|
|
165
|
+
const error = new Error('File not found');
|
|
166
|
+
fileService['s3'].getFileMetadata = vi.fn().mockRejectedValue(error);
|
|
167
|
+
|
|
168
|
+
await expect(fileService.getFileMetadata('non-existent.txt')).rejects.toThrow(
|
|
169
|
+
'File not found',
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
155
174
|
describe('uploadContent', () => {
|
|
156
175
|
it('应该调用S3的uploadContent方法', async () => {
|
|
157
176
|
await fileService.uploadContent('test.jpg', 'content');
|
|
@@ -36,6 +36,10 @@ export class S3StaticFileImpl implements FileServiceImpl {
|
|
|
36
36
|
return this.s3.createPreSignedUrl(key);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
async getFileMetadata(key: string): Promise<{ contentLength: number; contentType?: string }> {
|
|
40
|
+
return this.s3.getFileMetadata(key);
|
|
41
|
+
}
|
|
42
|
+
|
|
39
43
|
async createPreSignedUrlForPreview(key: string, expiresIn?: number): Promise<string> {
|
|
40
44
|
return this.s3.createPreSignedUrlForPreview(key, expiresIn);
|
|
41
45
|
}
|
|
@@ -32,6 +32,12 @@ export interface FileServiceImpl {
|
|
|
32
32
|
*/
|
|
33
33
|
getFileContent(key: string): Promise<string>;
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Get file metadata from storage
|
|
37
|
+
* Used to verify actual file size instead of trusting client-provided values
|
|
38
|
+
*/
|
|
39
|
+
getFileMetadata(key: string): Promise<{ contentLength: number; contentType?: string }>;
|
|
40
|
+
|
|
35
41
|
/**
|
|
36
42
|
* Get full file URL
|
|
37
43
|
*/
|
|
@@ -61,6 +61,16 @@ export class FileService {
|
|
|
61
61
|
return this.impl.createPreSignedUrl(key);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Get file metadata from storage
|
|
66
|
+
* Used to verify actual file size instead of trusting client-provided values
|
|
67
|
+
*/
|
|
68
|
+
public async getFileMetadata(
|
|
69
|
+
key: string,
|
|
70
|
+
): Promise<{ contentLength: number; contentType?: string }> {
|
|
71
|
+
return this.impl.getFileMetadata(key);
|
|
72
|
+
}
|
|
73
|
+
|
|
64
74
|
/**
|
|
65
75
|
* Create pre-signed preview URL
|
|
66
76
|
*/
|