VYPR
Medium severityOSV Advisory· Published Jan 30, 2026· Updated Apr 15, 2026

CVE-2026-23835

CVE-2026-23835

Description

LobeHub is an open source human-and-AI-agent network. Prior to version 1.143.3, the file upload feature in Knowledge Base > File Upload does not validate the integrity of the upload request, allowing users to intercept and modify the request parameters. As a result, it is possible to create arbitrary files in abnormal or unintended paths. In addition, since lobechat.com relies on the size parameter from the request to calculate file usage, an attacker can manipulate this value to misrepresent the actual file size, such as uploading a 1 GB file while reporting it as 10 MB, or falsely declaring a 10 MB file as a 1 GB file. By manipulating the size value provided in the client upload request, it is possible to bypass the monthly upload quota enforced by the server and continuously upload files beyond the intended storage and traffic limits. This abuse can result in a discrepancy between actual resource consumption and billing calculations, causing direct financial impact to the service operator. Additionally, exhaustion of storage or related resources may lead to degraded service availability, including failed uploads, delayed content delivery, or temporary suspension of upload functionality for legitimate users. A single malicious user can also negatively affect other users or projects sharing the same subscription plan, effectively causing an indirect denial of service (DoS). Furthermore, excessive and unaccounted-for uploads can distort monitoring metrics and overload downstream systems such as backup processes, malware scanning, and media processing pipelines, ultimately undermining overall operational stability and service reliability. Version 1.143.3 contains a patch for the issue.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
@lobehub/chatnpm
< 1.143.31.143.3

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/lobehubArvin 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.