Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -1198,6 +1198,17 @@ EMAIL_FROM=noreply@librechat.ai
# # Optional: For EU region
# MAILGUN_HOST=https://api.eu.mailgun.net

#========================#
# Stored File Links #
#========================#

# Serve files kept in S3 through LibreChat (/api/stored-files) instead of presigned
# storage URLs. Links never expire, and browsers never reach the bucket, so it can stay
# private to your network. The route admits a
# file's owner, any signed-in user for avatars, and anyone the file's own access rules
# allow (files shared through agents), within their tenant.
# STORAGE_PROXY_FILES=false

#========================#
# Firebase CDN #
#========================#
Expand Down
5 changes: 5 additions & 0 deletions api/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const mongoSanitize = require('express-mongo-sanitize');
const { logger, runAsSystem } = require('@librechat/data-schemas');
const {
isEnabled,
STORED_FILES_ROUTE,
isStoredFileProxyEnabled,
issueCsp,
apiNotFound,
createMetrics,
Expand Down Expand Up @@ -432,6 +434,9 @@ const startServer = async () => {
app.use('/api/models', routes.models);
app.use('/api/config', preAuthTenantMiddleware, optionalJwtAuth, routes.config);
app.use('/api/assistants', routes.assistants);
if (isStoredFileProxyEnabled()) {
app.use(STORED_FILES_ROUTE, routes.storedFiles);
}
app.use('/api/files', await routes.files.initialize());
app.use(
'/images/',
Expand Down
52 changes: 28 additions & 24 deletions api/server/middleware/accessResources/fileAccess.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,32 @@ const denyFileAccess = (res) =>
message: 'Insufficient permissions to access this file',
});

/**
* Whether a user may access a file: same tenant (for tenant-scoped files), then
* ownership, then agent-based access through the agents it is attached to.
* @param {{ id: string, role?: string, tenantId?: unknown }} user
* @param {{ file_id: string, user?: unknown, tenantId?: unknown }} file
* @returns {Promise<boolean>}
*/
const canAccessFile = async (user, file) => {
const fileTenantId = getTenantId(file.tenantId);
// Tenant-scoped files are restricted to their tenant. Legacy files without
// tenantId remain governed by owner/agent ACLs for non-tenant migrations.
if (fileTenantId && fileTenantId !== getTenantId(user.tenantId)) {
logger.warn(`[fileAccess] User ${user.id} denied cross-tenant access to file ${file.file_id}`);
return false;
}
if (file.user && file.user.toString() === user.id) {
return true;
}
return checkAgentBasedFileAccess({
userId: user.id,
role: user.role,
fileId: file.file_id,
fileOwner: file.user,
});
};

/**
* Middleware to check if user can access a file
* Checks: 1) File ownership, 2) Agent-based access through attached agents
Expand Down Expand Up @@ -107,30 +133,7 @@ const fileAccess = async (req, res, next) => {
});
}

const fileTenantId = getTenantId(file.tenantId);
const userTenantId = getTenantId(req.user?.tenantId);
// Tenant-scoped files are restricted to their tenant. Legacy files without
// tenantId remain governed by owner/agent ACLs for non-tenant migrations.
if (fileTenantId && fileTenantId !== userTenantId) {
logger.warn(
`[fileAccess] User ${userId} denied cross-tenant access to file ${fileId} (route ${req.originalUrl})`,
);
return denyFileAccess(res);
}

if (file.user && file.user.toString() === userId) {
req.fileAccess = { file };
return next();
}

/** Agent-based access (file inherits agent permissions) */
const hasAgentAccess = await checkAgentBasedFileAccess({
userId,
role: userRole,
fileId,
fileOwner: file.user,
});
if (hasAgentAccess) {
if (await canAccessFile({ id: userId, role: userRole, tenantId: req.user?.tenantId }, file)) {
req.fileAccess = { file };
return next();
}
Expand All @@ -150,4 +153,5 @@ const fileAccess = async (req, res, next) => {

module.exports = {
fileAccess,
canAccessFile,
};
67 changes: 66 additions & 1 deletion api/server/middleware/accessResources/fileAccess.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const mongoose = require('mongoose');
const { tenantStorage } = require('@librechat/data-schemas');
const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { fileAccess } = require('./fileAccess');
const { fileAccess, canAccessFile } = require('./fileAccess');
const { User, Role, AclEntry } = require('~/db/models');
const { createAgent, createFile } = require('~/models');

Expand Down Expand Up @@ -646,4 +646,69 @@ describe('fileAccess middleware', () => {
expect(res.status).toHaveBeenCalledWith(403);
});
});
describe('canAccessFile', () => {
const viewer = () => ({ id: testUser._id.toString(), role: 'USER' });

test('admits the owner of a file record', async () => {
const file = await createFile({
user: testUser._id.toString(),
file_id: 'owned_file',
filepath: '/test/owned.txt',
filename: 'owned.txt',
type: 'text/plain',
size: 100,
});

await expect(canAccessFile(viewer(), file)).resolves.toBe(true);
});

test('admits a viewer of an agent the file is attached to, and refuses others', async () => {
const file = await createFile({
user: otherUser._id.toString(),
file_id: 'agent_file',
filepath: '/test/agent.txt',
filename: 'agent.txt',
type: 'text/plain',
size: 100,
});
await expect(canAccessFile(viewer(), file)).resolves.toBe(false);

const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Shared Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
tool_resources: { file_search: { file_ids: ['agent_file'] } },
});
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
permBits: 1,
grantedBy: otherUser._id,
});

await expect(canAccessFile(viewer(), file)).resolves.toBe(true);
});

test("refuses a tenant-scoped file to another tenant's user, even its owner", async () => {
const file = await tenantStorage.run({ tenantId: 'tenant-a' }, async () =>
createFile({
user: testUser._id.toString(),
file_id: 'tenant_file',
filepath: '/test/tenant.txt',
filename: 'tenant.txt',
type: 'text/plain',
size: 100,
tenantId: 'tenant-a',
}),
);

await expect(canAccessFile({ ...viewer(), tenantId: 'tenant-b' }, file)).resolves.toBe(false);
await expect(canAccessFile({ ...viewer(), tenantId: 'tenant-a' }, file)).resolves.toBe(true);
});
});
});
126 changes: 126 additions & 0 deletions api/server/routes/__tests__/storedFiles.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
const express = require('express');
const jwt = require('jsonwebtoken');
const request = require('supertest');
const { Readable } = require('stream');
const { mockClient } = require('aws-sdk-client-mock');
const { sdkStreamMixin } = require('@smithy/util-stream');
const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');

jest.mock('~/models', () => ({
findSession: jest.fn(),
getUserById: jest.fn(),
getFiles: jest.fn(),
getAgents: jest.fn(),
}));

jest.mock('~/server/services/PermissionService', () => ({
getEffectivePermissions: jest.fn(),
}));

const OWNER_ID = '65cfb246f7ecadb8b1e8036b';
const OTHER_ID = '65cfb246f7ecadb8b1e8036c';
const KEY_PATH = `/api/stored-files/s3/images/${OWNER_ID}/file-1__photo.png`;

describe('stored file route', () => {
const originalEnv = { ...process.env };
const s3Mock = mockClient(S3Client);
let app;
let models;

beforeAll(() => {
process.env.AWS_REGION = 'eu-west-2';
process.env.AWS_BUCKET_NAME = 'test-bucket';
process.env.JWT_REFRESH_SECRET = 'stored-files-secret';
process.env.ENFORCE_TWO_FACTOR_AUTHENTICATION = 'false';
const { STORED_FILES_ROUTE } = require('@librechat/api');
models = require('~/models');
app = express();
app.use(STORED_FILES_ROUTE, require('../storedFiles'));
});

afterAll(() => {
process.env = originalEnv;
s3Mock.restore();
});

beforeEach(() => {
jest.clearAllMocks();
s3Mock.reset();
s3Mock
.on(GetObjectCommand)
.callsFake(() => ({ Body: sdkStreamMixin(Readable.from([Buffer.from('png')])) }));
models.findSession.mockResolvedValue({ _id: 'session' });
models.getUserById.mockResolvedValue({ provider: 'local', role: 'USER' });
models.getFiles.mockResolvedValue([]);
models.getAgents.mockResolvedValue([]);
});

const signedCookie = (userId) =>
`refreshToken=${jwt.sign({ id: userId }, process.env.JWT_REFRESH_SECRET, { expiresIn: '1h' })}`;

it('streams the object to the owner named in the key', async () => {
const res = await request(app).get(KEY_PATH).set('Cookie', signedCookie(OWNER_ID));

expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
expect(res.body.toString()).toBe('png');
expect(s3Mock.commandCalls(GetObjectCommand)[0].args[0].input).toEqual({
Bucket: 'test-bucket',
Key: `images/${OWNER_ID}/file-1__photo.png`,
});
});

it('asks for a session cookie', async () => {
expect((await request(app).get(KEY_PATH)).status).toBe(401);
expect(s3Mock.commandCalls(GetObjectCommand)).toHaveLength(0);
});

it('refuses a cookie whose session has ended', async () => {
models.findSession.mockResolvedValue(null);

const res = await request(app).get(KEY_PATH).set('Cookie', signedCookie(OWNER_ID));

expect(res.status).toBe(403);
expect(s3Mock.commandCalls(GetObjectCommand)).toHaveLength(0);
});

it("does not reveal another user's file", async () => {
const res = await request(app).get(KEY_PATH).set('Cookie', signedCookie(OTHER_ID));

expect(res.status).toBe(404);
expect(s3Mock.commandCalls(GetObjectCommand)).toHaveLength(0);
expect(models.getFiles).toHaveBeenCalledWith(
{
user: OWNER_ID,
source: 's3',
$or: [{ storageKey: `images/${OWNER_ID}/file-1__photo.png` }, { filepath: KEY_PATH }],
},
null,
{ text: 0 },
1,
);
});

it('serves a file attached to an agent the viewer can view', async () => {
const { getEffectivePermissions } = require('~/server/services/PermissionService');
models.getFiles.mockResolvedValue([{ file_id: 'file-1', user: OWNER_ID }]);
models.getAgents.mockResolvedValue([{ _id: 'agent-db-id', id: 'agent_1', author: OWNER_ID }]);
getEffectivePermissions.mockResolvedValue(1);

const res = await request(app).get(KEY_PATH).set('Cookie', signedCookie(OTHER_ID));

expect(res.status).toBe(200);
expect(getEffectivePermissions).toHaveBeenCalledWith(
expect.objectContaining({ userId: OTHER_ID, role: 'USER', resourceId: 'agent-db-id' }),
);
});

it('serves avatars to any signed-in user', async () => {
const res = await request(app)
.get(`/api/stored-files/s3/avatars/${OWNER_ID}/avatar-123.png`)
.set('Cookie', signedCookie(OTHER_ID));

expect(res.status).toBe(200);
expect(models.getFiles).not.toHaveBeenCalled();
});
});
2 changes: 2 additions & 0 deletions api/server/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const adminUsers = require('./admin/users');
const adminAuditLog = require('./admin/audit');
const endpoints = require('./endpoints');
const staticRoute = require('./static');
const storedFiles = require('./storedFiles');
const messages = require('./messages');
const memories = require('./memories');
const presets = require('./presets');
Expand Down Expand Up @@ -89,5 +90,6 @@ module.exports = {
assistants,
categories,
staticRoute,
storedFiles,
accessPermissions,
};
42 changes: 42 additions & 0 deletions api/server/routes/storedFiles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
const cookie = require('cookie');
const { FileSources } = require('librechat-data-provider');
const {
isEnabled,
s3FileSource,
getStoredFileURL,
authenticateViewer,
createStoredFileHandler,
} = require('@librechat/api');
const { canAccessFile } = require('~/server/middleware/accessResources/fileAccess');
const { findSession, getUserById, getFiles } = require('~/models');

const cookieAuth = {
parseCookies: cookie.parse,
isOpenIdReuseEnabled: () => isEnabled(process.env.OPENID_REUSE_TOKENS),
findSession,
getUserById,
};

/** The stored file's own access rules, as for `/api/files/download`. */
const canViewFile = async (viewer, { source, key, ownerId }) => {
const [file] = await getFiles(
{
user: ownerId,
source,
$or: [{ storageKey: key }, { filepath: getStoredFileURL(source, key) }],
},
null,
{ text: 0 },
1,
);
if (!file) {
return false;
}
return canAccessFile({ id: viewer.userId, role: viewer.role, tenantId: viewer.tenantId }, file);
};

module.exports = createStoredFileHandler({
authenticate: (req) => authenticateViewer(req, cookieAuth),
sources: { [FileSources.s3]: s3FileSource },
canViewFile,
});
Loading