@lobehub/lobehub 2.0.0-next.192 → 2.0.0-next.194

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.
@@ -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: input.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
  */