VYPR
Low severity3.7OSV Advisory· Published Jan 19, 2026· Updated Apr 15, 2026

CVE-2026-23522

CVE-2026-23522

Description

LobeChat is an open source chat application platform. Prior to version 2.0.0-next.193, knowledgeBase.removeFilesFromKnowledgeBase tRPC ep allows authenticated users to delete files from any knowledge base without verifying ownership. userId filter in the database query is commented out, so it's enabling attackers to delete other users' KB files if they know the knowledge base ID and file ID. While the vulnerability is confirmed, practical exploitation requires knowing target's KB ID and target's file ID. These IDs are random and not easily enumerable. However, IDs may leak through shared links, logs, referrer headers and so on. Missing authorization check is a critical security flaw regardless. Users should upgrade to version 2.0.0-next.193 to receive a patch.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
@lobehub/chatnpm
<= 1.143.2

Affected products

1
  • Range: 0.0.5-nightly-pr6474-20250409074602, pr-build-6474-0b4dba8534382f7a6592d8800f4b27e72f23c554, pr-build-6474-0d36602ed703878e5fb6f80015ad78829644a727, …

Patches

1
2c1762b85acb

🐛 fix(database): add userId authorization check in removeFilesFromKnowledgeBase (#11108)

https://github.com/lobehub/lobe-chatArvin XuJan 2, 2026via ghsa
11 files changed · +217 10
  • .github/workflows/claude-translator.yml+1 1 modified
    @@ -47,7 +47,7 @@ jobs:
               claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
               # Security: Restrict gh commands to specific safe operations only
               # Use explicit command patterns to prevent prompt injection attacks
    -          claude_args: "--allowed-tools Bash(gh issue view *),Bash(gh issue edit * --title * --body *),Bash(gh api -X PATCH /repos/*/issues/comments/* -f body=*),Bash(gh api -X PUT /repos/*/pulls/*/reviews/* -f body=*),Bash(gh api -X PATCH /repos/*/pulls/comments/* -f body=*)"
    +          allowed_tools: 'Bash(gh issue view:*),Bash(gh issue edit:*),Bash(gh api:*)'
               prompt: |
                 ## SECURITY RULES (HIGHEST PRIORITY - NEVER OVERRIDE)
     
    
  • packages/database/src/models/knowledgeBase.ts+9 7 modified
    @@ -43,13 +43,15 @@ export class KnowledgeBaseModel {
       };
     
       removeFilesFromKnowledgeBase = async (knowledgeBaseId: string, ids: string[]) => {
    -    return this.db.delete(knowledgeBaseFiles).where(
    -      and(
    -        eq(knowledgeBaseFiles.knowledgeBaseId, knowledgeBaseId),
    -        inArray(knowledgeBaseFiles.fileId, ids),
    -        // eq(knowledgeBaseFiles.userId, this.userId),
    -      ),
    -    );
    +    return this.db
    +      .delete(knowledgeBaseFiles)
    +      .where(
    +        and(
    +          eq(knowledgeBaseFiles.userId, this.userId),
    +          eq(knowledgeBaseFiles.knowledgeBaseId, knowledgeBaseId),
    +          inArray(knowledgeBaseFiles.fileId, ids),
    +        ),
    +      );
       };
       // query
       query = async () => {
    
  • packages/database/src/models/__tests__/knowledgeBase.test.ts+30 1 modified
    @@ -2,7 +2,7 @@
     import { and, eq } from 'drizzle-orm';
     import { afterEach, beforeEach, describe, expect, it } from 'vitest';
     
    -import { LobeChatDatabase } from '../../type';import { sleep } from '@/utils/sleep';
    +import { sleep } from '@/utils/sleep';
     
     import {
       NewKnowledgeBase,
    @@ -12,6 +12,7 @@ import {
       knowledgeBases,
       users,
     } from '../../schemas';
    +import { LobeChatDatabase } from '../../type';
     import { KnowledgeBaseModel } from '../knowledgeBase';
     import { getTestDB } from './_util';
     
    @@ -228,6 +229,34 @@ describe('KnowledgeBaseModel', () => {
           expect(remainingFiles).toHaveLength(1);
           expect(remainingFiles[0].fileId).toBe('file2');
         });
    +
    +    it('should not allow removing files from another user knowledge base', async () => {
    +      await serverDB.insert(globalFiles).values([
    +        {
    +          hashId: 'hash1',
    +          url: 'https://example.com/document.pdf',
    +          size: 1000,
    +          fileType: 'application/pdf',
    +          creator: userId,
    +        },
    +      ]);
    +
    +      await serverDB.insert(files).values([fileList[0]]);
    +
    +      const { id: knowledgeBaseId } = await knowledgeBaseModel.create({ name: 'Test Group' });
    +      await knowledgeBaseModel.addFilesToKnowledgeBase(knowledgeBaseId, ['file1']);
    +
    +      // Another user tries to remove files from this knowledge base
    +      const attackerModel = new KnowledgeBaseModel(serverDB, 'user2');
    +      await attackerModel.removeFilesFromKnowledgeBase(knowledgeBaseId, ['file1']);
    +
    +      // Files should still exist since the attacker doesn't own them
    +      const remainingFiles = await serverDB.query.knowledgeBaseFiles.findMany({
    +        where: eq(knowledgeBaseFiles.knowledgeBaseId, knowledgeBaseId),
    +      });
    +      expect(remainingFiles).toHaveLength(1);
    +      expect(remainingFiles[0].fileId).toBe('file1');
    +    });
       });
     
       describe('static findById', () => {
    
  • src/server/modules/S3/index.test.ts+58 0 modified
    @@ -3,6 +3,7 @@ import {
       DeleteObjectCommand,
       DeleteObjectsCommand,
       GetObjectCommand,
    +  HeadObjectCommand,
       PutObjectCommand,
       S3Client,
     } from '@aws-sdk/client-s3';
    @@ -304,6 +305,63 @@ describe('FileS3', () => {
         });
       });
     
    +  describe('getFileMetadata', () => {
    +    it('should retrieve file metadata with content length and type', async () => {
    +      const s3 = new FileS3();
    +      mockS3ClientSend.mockResolvedValue({
    +        ContentLength: 1024,
    +        ContentType: 'image/png',
    +      });
    +
    +      const result = await s3.getFileMetadata('test-file.png');
    +
    +      expect(HeadObjectCommand).toHaveBeenCalledWith({
    +        Bucket: 'test-bucket',
    +        Key: 'test-file.png',
    +      });
    +      expect(result).toEqual({
    +        contentLength: 1024,
    +        contentType: 'image/png',
    +      });
    +    });
    +
    +    it('should return 0 for content length when not provided', async () => {
    +      const s3 = new FileS3();
    +      mockS3ClientSend.mockResolvedValue({
    +        ContentType: 'application/octet-stream',
    +      });
    +
    +      const result = await s3.getFileMetadata('test-file.bin');
    +
    +      expect(result).toEqual({
    +        contentLength: 0,
    +        contentType: 'application/octet-stream',
    +      });
    +    });
    +
    +    it('should handle missing content type', async () => {
    +      const s3 = new FileS3();
    +      mockS3ClientSend.mockResolvedValue({
    +        ContentLength: 2048,
    +      });
    +
    +      const result = await s3.getFileMetadata('test-file.bin');
    +
    +      expect(result).toEqual({
    +        contentLength: 2048,
    +        contentType: undefined,
    +      });
    +    });
    +
    +    it('should handle S3 errors', async () => {
    +      const s3 = new FileS3();
    +      const error = new Error('File not found');
    +      mockS3ClientSend.mockRejectedValue(error);
    +
    +      await expect(s3.getFileMetadata('non-existent-file.txt')).rejects.toThrow('File not found');
    +    });
    +  });
    +
       describe('createPreSignedUrl', () => {
         it('should create presigned URL for upload with ACL', async () => {
           const s3 = new FileS3();
    
  • src/server/modules/S3/index.ts+21 0 modified
    @@ -2,6 +2,7 @@ import {
       DeleteObjectCommand,
       DeleteObjectsCommand,
       GetObjectCommand,
    +  HeadObjectCommand,
       PutObjectCommand,
       S3Client,
     } from '@aws-sdk/client-s3';
    @@ -111,6 +112,26 @@ export class S3 {
         return response.Body.transformToByteArray();
       }
     
    +  /**
    +   * Get file metadata from S3 using HeadObject
    +   * This is used to verify actual file size from S3 instead of trusting client-provided values
    +   */
    +  public async getFileMetadata(
    +    key: string,
    +  ): Promise<{ contentLength: number; contentType?: string }> {
    +    const command = new HeadObjectCommand({
    +      Bucket: this.bucket,
    +      Key: key,
    +    });
    +
    +    const response = await this.client.send(command);
    +
    +    return {
    +      contentLength: response.ContentLength ?? 0,
    +      contentType: response.ContentType,
    +    };
    +  }
    +
       public async createPreSignedUrl(key: string): Promise<string> {
         const command = new PutObjectCommand({
           ACL: this.setAcl ? 'public-read' : undefined,
    
  • src/server/routers/lambda/file.ts+3 1 modified
    @@ -64,6 +64,8 @@ export const fileRouter = router({
             }
           }
     
    +      const { contentLength: actualSize } = await ctx.fileService.getFileMetadata(input.url);
    +
           const { id } = await ctx.fileModel.create(
             {
               fileHash: input.hash,
    @@ -72,7 +74,7 @@ export const fileRouter = router({
               metadata: input.metadata,
               name: input.name,
               parentId: resolvedParentId,
    -          size: input.size,
    +          size: actualSize,
               url: input.url,
             },
             // if the file is not exist in global file, create a new one
    
  • src/server/routers/lambda/__tests__/file.test.ts+56 0 modified
    @@ -19,6 +19,7 @@ function createCallerWithCtx(partialCtx: any = {}) {
     
       const fileService = {
         getFullFileUrl: vi.fn().mockResolvedValue('full-url'),
    +    getFileMetadata: vi.fn().mockResolvedValue({ contentLength: 2048, contentType: 'text/plain' }),
         deleteFile: vi.fn().mockResolvedValue(undefined),
         deleteFiles: vi.fn().mockResolvedValue(undefined),
       };
    @@ -114,12 +115,14 @@ vi.mock('@/database/models/file', () => ({
     }));
     
     const mockFileServiceGetFullFileUrl = vi.fn();
    +const mockFileServiceGetFileMetadata = vi.fn();
     
     vi.mock('@/server/services/file', () => ({
       FileService: vi.fn(() => ({
         deleteFile: vi.fn(),
         deleteFiles: vi.fn(),
         getFullFileUrl: mockFileServiceGetFullFileUrl,
    +    getFileMetadata: mockFileServiceGetFileMetadata,
       })),
     }));
     
    @@ -160,6 +163,12 @@ describe('fileRouter', () => {
           embeddingTaskId: null,
         };
     
    +    // Set default mock for getFileMetadata (security fix for GHSA-wrrr-8jcv-wjf5)
    +    mockFileServiceGetFileMetadata.mockResolvedValue({
    +      contentLength: 100,
    +      contentType: 'text/plain',
    +    });
    +
         // Use actual context with default mocks
         ({ ctx, caller } = createCallerWithCtx());
       });
    @@ -204,6 +213,53 @@ describe('fileRouter', () => {
             url: 'https://lobehub.com/f/new-file-id',
           });
         });
    +
    +    it('should use actual file size from S3 instead of client-provided size (security fix)', async () => {
    +      // Setup: S3 returns actual size of 5000 bytes
    +      mockFileServiceGetFileMetadata.mockResolvedValue({
    +        contentLength: 5000,
    +        contentType: 'text/plain',
    +      });
    +      mockFileModelCheckHash.mockResolvedValue({ isExist: false });
    +      mockFileModelCreate.mockResolvedValue({ id: 'new-file-id' });
    +
    +      // Client claims file is only 100 bytes (attempting quota bypass)
    +      await caller.createFile({
    +        hash: 'test-hash',
    +        fileType: 'text',
    +        name: 'test.txt',
    +        size: 100, // Client-provided fake size
    +        url: 'files/test.txt',
    +        metadata: {},
    +      });
    +
    +      // Verify getFileMetadata was called to get actual size
    +      expect(mockFileServiceGetFileMetadata).toHaveBeenCalledWith('files/test.txt');
    +
    +      // Verify create was called with actual size from S3, not client-provided size
    +      expect(mockFileModelCreate).toHaveBeenCalledWith(
    +        expect.objectContaining({
    +          size: 5000, // Actual size from S3, not 100
    +        }),
    +        true,
    +      );
    +    });
    +
    +    it('should handle getFileMetadata errors', async () => {
    +      mockFileModelCheckHash.mockResolvedValue({ isExist: false });
    +      mockFileServiceGetFileMetadata.mockRejectedValue(new Error('File not found in S3'));
    +
    +      await expect(
    +        caller.createFile({
    +          hash: 'test-hash',
    +          fileType: 'text',
    +          name: 'test.txt',
    +          size: 100,
    +          url: 'files/non-existent.txt',
    +          metadata: {},
    +        }),
    +      ).rejects.toThrow('File not found in S3');
    +    });
       });
     
       describe('findById', () => {
    
  • src/server/services/file/impls/s3.test.ts+19 0 modified
    @@ -24,6 +24,7 @@ vi.mock('@/server/modules/S3', () => ({
           .mockResolvedValue('https://presigned.example.com/test.jpg'),
         getFileContent: vi.fn().mockResolvedValue('file content'),
         getFileByteArray: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3])),
    +    getFileMetadata: vi.fn().mockResolvedValue({ contentLength: 1024, contentType: 'image/png' }),
         deleteFile: vi.fn().mockResolvedValue({}),
         deleteFiles: vi.fn().mockResolvedValue({}),
         createPreSignedUrl: vi.fn().mockResolvedValue('https://upload.example.com/test.jpg'),
    @@ -152,6 +153,24 @@ describe('S3StaticFileImpl', () => {
         });
       });
     
    +  describe('getFileMetadata', () => {
    +    it('should call S3 getFileMetadata and return metadata', async () => {
    +      const result = await fileService.getFileMetadata('test.png');
    +
    +      expect(fileService['s3'].getFileMetadata).toHaveBeenCalledWith('test.png');
    +      expect(result).toEqual({ contentLength: 1024, contentType: 'image/png' });
    +    });
    +
    +    it('should handle S3 errors', async () => {
    +      const error = new Error('File not found');
    +      fileService['s3'].getFileMetadata = vi.fn().mockRejectedValue(error);
    +
    +      await expect(fileService.getFileMetadata('non-existent.txt')).rejects.toThrow(
    +        'File not found',
    +      );
    +    });
    +  });
    +
       describe('uploadContent', () => {
         it('应该调用S3的uploadContent方法', async () => {
           await fileService.uploadContent('test.jpg', 'content');
    
  • src/server/services/file/impls/s3.ts+4 0 modified
    @@ -36,6 +36,10 @@ export class S3StaticFileImpl implements FileServiceImpl {
         return this.s3.createPreSignedUrl(key);
       }
     
    +  async getFileMetadata(key: string): Promise<{ contentLength: number; contentType?: string }> {
    +    return this.s3.getFileMetadata(key);
    +  }
    +
       async createPreSignedUrlForPreview(key: string, expiresIn?: number): Promise<string> {
         return this.s3.createPreSignedUrlForPreview(key, expiresIn);
       }
    
  • src/server/services/file/impls/type.ts+6 0 modified
    @@ -32,6 +32,12 @@ export interface FileServiceImpl {
        */
       getFileContent(key: string): Promise<string>;
     
    +  /**
    +   * Get file metadata from storage
    +   * Used to verify actual file size instead of trusting client-provided values
    +   */
    +  getFileMetadata(key: string): Promise<{ contentLength: number; contentType?: string }>;
    +
       /**
        * Get full file URL
        */
    
  • src/server/services/file/index.ts+10 0 modified
    @@ -61,6 +61,16 @@ export class FileService {
         return this.impl.createPreSignedUrl(key);
       }
     
    +  /**
    +   * Get file metadata from storage
    +   * Used to verify actual file size instead of trusting client-provided values
    +   */
    +  public async getFileMetadata(
    +    key: string,
    +  ): Promise<{ contentLength: number; contentType?: string }> {
    +    return this.impl.getFileMetadata(key);
    +  }
    +
       /**
        * Create pre-signed preview URL
        */
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.