📦 feat: Configure Skill Import Size Limit (#13073)

* fix: configure skill import size limit

* fix: validate skill import size in ui

* fix: align skill import size boundary

* fix: show exact skill import limit
This commit is contained in:
Danny Avila 2026-05-11 16:24:04 -04:00 committed by GitHub
parent 8735c1763c
commit c385f2ba88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 329 additions and 10 deletions

View file

@ -16,6 +16,7 @@ const {
PermissionTypes,
Permissions,
FileContext,
mergeFileConfig,
} = require('librechat-data-provider');
const {
createSkill,
@ -52,6 +53,11 @@ const MAX_IMPORT_SIZE = 50 * 1024 * 1024; // 50 MB
const memoryStorage = multer.memoryStorage();
function getSkillImportSizeLimit(req) {
const fileConfig = mergeFileConfig(req.config?.fileConfig);
return fileConfig.skills?.fileSizeLimit ?? MAX_IMPORT_SIZE;
}
const skillImportFilter = (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (ALLOWED_EXTENSIONS.has(ext)) {
@ -62,11 +68,12 @@ const skillImportFilter = (_req, file, cb) => {
}
};
const skillUpload = multer({
storage: memoryStorage,
fileFilter: skillImportFilter,
limits: { fileSize: MAX_IMPORT_SIZE },
});
const skillUpload = (req, res, next) =>
multer({
storage: memoryStorage,
fileFilter: skillImportFilter,
limits: { fileSize: getSkillImportSizeLimit(req) },
}).single('file')(req, res, next);
// Per-file upload (for adding individual files to an existing skill)
const MAX_SINGLE_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
@ -135,6 +142,9 @@ function resolveSkillStorage(req, { isImage = false } = {}) {
// Import handler (zip/md/skill → create skill + files)
// ---------------------------------------------------------------------------
const importHandler = createImportHandler({
limits: (req) => ({
maxZipBytes: getSkillImportSizeLimit(req),
}),
createSkill,
getSkillById,
deleteSkill,
@ -269,7 +279,7 @@ router.post(
checkSkillCreate,
fileUploadIpLimiter,
fileUploadUserLimiter,
skillUpload.single('file'),
skillUpload,
restoreTenantContextFromReq,
importHandler,
);

View file

@ -3,6 +3,27 @@ const request = require('supertest');
const JSZip = require('jszip');
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
jest.mock('librechat-data-provider', () => {
const actual = jest.requireActual('librechat-data-provider');
return {
...actual,
mergeFileConfig: jest.fn((dynamic) => {
const skillFileSizeLimit = dynamic?.skills?.fileSizeLimit;
return {
...actual.fileConfig,
...dynamic,
skills: {
...(actual.fileConfig.skills ?? { fileSizeLimit: 50 * 1024 * 1024 }),
...(skillFileSizeLimit !== undefined
? { fileSizeLimit: skillFileSizeLimit * 1024 * 1024 }
: {}),
},
};
}),
};
});
const {
SystemRoles,
ResourceType,
@ -11,6 +32,8 @@ const {
PermissionBits,
} = require('librechat-data-provider');
let mockFileConfig;
jest.mock('~/server/services/Config', () => ({
getCachedTools: jest.fn().mockResolvedValue({}),
getAppConfig: jest.fn().mockResolvedValue({
@ -23,6 +46,7 @@ jest.mock('~/server/middleware/config/app', () => (req, _res, next) => {
req.config = {
fileStrategy: 'local',
paths: { uploads: '/tmp/uploads', images: '/tmp/images' },
fileConfig: mockFileConfig,
};
next();
});
@ -127,6 +151,7 @@ afterEach(async () => {
await SkillFile.deleteMany({});
await AclEntry.deleteMany({});
currentTestUser = testUsers.owner;
mockFileConfig = undefined;
});
afterAll(async () => {
@ -300,6 +325,26 @@ describe('Skill routes', () => {
});
describe('POST /api/skills/import', () => {
it('enforces fileConfig.skills.fileSizeLimit before import handling', async () => {
mockFileConfig = {
skills: {
fileSizeLimit: 1,
},
};
const res = await request(app)
.post('/api/skills/import')
.attach('file', Buffer.alloc(2 * 1024 * 1024), {
filename: 'too-large.skill',
contentType: 'application/zip',
});
const { mergeFileConfig } = require('librechat-data-provider');
expect(mergeFileConfig).toHaveBeenCalledWith(mockFileConfig);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/file too large/i);
});
it('persists storage metadata for imported skill files', async () => {
const savedFilepath =
'https://cdn.example.com/r/us-east-2/uploads/user123/imported-script.sh';