mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 10:02:41 +00:00
🎟️ refactor: Require Credentials for Local Image Access by Default (#15252)
* 🔐 fix: Protect Local Image Access by Default * 🔐 fix: Scope Image Authorization to Active Sessions * 🧹 style: Format Image Authorization Checks * 🛡️ fix: Harden Image Avatar Authorization * 🧭 style: Sort Image Authorization Imports * 🔐 fix: Close Image Authorization Review Gaps * 🧭 fix: Normalize Stored Avatar Base Paths * 🏢 fix: Resolve Tenant Assistant Image Policy * 🛂 fix: Enforce Effective Image Access Policy * 🧹 style: Flatten Assistant Config Selection * 🧷 fix: Preserve Image Access Compatibility * 🪪 fix: Make Image Sessions Revocable * 🏗️ fix: Move Image Session Policy Into API
This commit is contained in:
parent
ff1784568b
commit
de59da9636
33 changed files with 1834 additions and 189 deletions
|
|
@ -13,6 +13,7 @@ const {
|
|||
const {
|
||||
requestPasswordReset,
|
||||
setOpenIDAuthTokens,
|
||||
storeOpenIDSession,
|
||||
setCloudFrontAuthCookies,
|
||||
resetPassword,
|
||||
setAuthTokens,
|
||||
|
|
@ -242,6 +243,13 @@ const refreshController = async (req, res) => {
|
|||
);
|
||||
}
|
||||
|
||||
const activeRefreshToken = tokenset.refresh_token || refreshToken;
|
||||
await storeOpenIDSession(
|
||||
user._id.toString(),
|
||||
activeRefreshToken,
|
||||
user.tenantId,
|
||||
refreshToken,
|
||||
);
|
||||
const token = setOpenIDAuthTokens(tokenset, req, res, {
|
||||
userId: user._id.toString(),
|
||||
existingRefreshToken: refreshToken,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ jest.mock('~/server/services/GraphTokenService', () => ({
|
|||
jest.mock('~/server/services/AuthService', () => ({
|
||||
requestPasswordReset: jest.fn(),
|
||||
setOpenIDAuthTokens: jest.fn(),
|
||||
storeOpenIDSession: jest.fn(),
|
||||
setCloudFrontAuthCookies: jest.fn(),
|
||||
resetPassword: jest.fn(),
|
||||
setAuthTokens: jest.fn(),
|
||||
|
|
@ -47,6 +48,7 @@ const { graphTokenController, refreshController } = require('./AuthController');
|
|||
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
|
||||
const {
|
||||
setOpenIDAuthTokens,
|
||||
storeOpenIDSession,
|
||||
setCloudFrontAuthCookies,
|
||||
setAuthTokens,
|
||||
} = require('~/server/services/AuthService');
|
||||
|
|
@ -236,6 +238,7 @@ describe('refreshController – OpenID path', () => {
|
|||
mockTokenset.claims.mockReturnValue(baseClaims);
|
||||
getOpenIdEmail.mockReturnValue(baseClaims.email);
|
||||
setOpenIDAuthTokens.mockReturnValue('new-app-token');
|
||||
storeOpenIDSession.mockResolvedValue(true);
|
||||
setCloudFrontAuthCookies.mockReturnValue(true);
|
||||
findOpenIDUser.mockResolvedValue({ user: { ...defaultUser }, error: null, migration: false });
|
||||
getUserById.mockResolvedValue({
|
||||
|
|
@ -289,6 +292,12 @@ describe('refreshController – OpenID path', () => {
|
|||
existingRefreshToken: 'stored-refresh',
|
||||
tenantId: undefined,
|
||||
});
|
||||
expect(storeOpenIDSession).toHaveBeenCalledWith(
|
||||
'user-db-id',
|
||||
'new-refresh',
|
||||
undefined,
|
||||
'stored-refresh',
|
||||
);
|
||||
};
|
||||
|
||||
it('should call getOpenIdEmail with token claims and use result for findOpenIDUser', async () => {
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ const createAssistant = async (req, res) => {
|
|||
|
||||
const assistant = await openai.beta.assistants.create(assistantData);
|
||||
|
||||
const createData = { user: req.user.id };
|
||||
const createData = { user: req.user.id, endpoint };
|
||||
if (conversation_starters) {
|
||||
createData.conversation_starters = conversation_starters;
|
||||
}
|
||||
|
|
@ -366,6 +366,7 @@ const uploadAssistantAvatar = async (req, res) => {
|
|||
}
|
||||
|
||||
const { assistant_id } = req.params;
|
||||
const endpoint = req.body?.endpoint ?? req.query?.endpoint;
|
||||
if (!assistant_id) {
|
||||
return res.status(400).json({ message: 'Assistant ID is required' });
|
||||
}
|
||||
|
|
@ -422,6 +423,7 @@ const uploadAssistantAvatar = async (req, res) => {
|
|||
source: appConfig.fileStrategy,
|
||||
},
|
||||
user: req.user.id,
|
||||
endpoint,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ const createAssistant = async (req, res) => {
|
|||
|
||||
const assistant = await openai.beta.assistants.create(assistantData);
|
||||
|
||||
const createData = { user: req.user.id };
|
||||
const createData = { user: req.user.id, endpoint };
|
||||
if (conversation_starters) {
|
||||
createData.conversation_starters = conversation_starters;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@ const {
|
|||
generateAdminExchangeCode,
|
||||
} = require('@librechat/api');
|
||||
const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
|
||||
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
|
||||
const {
|
||||
setAuthTokens,
|
||||
setOpenIDAuthTokens,
|
||||
storeOpenIDSession,
|
||||
} = require('~/server/services/AuthService');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { checkBan } = require('~/server/middleware');
|
||||
const { generateToken } = require('~/models');
|
||||
|
|
@ -76,6 +80,11 @@ function createOAuthHandler(redirectUri = domains.client) {
|
|||
isEnabled(process.env.OPENID_REUSE_TOKENS) === true
|
||||
) {
|
||||
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
|
||||
await storeOpenIDSession(
|
||||
req.user._id.toString(),
|
||||
req.user.tokenset.refresh_token,
|
||||
req.user.tenantId,
|
||||
);
|
||||
setOpenIDAuthTokens(req.user.tokenset, req, res, {
|
||||
userId: req.user._id.toString(),
|
||||
tenantId: req.user.tenantId,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const mockGenerateAdminExchangeCode = jest.fn();
|
|||
const mockSyncUserEntraGroupMemberships = jest.fn();
|
||||
const mockSetAuthTokens = jest.fn();
|
||||
const mockSetOpenIDAuthTokens = jest.fn();
|
||||
const mockStoreOpenIDSession = jest.fn();
|
||||
const mockGetLogStores = jest.fn();
|
||||
const mockCheckBan = jest.fn();
|
||||
const mockGenerateToken = jest.fn();
|
||||
|
|
@ -33,6 +34,7 @@ jest.mock('~/server/services/PermissionService', () => ({
|
|||
jest.mock('~/server/services/AuthService', () => ({
|
||||
setAuthTokens: (...args) => mockSetAuthTokens(...args),
|
||||
setOpenIDAuthTokens: (...args) => mockSetOpenIDAuthTokens(...args),
|
||||
storeOpenIDSession: (...args) => mockStoreOpenIDSession(...args),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
|
|
@ -92,6 +94,7 @@ describe('createOAuthHandler', () => {
|
|||
mockCheckBan.mockResolvedValue(undefined);
|
||||
mockGenerateToken.mockResolvedValue('jwt-token');
|
||||
mockGenerateAdminExchangeCode.mockResolvedValue('exchange-code');
|
||||
mockStoreOpenIDSession.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
|
@ -149,6 +152,28 @@ describe('createOAuthHandler', () => {
|
|||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stores the OpenID refresh token before setting cookies for the standard app', async () => {
|
||||
process.env.OPENID_REUSE_TOKENS = 'true';
|
||||
mockIsAdminPanelRedirect.mockReturnValue(false);
|
||||
const handler = createOAuthHandler('http://localhost:3080');
|
||||
const req = buildReq();
|
||||
const res = buildRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await handler(req, res, next);
|
||||
|
||||
expect(mockStoreOpenIDSession).toHaveBeenCalledWith(
|
||||
'user-123',
|
||||
'openid-refresh-token',
|
||||
undefined,
|
||||
);
|
||||
expect(mockSetOpenIDAuthTokens).toHaveBeenCalledWith(req.user.tokenset, req, res, {
|
||||
userId: 'user-123',
|
||||
tenantId: undefined,
|
||||
});
|
||||
expect(res.redirect).toHaveBeenCalledWith('http://localhost:3080');
|
||||
});
|
||||
|
||||
it('forwards the refresh token from req.authInfo for non-openid admin providers', async () => {
|
||||
const handler = createOAuthHandler('http://admin.example.com/auth/google/callback');
|
||||
const req = buildReq({
|
||||
|
|
|
|||
|
|
@ -535,7 +535,13 @@ if (cluster.isMaster) {
|
|||
app.use('/api/config', preAuthTenantMiddleware, optionalJwtAuth, routes.config);
|
||||
app.use('/api/assistants', routes.assistants);
|
||||
app.use('/api/files', await routes.files.initialize());
|
||||
app.use('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute);
|
||||
app.use(
|
||||
'/images/',
|
||||
createValidateImageRequest({
|
||||
secureImageLinks: appConfig.secureImageLinks,
|
||||
}),
|
||||
routes.staticRoute,
|
||||
);
|
||||
app.use('/api/share', preAuthTenantMiddleware, routes.share);
|
||||
app.use('/api/roles', routes.roles);
|
||||
app.use('/api/agents', routes.agents);
|
||||
|
|
|
|||
|
|
@ -387,7 +387,13 @@ const startServer = async () => {
|
|||
app.use('/api/config', preAuthTenantMiddleware, optionalJwtAuth, routes.config);
|
||||
app.use('/api/assistants', routes.assistants);
|
||||
app.use('/api/files', await routes.files.initialize());
|
||||
app.use('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute);
|
||||
app.use(
|
||||
'/images/',
|
||||
createValidateImageRequest({
|
||||
secureImageLinks: appConfig.secureImageLinks,
|
||||
}),
|
||||
routes.staticRoute,
|
||||
);
|
||||
app.use('/api/share', preAuthTenantMiddleware, routes.share);
|
||||
app.use('/api/roles', routes.roles);
|
||||
app.use('/api/agents/chat', rejectChatStartsUntilReady);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const jwt = require('jsonwebtoken');
|
||||
const { createHash } = require('node:crypto');
|
||||
const createValidateImageRequest = require('~/server/middleware/validateImageRequest');
|
||||
|
||||
// Mock only isEnabled, keep getBasePath real so it reads process.env.DOMAIN_CLIENT
|
||||
|
|
@ -6,8 +7,30 @@ jest.mock('@librechat/api', () => ({
|
|||
...jest.requireActual('@librechat/api'),
|
||||
isEnabled: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/models', () => ({
|
||||
findSession: jest.fn(),
|
||||
getAgent: jest.fn(),
|
||||
getAssistant: jest.fn(),
|
||||
getUserById: jest.fn(),
|
||||
getUserPrincipals: jest.fn(),
|
||||
hasCapabilityForPrincipals: jest.fn(),
|
||||
hasPermission: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getAppConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const {
|
||||
findSession,
|
||||
getAgent,
|
||||
getAssistant,
|
||||
getUserById,
|
||||
getUserPrincipals,
|
||||
hasCapabilityForPrincipals,
|
||||
hasPermission,
|
||||
} = require('~/models');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
|
||||
describe('validateImageRequest middleware', () => {
|
||||
let req, res, next, validateImageRequest;
|
||||
|
|
@ -20,6 +43,7 @@ describe('validateImageRequest middleware', () => {
|
|||
originalUrl: '',
|
||||
};
|
||||
res = {
|
||||
locals: {},
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn(),
|
||||
};
|
||||
|
|
@ -30,6 +54,14 @@ describe('validateImageRequest middleware', () => {
|
|||
|
||||
// Default: OpenID token reuse disabled
|
||||
isEnabled.mockReturnValue(false);
|
||||
getAgent.mockResolvedValue(null);
|
||||
getAssistant.mockResolvedValue(null);
|
||||
getAppConfig.mockResolvedValue({ endpoints: {} });
|
||||
getUserById.mockResolvedValue({ role: 'USER', tenantId: 'tenant-a', idOnTheSource: null });
|
||||
findSession.mockResolvedValue({ _id: 'session' });
|
||||
getUserPrincipals.mockResolvedValue([{ principalType: 'user', principalId: validObjectId }]);
|
||||
hasCapabilityForPrincipals.mockResolvedValue(false);
|
||||
hasPermission.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -44,12 +76,68 @@ describe('validateImageRequest middleware', () => {
|
|||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should return validation middleware if secureImageLinks is true', async () => {
|
||||
validateImageRequest = createValidateImageRequest(true);
|
||||
test('should protect images when secureImageLinks is omitted', async () => {
|
||||
validateImageRequest = createValidateImageRequest();
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith('Unauthorized');
|
||||
});
|
||||
|
||||
test('should honor an owner-scoped setting that disables image protection', async () => {
|
||||
getAppConfig.mockResolvedValue({ secureImageLinks: false, endpoints: {} });
|
||||
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/example.jpg';
|
||||
const middleware = createValidateImageRequest({ secureImageLinks: true });
|
||||
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(res.locals.privateImageCache).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should honor an owner-scoped setting that enables image protection', async () => {
|
||||
getAppConfig.mockResolvedValue({ secureImageLinks: true, endpoints: {} });
|
||||
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/example.jpg';
|
||||
const middleware = createValidateImageRequest({ secureImageLinks: false });
|
||||
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.locals.privateImageCache).toBe(true);
|
||||
});
|
||||
|
||||
test('should use the disabled fallback for an image without an owner layout', async () => {
|
||||
req.originalUrl = '/images/logo.png';
|
||||
const middleware = createValidateImageRequest({ secureImageLinks: false });
|
||||
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(getAppConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should use the disabled fallback when the path owner no longer exists', async () => {
|
||||
getUserById.mockResolvedValue(null);
|
||||
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/orphaned.png';
|
||||
const middleware = createValidateImageRequest({ secureImageLinks: false });
|
||||
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(getAppConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should normalize repeated separators before applying a disabled fallback', async () => {
|
||||
getAppConfig.mockResolvedValue({ secureImageLinks: true, endpoints: {} });
|
||||
req.originalUrl = '/images//65cfb246f7ecadb8b1e8036c/private.png';
|
||||
const middleware = createValidateImageRequest({ secureImageLinks: false });
|
||||
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(getAppConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Standard LibreChat token flow', () => {
|
||||
|
|
@ -92,6 +180,21 @@ describe('validateImageRequest middleware', () => {
|
|||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should reject a valid refresh token after its session is revoked', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
findSession.mockResolvedValue(null);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/images/${validObjectId}/example.jpg`;
|
||||
|
||||
await validateImageRequest(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
test('should return 403 for invalid image path', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
|
|
@ -104,15 +207,120 @@ describe('validateImageRequest middleware', () => {
|
|||
expect(res.send).toHaveBeenCalledWith('Access Denied');
|
||||
});
|
||||
|
||||
test('should allow agent avatar pattern for any valid ObjectId', async () => {
|
||||
test('should allow an agent avatar when the user has VIEW access', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/agent-avatar-12345.png';
|
||||
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/agent-agent_abc123-avatar-12345.png';
|
||||
getAgent.mockResolvedValue({ _id: '65cfb246f7ecadb8b1e8036c' });
|
||||
hasPermission.mockResolvedValue(true);
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(getUserPrincipals).toHaveBeenCalledTimes(1);
|
||||
expect(hasPermission).toHaveBeenLastCalledWith(
|
||||
[{ principalType: 'user', principalId: validObjectId }],
|
||||
'agent',
|
||||
'65cfb246f7ecadb8b1e8036c',
|
||||
1,
|
||||
);
|
||||
expect(getAgent).toHaveBeenCalledWith(
|
||||
{
|
||||
id: 'agent_abc123',
|
||||
'avatar.filepath': {
|
||||
$in: [
|
||||
'/images/65cfb246f7ecadb8b1e8036c/agent-agent_abc123-avatar-12345.png',
|
||||
'/images/65cfb246f7ecadb8b1e8036c/agent-agent_abc123-avatar-12345.png?manual=false',
|
||||
'/images/65cfb246f7ecadb8b1e8036c/agent-agent_abc123-avatar-12345.png?manual=true',
|
||||
],
|
||||
},
|
||||
},
|
||||
{ _id: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
test('should deny an agent avatar when the user lacks VIEW access', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/agent-agent_abc123-avatar-12345.png';
|
||||
getAgent.mockResolvedValue({ _id: '65cfb246f7ecadb8b1e8036c' });
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
test('should allow an agent avatar for a user who manages agents', async () => {
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/agent-agent_abc123-avatar-12345.png';
|
||||
getAgent.mockResolvedValue({ _id: '65cfb246f7ecadb8b1e8036c' });
|
||||
hasCapabilityForPrincipals.mockResolvedValue(true);
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(hasCapabilityForPrincipals).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tenantId: 'tenant-a' }),
|
||||
);
|
||||
expect(hasPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should allow an anonymous viewer to load a publicly viewable agent avatar', async () => {
|
||||
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/agent-agent_abc123-avatar-12345.png';
|
||||
getAgent.mockResolvedValue({ _id: '65cfb246f7ecadb8b1e8036c' });
|
||||
hasPermission.mockResolvedValue(true);
|
||||
|
||||
await validateImageRequest(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(getUserPrincipals).not.toHaveBeenCalled();
|
||||
expect(hasPermission).toHaveBeenCalledWith(
|
||||
[{ principalType: 'public' }],
|
||||
'agent',
|
||||
'65cfb246f7ecadb8b1e8036c',
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('should allow a shared assistant avatar for a different authenticated user', async () => {
|
||||
validateImageRequest = createValidateImageRequest({
|
||||
secureImageLinks: true,
|
||||
});
|
||||
getAppConfig.mockResolvedValue({
|
||||
endpoints: { assistants: { privateAssistants: false } },
|
||||
});
|
||||
const validToken = jwt.sign(
|
||||
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/assistant-avatar.png';
|
||||
getAssistant.mockResolvedValue({
|
||||
_id: '65cfb246f7ecadb8b1e8036d',
|
||||
assistant_id: 'asst_shared',
|
||||
endpoint: 'assistants',
|
||||
});
|
||||
|
||||
await validateImageRequest(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(getAssistant).toHaveBeenCalledWith(
|
||||
{
|
||||
'avatar.filepath': {
|
||||
$in: [
|
||||
'/images/65cfb246f7ecadb8b1e8036c/assistant-avatar.png',
|
||||
'/images/65cfb246f7ecadb8b1e8036c/assistant-avatar.png?manual=false',
|
||||
'/images/65cfb246f7ecadb8b1e8036c/assistant-avatar.png?manual=true',
|
||||
],
|
||||
},
|
||||
},
|
||||
{ _id: 1, assistant_id: 1, endpoint: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
test('should prevent file traversal attempts', async () => {
|
||||
|
|
@ -159,6 +367,7 @@ describe('validateImageRequest middleware', () => {
|
|||
// Enable OpenID token reuse
|
||||
isEnabled.mockReturnValue(true);
|
||||
process.env.OPENID_REUSE_TOKENS = 'true';
|
||||
req.session = { openidTokens: { refreshToken: 'dummy-token' } };
|
||||
});
|
||||
|
||||
test('should return 403 if no OpenID user ID cookie when token_provider is openid', async () => {
|
||||
|
|
@ -179,6 +388,29 @@ describe('validateImageRequest middleware', () => {
|
|||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should validate a refresh-bound user ID after the OpenID session expires', async () => {
|
||||
const refreshToken = 'dummy-token';
|
||||
const signedUserId = jwt.sign(
|
||||
{
|
||||
id: validObjectId,
|
||||
refreshTokenHash: createHash('sha256').update(refreshToken).digest('base64url'),
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
},
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.session = undefined;
|
||||
req.headers.cookie = `refreshToken=${refreshToken}; token_provider=openid; openid_user_id=${signedUserId}`;
|
||||
req.originalUrl = `/images/${validObjectId}/example.jpg`;
|
||||
|
||||
await validateImageRequest(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(findSession).toHaveBeenCalledWith({
|
||||
userId: validObjectId,
|
||||
refreshToken,
|
||||
});
|
||||
});
|
||||
|
||||
test('should return 403 for invalid JWT-signed user ID', async () => {
|
||||
req.headers.cookie =
|
||||
'refreshToken=dummy-token; token_provider=openid; openid_user_id=invalid-jwt';
|
||||
|
|
@ -217,7 +449,9 @@ describe('validateImageRequest middleware', () => {
|
|||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${signedUserId}`;
|
||||
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/agent-avatar-12345.png';
|
||||
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/agent-agent_abc123-avatar-12345.png';
|
||||
getAgent.mockResolvedValue({ _id: '65cfb246f7ecadb8b1e8036c' });
|
||||
hasPermission.mockResolvedValue(true);
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -332,7 +566,10 @@ describe('validateImageRequest middleware', () => {
|
|||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}`;
|
||||
req.originalUrl = `/librechat/images/${validObjectId}/agent-avatar.png`;
|
||||
req.originalUrl =
|
||||
'/librechat/images/65cfb246f7ecadb8b1e8036c/agent-agent_abc123-avatar-12345.png';
|
||||
getAgent.mockResolvedValue({ _id: '65cfb246f7ecadb8b1e8036c' });
|
||||
hasPermission.mockResolvedValue(true);
|
||||
|
||||
await validateImageRequest(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
|
|
@ -465,6 +702,7 @@ describe('validateImageRequest middleware', () => {
|
|||
process.env.JWT_REFRESH_SECRET,
|
||||
);
|
||||
req.headers.cookie = `refreshToken=${validToken}; token_provider=openid; openid_user_id=${validToken}`;
|
||||
req.session = { openidTokens: { refreshToken: validToken } };
|
||||
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`;
|
||||
|
||||
await validateImageRequest(req, res, next);
|
||||
|
|
|
|||
|
|
@ -1,162 +1,72 @@
|
|||
const cookies = require('cookie');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { isEnabled, getBasePath } = require('@librechat/api');
|
||||
const cookie = require('cookie');
|
||||
const {
|
||||
createImageAuthorizationMiddleware,
|
||||
getAppConfigOptionsFromUser,
|
||||
getBasePath,
|
||||
isEnabled,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
findSession,
|
||||
getAgent,
|
||||
getAssistant,
|
||||
getUserById,
|
||||
getUserPrincipals,
|
||||
hasCapabilityForPrincipals,
|
||||
hasPermission,
|
||||
} = require('~/models');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
|
||||
const OBJECT_ID_LENGTH = 24;
|
||||
const OBJECT_ID_PATTERN = /^[0-9a-f]{24}$/i;
|
||||
const getAssistantEndpointConfigs = (appConfig) =>
|
||||
[
|
||||
appConfig?.endpoints?.assistants && {
|
||||
endpoint: 'assistants',
|
||||
...appConfig.endpoints.assistants,
|
||||
},
|
||||
appConfig?.endpoints?.azureAssistants && {
|
||||
endpoint: 'azureAssistants',
|
||||
...appConfig.endpoints.azureAssistants,
|
||||
},
|
||||
].filter(Boolean);
|
||||
|
||||
/**
|
||||
* Validates if a string is a valid MongoDB ObjectId
|
||||
* @param {string} id - String to validate
|
||||
* @returns {boolean} - Whether string is a valid ObjectId format
|
||||
* Thin Express adapter for the typed image-authorization service in `@librechat/api`.
|
||||
* @param {boolean | {secureImageLinks?: boolean, assistantEndpoints?: object[]}} [config]
|
||||
*/
|
||||
function isValidObjectId(id) {
|
||||
if (typeof id !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (id.length !== OBJECT_ID_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
return OBJECT_ID_PATTERN.test(id);
|
||||
}
|
||||
function createValidateImageRequest(config = {}) {
|
||||
const resolveDynamicConfig = typeof config !== 'boolean';
|
||||
const options =
|
||||
typeof config === 'boolean'
|
||||
? { secureImageLinks: config }
|
||||
: {
|
||||
secureImageLinks: config.secureImageLinks,
|
||||
assistantEndpoints: config.assistantEndpoints,
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a LibreChat refresh token
|
||||
* @param {string} refreshToken - The refresh token to validate
|
||||
* @returns {{valid: boolean, userId?: string, error?: string}} - Validation result
|
||||
*/
|
||||
function validateToken(refreshToken) {
|
||||
try {
|
||||
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
|
||||
|
||||
if (!isValidObjectId(payload.id)) {
|
||||
return { valid: false, error: 'Invalid User ID' };
|
||||
}
|
||||
|
||||
const currentTimeInSeconds = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp < currentTimeInSeconds) {
|
||||
return { valid: false, error: 'Refresh token expired' };
|
||||
}
|
||||
|
||||
return { valid: true, userId: payload.id };
|
||||
} catch (err) {
|
||||
logger.warn('[validateToken]', err);
|
||||
return { valid: false, error: 'Invalid token' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory to create the `validateImageRequest` middleware with configured secureImageLinks
|
||||
* @param {boolean} [secureImageLinks] - Whether secure image links are enabled
|
||||
*/
|
||||
function createValidateImageRequest(secureImageLinks) {
|
||||
if (!secureImageLinks) {
|
||||
return (_req, _res, next) => next();
|
||||
}
|
||||
/**
|
||||
* Middleware to validate image request.
|
||||
* Supports both LibreChat refresh tokens and OpenID JWT tokens.
|
||||
* Must be set by `secureImageLinks` via custom config file.
|
||||
*/
|
||||
return async function validateImageRequest(req, res, next) {
|
||||
try {
|
||||
const cookieHeader = req.headers.cookie;
|
||||
if (!cookieHeader) {
|
||||
logger.warn('[validateImageRequest] No cookies provided');
|
||||
return res.status(401).send('Unauthorized');
|
||||
}
|
||||
|
||||
const parsedCookies = cookies.parse(cookieHeader);
|
||||
const tokenProvider = parsedCookies.token_provider;
|
||||
let userIdForPath;
|
||||
|
||||
if (tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
|
||||
/** For OpenID users with OPENID_REUSE_TOKENS, use openid_user_id cookie */
|
||||
const openidUserId = parsedCookies.openid_user_id;
|
||||
if (!openidUserId) {
|
||||
logger.warn('[validateImageRequest] No OpenID user ID cookie found');
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
|
||||
const validationResult = validateToken(openidUserId);
|
||||
if (!validationResult.valid) {
|
||||
logger.warn(`[validateImageRequest] ${validationResult.error}`);
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
userIdForPath = validationResult.userId;
|
||||
} else {
|
||||
/**
|
||||
* For non-OpenID users (or OpenID without REUSE_TOKENS), use refreshToken from cookies.
|
||||
* These users authenticate via setAuthTokens() which stores refreshToken in cookies.
|
||||
*/
|
||||
const refreshToken = parsedCookies.refreshToken;
|
||||
|
||||
if (!refreshToken) {
|
||||
logger.warn('[validateImageRequest] Token not provided');
|
||||
return res.status(401).send('Unauthorized');
|
||||
}
|
||||
|
||||
const validationResult = validateToken(refreshToken);
|
||||
if (!validationResult.valid) {
|
||||
logger.warn(`[validateImageRequest] ${validationResult.error}`);
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
userIdForPath = validationResult.userId;
|
||||
}
|
||||
|
||||
if (!userIdForPath) {
|
||||
logger.warn('[validateImageRequest] No user ID available for path validation');
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
|
||||
const MAX_URL_LENGTH = 2048;
|
||||
if (req.originalUrl.length > MAX_URL_LENGTH) {
|
||||
logger.warn('[validateImageRequest] URL too long');
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
|
||||
if (req.originalUrl.includes('\x00')) {
|
||||
logger.warn('[validateImageRequest] URL contains null byte');
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
|
||||
let fullPath;
|
||||
try {
|
||||
fullPath = decodeURIComponent(req.originalUrl);
|
||||
} catch {
|
||||
logger.warn('[validateImageRequest] Invalid URL encoding');
|
||||
return res.status(403).send('Access Denied');
|
||||
}
|
||||
|
||||
const basePath = getBasePath();
|
||||
const imagesPath = `${basePath}/images`;
|
||||
|
||||
const agentAvatarPattern = new RegExp(
|
||||
`^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[a-f0-9]{24}/agent-[^/]*$`,
|
||||
);
|
||||
if (agentAvatarPattern.test(fullPath)) {
|
||||
logger.debug('[validateImageRequest] Image request validated');
|
||||
return next();
|
||||
}
|
||||
|
||||
const escapedUserId = userIdForPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pathPattern = new RegExp(
|
||||
`^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/${escapedUserId}/[^/]+$`,
|
||||
);
|
||||
|
||||
if (pathPattern.test(fullPath)) {
|
||||
logger.debug('[validateImageRequest] Image request validated');
|
||||
next();
|
||||
} else {
|
||||
logger.warn('[validateImageRequest] Invalid image path');
|
||||
res.status(403).send('Access Denied');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[validateImageRequest] Error:', error);
|
||||
res.status(500).send('Internal Server Error');
|
||||
}
|
||||
const deps = {
|
||||
parseCookies: cookie.parse,
|
||||
isOpenIdReuseEnabled: () => isEnabled(process.env.OPENID_REUSE_TOKENS),
|
||||
getBasePath,
|
||||
findSession,
|
||||
getAgent,
|
||||
getAssistant,
|
||||
getUserById,
|
||||
getUserPrincipals,
|
||||
hasCapabilityForPrincipals,
|
||||
hasPermission,
|
||||
};
|
||||
if (resolveDynamicConfig) {
|
||||
deps.getImageConfig = async ({ userId, user }) => {
|
||||
const appConfig = await getAppConfig(
|
||||
getAppConfigOptionsFromUser({ ...user, id: userId }, user.tenantId),
|
||||
);
|
||||
return {
|
||||
secureImageLinks: appConfig.secureImageLinks,
|
||||
assistantEndpoints: getAssistantEndpointConfigs(appConfig),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
return createImageAuthorizationMiddleware(options, deps);
|
||||
}
|
||||
|
||||
module.exports = createValidateImageRequest;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { webcrypto } = require('node:crypto');
|
||||
const { createHash, webcrypto } = require('node:crypto');
|
||||
const {
|
||||
logger,
|
||||
getTenantId,
|
||||
|
|
@ -11,6 +11,7 @@ const { ErrorTypes, SystemRoles, errorsToString } = require('librechat-data-prov
|
|||
const {
|
||||
math,
|
||||
isEnabled,
|
||||
storeOpenIdSession,
|
||||
checkEmailConfig,
|
||||
setCloudFrontCookies,
|
||||
getCloudFrontConfig,
|
||||
|
|
@ -32,6 +33,7 @@ const {
|
|||
deleteTokens,
|
||||
deleteSession,
|
||||
createSession,
|
||||
upsertSession,
|
||||
generateToken,
|
||||
deleteUserById,
|
||||
generateRefreshToken,
|
||||
|
|
@ -826,10 +828,13 @@ const setOpenIDAuthTokens = (
|
|||
sameSite: 'strict',
|
||||
});
|
||||
if (userId && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
|
||||
/** JWT-signed user ID cookie for image path validation when OPENID_REUSE_TOKENS is enabled */
|
||||
const signedUserId = jwt.sign({ id: userId }, process.env.JWT_REFRESH_SECRET, {
|
||||
expiresIn: expiryInMilliseconds / 1000,
|
||||
});
|
||||
/** Bind image cookie identity to the durable refresh-token session. */
|
||||
const refreshTokenHash = createHash('sha256').update(refreshToken).digest('base64url');
|
||||
const signedUserId = jwt.sign(
|
||||
{ id: userId, refreshTokenHash },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
{ expiresIn: expiryInMilliseconds / 1000 },
|
||||
);
|
||||
res.cookie('openid_user_id', signedUserId, {
|
||||
expires: expirationDate,
|
||||
httpOnly: true,
|
||||
|
|
@ -847,6 +852,14 @@ const setOpenIDAuthTokens = (
|
|||
}
|
||||
};
|
||||
|
||||
/** Stores OpenID refresh-token state independently of the shorter Express session. */
|
||||
const storeOpenIDSession = async (userId, refreshToken, tenantId, previousRefreshToken) => {
|
||||
return storeOpenIdSession(
|
||||
{ userId, refreshToken, tenantId, previousRefreshToken },
|
||||
{ upsertSession, deleteSession },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resend Verification Email
|
||||
* @param {Object} req
|
||||
|
|
@ -915,6 +928,7 @@ module.exports = {
|
|||
setAuthTokens,
|
||||
resetPassword,
|
||||
setOpenIDAuthTokens,
|
||||
storeOpenIDSession,
|
||||
setCloudFrontAuthCookies,
|
||||
requestPasswordReset,
|
||||
resendVerificationEmail,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ jest.mock(
|
|||
'@librechat/data-schemas',
|
||||
() => ({
|
||||
logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
||||
runAsSystem: (callback) => callback(),
|
||||
getTenantId: jest.fn(() => undefined),
|
||||
DEFAULT_SESSION_EXPIRY: 900000,
|
||||
DEFAULT_REFRESH_TOKEN_EXPIRY: 604800000,
|
||||
|
|
@ -24,6 +25,7 @@ jest.mock(
|
|||
checkEmailConfig: jest.fn(),
|
||||
isEmailDomainAllowed: jest.fn(),
|
||||
math: jest.fn((val, fallback) => (val ? Number(val) : fallback)),
|
||||
storeOpenIdSession: jest.fn(),
|
||||
shouldUseSecureCookie: jest.fn(() => false),
|
||||
resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})),
|
||||
setCloudFrontCookies: jest.fn(() => true),
|
||||
|
|
@ -51,6 +53,7 @@ jest.mock('~/models', () => ({
|
|||
deleteTokens: jest.fn(),
|
||||
deleteSession: jest.fn(),
|
||||
createSession: jest.fn(),
|
||||
upsertSession: jest.fn(),
|
||||
generateToken: jest.fn(),
|
||||
deleteUserById: jest.fn(),
|
||||
generateRefreshToken: jest.fn(),
|
||||
|
|
@ -80,8 +83,10 @@ const {
|
|||
setCloudFrontCookies,
|
||||
getCloudFrontConfig,
|
||||
parseCloudFrontCookieScope,
|
||||
storeOpenIdSession,
|
||||
} = require('@librechat/api');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { logger, getTenantId } = require('@librechat/data-schemas');
|
||||
const {
|
||||
findUser,
|
||||
|
|
@ -93,6 +98,8 @@ const {
|
|||
generateToken,
|
||||
generateRefreshToken,
|
||||
createSession,
|
||||
upsertSession,
|
||||
deleteSession,
|
||||
createToken,
|
||||
deleteTokens,
|
||||
} = require('~/models');
|
||||
|
|
@ -101,6 +108,7 @@ const { sendEmail } = require('~/server/utils');
|
|||
const bcrypt = require('bcryptjs');
|
||||
const {
|
||||
setOpenIDAuthTokens,
|
||||
storeOpenIDSession,
|
||||
requestPasswordReset,
|
||||
registerUser,
|
||||
resetPassword,
|
||||
|
|
@ -309,6 +317,57 @@ describe('setOpenIDAuthTokens', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('binds the signed OpenID user cookie to its refresh token', () => {
|
||||
const tokenset = {
|
||||
id_token: 'the-id-token',
|
||||
access_token: 'the-access-token',
|
||||
refresh_token: 'the-refresh-token',
|
||||
};
|
||||
const req = mockRequest();
|
||||
const res = mockResponse();
|
||||
|
||||
setOpenIDAuthTokens(tokenset, req, res, 'user-123');
|
||||
|
||||
expect(jwt.verify(res._cookies.openid_user_id.value, process.env.JWT_REFRESH_SECRET)).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'user-123',
|
||||
refreshTokenHash: createHash('sha256').update(tokenset.refresh_token).digest('base64url'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('stores an OpenID refresh token for durable revocation checks', async () => {
|
||||
storeOpenIdSession.mockResolvedValue(true);
|
||||
|
||||
await storeOpenIDSession('user-123', 'the-refresh-token', 'tenant-a');
|
||||
|
||||
expect(storeOpenIdSession).toHaveBeenCalledWith(
|
||||
{
|
||||
userId: 'user-123',
|
||||
refreshToken: 'the-refresh-token',
|
||||
tenantId: 'tenant-a',
|
||||
previousRefreshToken: undefined,
|
||||
},
|
||||
{ upsertSession, deleteSession },
|
||||
);
|
||||
});
|
||||
|
||||
it('revokes the previous durable session when the IdP rotates the refresh token', async () => {
|
||||
storeOpenIdSession.mockResolvedValue(true);
|
||||
|
||||
await storeOpenIDSession('user-123', 'new-refresh-token', 'tenant-a', 'old-refresh-token');
|
||||
|
||||
expect(storeOpenIdSession).toHaveBeenCalledWith(
|
||||
{
|
||||
userId: 'user-123',
|
||||
refreshToken: 'new-refresh-token',
|
||||
tenantId: 'tenant-a',
|
||||
previousRefreshToken: 'old-refresh-token',
|
||||
},
|
||||
{ upsertSession, deleteSession },
|
||||
);
|
||||
});
|
||||
|
||||
describe('cookie secure flag', () => {
|
||||
it('should call shouldUseSecureCookie for every cookie set', () => {
|
||||
const tokenset = {
|
||||
|
|
|
|||
|
|
@ -132,6 +132,19 @@ describe('staticCache', () => {
|
|||
|
||||
expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate');
|
||||
});
|
||||
|
||||
it('should prevent shared caching when authorization marks an image private', async () => {
|
||||
app.use((_req, res, next) => {
|
||||
res.locals.privateImageCache = true;
|
||||
next();
|
||||
});
|
||||
app.use(staticCache(testDir));
|
||||
|
||||
const response = await request(app).get('/test.js').expect(200);
|
||||
|
||||
expect(response.headers['cache-control']).toBe('private, no-store');
|
||||
expect(response.headers.vary).toBe('Cookie');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache headers in non-production', () => {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ function staticCache(staticPath, options = {}) {
|
|||
const enableBrotli = isEnabled(process.env.ENABLE_STATIC_ASSET_BROTLI);
|
||||
|
||||
const setHeaders = (res, filePath) => {
|
||||
if (res.locals?.privateImageCache) {
|
||||
res.setHeader('Cache-Control', 'private, no-store');
|
||||
res.setHeader('Vary', 'Cookie');
|
||||
return;
|
||||
}
|
||||
if (process.env.NODE_ENV?.toLowerCase() !== 'production') {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ describe('AppService', () => {
|
|||
mcpConfig: null,
|
||||
imageOutputType: expect.any(String),
|
||||
fileConfig: undefined,
|
||||
secureImageLinks: undefined,
|
||||
secureImageLinks: true,
|
||||
balance: { enabled: true },
|
||||
filteredTools: undefined,
|
||||
includedTools: undefined,
|
||||
|
|
@ -163,6 +163,14 @@ describe('AppService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should require authentication for image links unless explicitly disabled', async () => {
|
||||
const secureResult = await AppService({ config: {} });
|
||||
const legacyResult = await AppService({ config: { secureImageLinks: false } });
|
||||
|
||||
expect(secureResult.secureImageLinks).toBe(true);
|
||||
expect(legacyResult.secureImageLinks).toBe(false);
|
||||
});
|
||||
|
||||
it('should default to `PNG` `imageOutputType` with no provided type', async () => {
|
||||
const config = {
|
||||
version: '0.10.0',
|
||||
|
|
|
|||
531
packages/api/src/images/authorization.spec.ts
Normal file
531
packages/api/src/images/authorization.spec.ts
Normal file
|
|
@ -0,0 +1,531 @@
|
|||
import jwt from 'jsonwebtoken';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import type { ImageAuthorizationDeps } from './authorization';
|
||||
import { createImageAuthorizationMiddleware } from './authorization';
|
||||
|
||||
const VIEWER_ID = '65cfb246f7ecadb8b1e8036b';
|
||||
const OWNER_ID = '65cfb246f7ecadb8b1e8036c';
|
||||
const AGENT_DB_ID = '65cfb246f7ecadb8b1e8036d';
|
||||
const AGENT_PATH = `/images/${OWNER_ID}/agent-agent_abc123-avatar-12345.png`;
|
||||
const USER_AVATAR_PATH = `/images/${OWNER_ID}/avatar-12345.png`;
|
||||
|
||||
function createResponse(): Response {
|
||||
const response = {
|
||||
locals: {},
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
};
|
||||
return response as unknown as Response;
|
||||
}
|
||||
|
||||
function createDeps(): ImageAuthorizationDeps {
|
||||
return {
|
||||
parseCookies: (header: string) =>
|
||||
Object.fromEntries(
|
||||
header.split(';').map((part: string) => {
|
||||
const [key, ...value] = part.trim().split('=');
|
||||
return [key, value.join('=')];
|
||||
}),
|
||||
),
|
||||
isOpenIdReuseEnabled: jest.fn().mockReturnValue(false),
|
||||
getBasePath: jest.fn().mockReturnValue(''),
|
||||
findSession: jest.fn().mockResolvedValue({ _id: 'active-session' }),
|
||||
getUserById: jest.fn().mockResolvedValue({
|
||||
role: 'USER',
|
||||
tenantId: 'tenant-a',
|
||||
idOnTheSource: null,
|
||||
}),
|
||||
getAgent: jest.fn().mockResolvedValue(null),
|
||||
getAssistant: jest.fn().mockResolvedValue(null),
|
||||
getUserPrincipals: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ principalType: 'user', principalId: VIEWER_ID }]),
|
||||
hasCapabilityForPrincipals: jest.fn().mockResolvedValue(false),
|
||||
hasPermission: jest.fn().mockResolvedValue(false),
|
||||
} as unknown as ImageAuthorizationDeps;
|
||||
}
|
||||
|
||||
function createRequest(path: string, cookie?: string): Request {
|
||||
return {
|
||||
originalUrl: path,
|
||||
headers: cookie ? { cookie } : {},
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
function signUser(userId: string): string {
|
||||
return jwt.sign({ id: userId }, process.env.JWT_REFRESH_SECRET as string, { expiresIn: '1h' });
|
||||
}
|
||||
|
||||
function signOpenIdUser(userId: string, refreshToken: string): string {
|
||||
return jwt.sign(
|
||||
{
|
||||
id: userId,
|
||||
refreshTokenHash: createHash('sha256').update(refreshToken).digest('base64url'),
|
||||
},
|
||||
process.env.JWT_REFRESH_SECRET as string,
|
||||
{ expiresIn: '1h' },
|
||||
);
|
||||
}
|
||||
|
||||
describe('createImageAuthorizationMiddleware', () => {
|
||||
let deps: ImageAuthorizationDeps;
|
||||
let response: Response;
|
||||
let next: jest.MockedFunction<NextFunction>;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.JWT_REFRESH_SECRET = 'image-authorization-secret';
|
||||
deps = createDeps();
|
||||
response = createResponse();
|
||||
next = jest.fn();
|
||||
});
|
||||
|
||||
it('authenticates an active session and allows the owner path', async () => {
|
||||
const token = signUser(VIEWER_ID);
|
||||
const middleware = createImageAuthorizationMiddleware({}, deps);
|
||||
|
||||
await middleware(
|
||||
createRequest(`/images/${VIEWER_ID}/profile.png`, `refreshToken=${token}`),
|
||||
response,
|
||||
next,
|
||||
);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(deps.findSession).toHaveBeenCalledWith({ userId: VIEWER_ID, refreshToken: token });
|
||||
});
|
||||
|
||||
it('rejects a signed refresh token whose session was revoked', async () => {
|
||||
(deps.findSession as jest.Mock).mockResolvedValue(null);
|
||||
const token = signUser(VIEWER_ID);
|
||||
const middleware = createImageAuthorizationMiddleware({}, deps);
|
||||
|
||||
await middleware(
|
||||
createRequest(`/images/${VIEWER_ID}/profile.png`, `refreshToken=${token}`),
|
||||
response,
|
||||
next,
|
||||
);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('authenticates a refresh-bound OpenID cookie after the Express session expires', async () => {
|
||||
const refreshToken = 'openid-refresh-token';
|
||||
const signedUserId = signOpenIdUser(VIEWER_ID, refreshToken);
|
||||
(deps.isOpenIdReuseEnabled as jest.Mock).mockReturnValue(true);
|
||||
const middleware = createImageAuthorizationMiddleware({}, deps);
|
||||
|
||||
await middleware(
|
||||
createRequest(
|
||||
`/images/${VIEWER_ID}/profile.png`,
|
||||
`refreshToken=${refreshToken}; token_provider=openid; openid_user_id=${signedUserId}`,
|
||||
),
|
||||
response,
|
||||
next,
|
||||
);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(deps.findSession).toHaveBeenCalledWith({
|
||||
userId: VIEWER_ID,
|
||||
refreshToken,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an OpenID identity cookie paired with a different refresh token', async () => {
|
||||
const signedUserId = signOpenIdUser(VIEWER_ID, 'expected-refresh-token');
|
||||
(deps.isOpenIdReuseEnabled as jest.Mock).mockReturnValue(true);
|
||||
const middleware = createImageAuthorizationMiddleware({}, deps);
|
||||
|
||||
await middleware(
|
||||
createRequest(
|
||||
`/images/${VIEWER_ID}/profile.png`,
|
||||
`refreshToken=different-refresh-token; token_provider=openid; openid_user_id=${signedUserId}`,
|
||||
),
|
||||
response,
|
||||
next,
|
||||
);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('rejects a refresh-bound OpenID cookie after its durable session is revoked', async () => {
|
||||
const refreshToken = 'revoked-openid-refresh-token';
|
||||
const signedUserId = signOpenIdUser(VIEWER_ID, refreshToken);
|
||||
(deps.isOpenIdReuseEnabled as jest.Mock).mockReturnValue(true);
|
||||
(deps.findSession as jest.Mock).mockResolvedValue(null);
|
||||
const middleware = createImageAuthorizationMiddleware({}, deps);
|
||||
|
||||
await middleware(
|
||||
createRequest(
|
||||
`/images/${VIEWER_ID}/profile.png`,
|
||||
`refreshToken=${refreshToken}; token_provider=openid; openid_user_id=${signedUserId}`,
|
||||
),
|
||||
response,
|
||||
next,
|
||||
);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('requires an active Express session for a legacy OpenID identity cookie', async () => {
|
||||
const refreshToken = 'legacy-refresh-token';
|
||||
const signedUserId = signUser(VIEWER_ID);
|
||||
(deps.isOpenIdReuseEnabled as jest.Mock).mockReturnValue(true);
|
||||
const request = {
|
||||
...createRequest(
|
||||
`/images/${VIEWER_ID}/profile.png`,
|
||||
`refreshToken=${refreshToken}; token_provider=openid; openid_user_id=${signedUserId}`,
|
||||
),
|
||||
session: { openidTokens: { refreshToken } },
|
||||
} as unknown as Request;
|
||||
const middleware = createImageAuthorizationMiddleware({}, deps);
|
||||
|
||||
await middleware(request, response, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses the disabled fallback for an image path without an owner layout', async () => {
|
||||
deps.getImageConfig = jest.fn();
|
||||
const middleware = createImageAuthorizationMiddleware({ secureImageLinks: false }, deps);
|
||||
|
||||
await middleware(createRequest('/images/logo.png'), response, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(deps.getUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the disabled fallback when the path owner no longer exists', async () => {
|
||||
deps.getImageConfig = jest.fn();
|
||||
(deps.getUserById as jest.Mock).mockResolvedValue(null);
|
||||
const middleware = createImageAuthorizationMiddleware({ secureImageLinks: false }, deps);
|
||||
|
||||
await middleware(createRequest(`/images/${OWNER_ID}/orphaned.png`), response, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(deps.getImageConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normalizes repeated separators before applying a disabled fallback', async () => {
|
||||
deps.getImageConfig = jest.fn().mockResolvedValue({ secureImageLinks: true });
|
||||
const middleware = createImageAuthorizationMiddleware({ secureImageLinks: false }, deps);
|
||||
|
||||
await middleware(createRequest(`/images//${OWNER_ID}/private.png`), response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(response.status).toHaveBeenCalledWith(401);
|
||||
expect(deps.getImageConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resolves principals once and applies the tenant to an agent capability check', async () => {
|
||||
(deps.getAgent as jest.Mock).mockResolvedValue({ _id: AGENT_DB_ID });
|
||||
(deps.hasPermission as jest.Mock).mockResolvedValue(true);
|
||||
const token = signUser(VIEWER_ID);
|
||||
const middleware = createImageAuthorizationMiddleware({}, deps);
|
||||
|
||||
await middleware(createRequest(AGENT_PATH, `refreshToken=${token}`), response, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(deps.getAgent).toHaveBeenCalledWith(
|
||||
{
|
||||
id: 'agent_abc123',
|
||||
'avatar.filepath': {
|
||||
$in: [AGENT_PATH, `${AGENT_PATH}?manual=false`, `${AGENT_PATH}?manual=true`],
|
||||
},
|
||||
},
|
||||
{ _id: 1 },
|
||||
);
|
||||
expect(deps.getUserPrincipals).toHaveBeenCalledTimes(1);
|
||||
expect(deps.hasCapabilityForPrincipals).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tenantId: 'tenant-a' }),
|
||||
);
|
||||
expect(deps.hasPermission).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('allows an anonymous request only when the agent has PUBLIC VIEW', async () => {
|
||||
(deps.getAgent as jest.Mock).mockResolvedValue({ _id: AGENT_DB_ID });
|
||||
(deps.hasPermission as jest.Mock).mockResolvedValue(true);
|
||||
const middleware = createImageAuthorizationMiddleware({}, deps);
|
||||
|
||||
await middleware(createRequest(AGENT_PATH), response, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(deps.getUserPrincipals).not.toHaveBeenCalled();
|
||||
expect(deps.hasPermission).toHaveBeenCalledWith(
|
||||
[{ principalType: 'public' }],
|
||||
'agent',
|
||||
AGENT_DB_ID,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not apply a viewer role or group grant across tenant boundaries', async () => {
|
||||
(deps.getAgent as jest.Mock).mockResolvedValue({ _id: AGENT_DB_ID });
|
||||
(deps.getUserById as jest.Mock)
|
||||
.mockResolvedValueOnce({ tenantId: 'tenant-a' })
|
||||
.mockResolvedValueOnce({ role: 'USER', tenantId: 'tenant-b', idOnTheSource: null });
|
||||
(deps.hasPermission as jest.Mock).mockResolvedValue(true);
|
||||
const token = signUser(VIEWER_ID);
|
||||
const middleware = createImageAuthorizationMiddleware({}, deps);
|
||||
|
||||
await middleware(createRequest(AGENT_PATH, `refreshToken=${token}`), response, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(deps.getUserPrincipals).not.toHaveBeenCalled();
|
||||
expect(deps.hasCapabilityForPrincipals).not.toHaveBeenCalled();
|
||||
expect(deps.hasPermission).toHaveBeenCalledWith(
|
||||
[{ principalType: 'public' }],
|
||||
'agent',
|
||||
AGENT_DB_ID,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves shared assistant avatars by matching their stored filepath', async () => {
|
||||
(deps.getAssistant as jest.Mock).mockResolvedValue({
|
||||
_id: '65cfb246f7ecadb8b1e8036e',
|
||||
assistant_id: 'asst_shared',
|
||||
endpoint: 'assistants',
|
||||
});
|
||||
const assistantPath = `/images/${OWNER_ID}/assistant-avatar.png`;
|
||||
const middleware = createImageAuthorizationMiddleware(
|
||||
{ assistantEndpoints: [{ endpoint: 'assistants', privateAssistants: false }] },
|
||||
deps,
|
||||
);
|
||||
const token = signUser(VIEWER_ID);
|
||||
|
||||
await middleware(createRequest(assistantPath, `refreshToken=${token}`), response, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(deps.getAssistant).toHaveBeenCalledWith(
|
||||
{
|
||||
'avatar.filepath': {
|
||||
$in: [assistantPath, `${assistantPath}?manual=false`, `${assistantPath}?manual=true`],
|
||||
},
|
||||
},
|
||||
{ _id: 1, assistant_id: 1, endpoint: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
it('requires authentication for an otherwise shared assistant avatar', async () => {
|
||||
(deps.getAssistant as jest.Mock).mockResolvedValue({
|
||||
_id: '65cfb246f7ecadb8b1e8036e',
|
||||
assistant_id: 'asst_shared',
|
||||
endpoint: 'assistants',
|
||||
});
|
||||
const assistantPath = `/images/${OWNER_ID}/assistant-avatar.png`;
|
||||
const middleware = createImageAuthorizationMiddleware(
|
||||
{ assistantEndpoints: [{ endpoint: 'assistants', privateAssistants: false }] },
|
||||
deps,
|
||||
);
|
||||
|
||||
await middleware(createRequest(assistantPath), response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(response.status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
|
||||
it('does not use a shared endpoint policy for a private endpoint assistant', async () => {
|
||||
(deps.getAssistant as jest.Mock).mockResolvedValue({
|
||||
_id: '65cfb246f7ecadb8b1e8036e',
|
||||
assistant_id: 'asst_private',
|
||||
endpoint: 'azureAssistants',
|
||||
});
|
||||
const assistantPath = `/images/${OWNER_ID}/assistant-avatar.png`;
|
||||
const middleware = createImageAuthorizationMiddleware(
|
||||
{
|
||||
assistantEndpoints: [
|
||||
{ endpoint: 'assistants', privateAssistants: false },
|
||||
{ endpoint: 'azureAssistants', privateAssistants: true },
|
||||
],
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
await middleware(createRequest(assistantPath), response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(response.status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
|
||||
it('allows an authenticated same-tenant viewer to load the stored user avatar', async () => {
|
||||
(deps.getUserById as jest.Mock)
|
||||
.mockResolvedValueOnce({
|
||||
tenantId: 'tenant-a',
|
||||
avatar: `${USER_AVATAR_PATH}?manual=true`,
|
||||
})
|
||||
.mockResolvedValueOnce({ tenantId: 'tenant-a' });
|
||||
const token = signUser(VIEWER_ID);
|
||||
const middleware = createImageAuthorizationMiddleware({}, deps);
|
||||
|
||||
await middleware(createRequest(USER_AVATAR_PATH, `refreshToken=${token}`), response, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(deps.getUserById).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
OWNER_ID,
|
||||
'role tenantId idOnTheSource avatar',
|
||||
);
|
||||
expect(deps.getUserById).toHaveBeenNthCalledWith(2, VIEWER_ID, 'tenantId');
|
||||
});
|
||||
|
||||
it('denies the stored user avatar to a viewer from another tenant', async () => {
|
||||
(deps.getUserById as jest.Mock)
|
||||
.mockResolvedValueOnce({
|
||||
tenantId: 'tenant-a',
|
||||
avatar: `${USER_AVATAR_PATH}?manual=true`,
|
||||
})
|
||||
.mockResolvedValueOnce({ tenantId: 'tenant-b' });
|
||||
const token = signUser(VIEWER_ID);
|
||||
const middleware = createImageAuthorizationMiddleware({}, deps);
|
||||
|
||||
await middleware(createRequest(USER_AVATAR_PATH, `refreshToken=${token}`), response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('normalizes a stored root image URL when the app uses a base path', async () => {
|
||||
(deps.getBasePath as jest.Mock).mockReturnValue('/chat');
|
||||
(deps.getUserById as jest.Mock)
|
||||
.mockResolvedValueOnce({
|
||||
tenantId: 'tenant-a',
|
||||
avatar: `${USER_AVATAR_PATH}?manual=true`,
|
||||
})
|
||||
.mockResolvedValueOnce({ tenantId: 'tenant-a' });
|
||||
const token = signUser(VIEWER_ID);
|
||||
const middleware = createImageAuthorizationMiddleware({}, deps);
|
||||
|
||||
await middleware(
|
||||
createRequest(`/chat${USER_AVATAR_PATH}`, `refreshToken=${token}`),
|
||||
response,
|
||||
next,
|
||||
);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses the owner principal endpoint config instead of the startup fallback', async () => {
|
||||
(deps.getAssistant as jest.Mock).mockResolvedValue({
|
||||
_id: '65cfb246f7ecadb8b1e8036e',
|
||||
assistant_id: 'asst_private',
|
||||
endpoint: 'assistants',
|
||||
});
|
||||
deps.getImageConfig = jest.fn().mockResolvedValue({
|
||||
secureImageLinks: true,
|
||||
assistantEndpoints: [{ endpoint: 'assistants', privateAssistants: true }],
|
||||
});
|
||||
const assistantPath = `/images/${OWNER_ID}/assistant-avatar.png`;
|
||||
const middleware = createImageAuthorizationMiddleware(
|
||||
{ assistantEndpoints: [{ endpoint: 'assistants', privateAssistants: false }] },
|
||||
deps,
|
||||
);
|
||||
|
||||
await middleware(createRequest(assistantPath), response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(response.status).toHaveBeenCalledWith(401);
|
||||
expect(deps.getImageConfig).toHaveBeenCalledWith({
|
||||
userId: OWNER_ID,
|
||||
user: expect.objectContaining({ tenantId: 'tenant-a' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('starts protected image authentication while owner config is resolving', async () => {
|
||||
let resolveConfig: ((config: { secureImageLinks: boolean }) => void) | undefined;
|
||||
deps.getImageConfig = jest.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveConfig = resolve;
|
||||
}),
|
||||
);
|
||||
const token = signUser(VIEWER_ID);
|
||||
const middleware = createImageAuthorizationMiddleware({}, deps);
|
||||
const pending = middleware(
|
||||
createRequest(`/images/${VIEWER_ID}/profile.png`, `refreshToken=${token}`),
|
||||
response,
|
||||
next,
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(deps.getImageConfig).toHaveBeenCalledTimes(1);
|
||||
expect(deps.findSession).toHaveBeenCalledWith({ userId: VIEWER_ID, refreshToken: token });
|
||||
resolveConfig?.({ secureImageLinks: true });
|
||||
await pending;
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('honors an owner-scoped setting that disables secure image links', async () => {
|
||||
deps.getImageConfig = jest.fn().mockResolvedValue({
|
||||
secureImageLinks: false,
|
||||
assistantEndpoints: [],
|
||||
});
|
||||
const middleware = createImageAuthorizationMiddleware({}, deps);
|
||||
|
||||
await middleware(createRequest(`/images/${OWNER_ID}/private.png`), response, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(response.locals.privateImageCache).toBeUndefined();
|
||||
});
|
||||
|
||||
it('honors an owner-scoped setting that enables protection over a disabled fallback', async () => {
|
||||
deps.getImageConfig = jest.fn().mockResolvedValue({
|
||||
secureImageLinks: true,
|
||||
assistantEndpoints: [],
|
||||
});
|
||||
const middleware = createImageAuthorizationMiddleware({ secureImageLinks: false }, deps);
|
||||
|
||||
await middleware(createRequest(`/images/${OWNER_ID}/private.png`), response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(response.status).toHaveBeenCalledWith(401);
|
||||
expect(response.locals.privateImageCache).toBe(true);
|
||||
});
|
||||
|
||||
it('decodes an encoded deployment base path consistently with the request URL', async () => {
|
||||
(deps.getBasePath as jest.Mock).mockReturnValue('/libre%20chat');
|
||||
const token = signUser(VIEWER_ID);
|
||||
const middleware = createImageAuthorizationMiddleware({}, deps);
|
||||
|
||||
await middleware(
|
||||
createRequest(`/libre%20chat/images/${VIEWER_ID}/profile.png`, `refreshToken=${token}`),
|
||||
response,
|
||||
next,
|
||||
);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marks protected image responses as private before serving them', async () => {
|
||||
const token = signUser(VIEWER_ID);
|
||||
const middleware = createImageAuthorizationMiddleware({}, deps);
|
||||
|
||||
await middleware(
|
||||
createRequest(`/images/${VIEWER_ID}/profile.png`, `refreshToken=${token}`),
|
||||
response,
|
||||
next,
|
||||
);
|
||||
|
||||
expect(response.locals.privateImageCache).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects encoded traversal before any resource lookup', async () => {
|
||||
const token = signUser(VIEWER_ID);
|
||||
const middleware = createImageAuthorizationMiddleware({}, deps);
|
||||
|
||||
await middleware(
|
||||
createRequest(`/images/${VIEWER_ID}/..%2F..%2Fetc%2Fpasswd`, `refreshToken=${token}`),
|
||||
response,
|
||||
next,
|
||||
);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
expect(deps.getAgent).not.toHaveBeenCalled();
|
||||
expect(deps.getAssistant).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
523
packages/api/src/images/authorization.ts
Normal file
523
packages/api/src/images/authorization.ts
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import jwt, { type JwtPayload } from 'jsonwebtoken';
|
||||
import { PermissionBits, PrincipalType, ResourceType } from 'librechat-data-provider';
|
||||
import {
|
||||
ResourceCapabilityMap,
|
||||
SystemCapabilities,
|
||||
logger,
|
||||
runAsSystem,
|
||||
tenantStorage,
|
||||
} from '@librechat/data-schemas';
|
||||
import type { IAgent, IAssistant, SystemCapability } from '@librechat/data-schemas';
|
||||
import type { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
import type { FilterQuery, ProjectionType, Types } from 'mongoose';
|
||||
|
||||
const MAX_URL_LENGTH = 2048;
|
||||
const OBJECT_ID_PATTERN = /^[0-9a-f]{24}$/i;
|
||||
const AGENT_AVATAR_PATTERN = /^agent-(.+)-avatar-\d+\.[^/]+$/;
|
||||
|
||||
type Principal = {
|
||||
principalType: PrincipalType;
|
||||
principalId?: string | Types.ObjectId;
|
||||
};
|
||||
|
||||
type ImageUser = {
|
||||
role?: string | null;
|
||||
tenantId?: string;
|
||||
idOnTheSource?: string | null;
|
||||
avatar?: string | null;
|
||||
};
|
||||
|
||||
type AssistantConfig = {
|
||||
endpoint: string;
|
||||
privateAssistants?: boolean;
|
||||
supportedIds?: string[];
|
||||
excludedIds?: string[];
|
||||
};
|
||||
|
||||
type ImagePath = {
|
||||
ownerId: string;
|
||||
filename: string;
|
||||
canonicalPath: string;
|
||||
agentId?: string;
|
||||
};
|
||||
|
||||
type AssistantImageRecord = Pick<IAssistant, '_id' | 'assistant_id'> & {
|
||||
endpoint?: string;
|
||||
};
|
||||
|
||||
type ResolvedImageConfig = {
|
||||
secureImageLinks: boolean;
|
||||
assistantEndpoints: AssistantConfig[];
|
||||
};
|
||||
|
||||
type CookieAuthResult =
|
||||
| { status: 'missing' }
|
||||
| { status: 'invalid' }
|
||||
| { status: 'authenticated'; userId: string };
|
||||
|
||||
type OpenIdCookieAuthResult =
|
||||
| { status: 'invalid' }
|
||||
| { status: 'legacy'; userId: string }
|
||||
| { status: 'authenticated'; userId: string };
|
||||
|
||||
export interface ImageAuthorizationDeps {
|
||||
parseCookies: (cookieHeader: string) => Record<string, string | undefined>;
|
||||
isOpenIdReuseEnabled: () => boolean;
|
||||
getBasePath: () => string;
|
||||
findSession: (query: { userId: string; refreshToken: string }) => Promise<unknown | null>;
|
||||
getUserById: (userId: string, select: string) => Promise<ImageUser | null>;
|
||||
getAgent: (
|
||||
query: FilterQuery<IAgent>,
|
||||
projection?: ProjectionType<IAgent>,
|
||||
) => Promise<Pick<IAgent, '_id'> | null>;
|
||||
getAssistant: (
|
||||
query: FilterQuery<IAssistant>,
|
||||
projection?: ProjectionType<IAssistant>,
|
||||
) => Promise<AssistantImageRecord | null>;
|
||||
getImageConfig?: (params: { userId: string; user: ImageUser }) => Promise<{
|
||||
secureImageLinks?: boolean;
|
||||
assistantEndpoints?: AssistantConfig[];
|
||||
}>;
|
||||
getUserPrincipals: (params: {
|
||||
userId: string;
|
||||
role?: string | null;
|
||||
idOnTheSource?: string | null;
|
||||
}) => Promise<Principal[]>;
|
||||
hasCapabilityForPrincipals: (params: {
|
||||
principals: Principal[];
|
||||
capability: SystemCapability;
|
||||
tenantId?: string;
|
||||
}) => Promise<boolean>;
|
||||
hasPermission: (
|
||||
principals: Principal[],
|
||||
resourceType: ResourceType,
|
||||
resourceId: string | Types.ObjectId,
|
||||
permissionBit: number,
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ImageAuthorizationOptions {
|
||||
secureImageLinks?: boolean;
|
||||
assistantEndpoints?: AssistantConfig[];
|
||||
}
|
||||
|
||||
type ImageRequest = Request & {
|
||||
session?: Request['session'] & {
|
||||
openidTokens?: { refreshToken?: string };
|
||||
};
|
||||
};
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function parseImagePath(originalUrl: string, basePath: string): ImagePath | null {
|
||||
if (!originalUrl || originalUrl.length > MAX_URL_LENGTH || originalUrl.includes('\0')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let decodedUrl: string;
|
||||
try {
|
||||
decodedUrl = decodeURIComponent(originalUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (decodedUrl.includes('\0')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleanPath = decodedUrl.split(/[?#]/, 1)[0];
|
||||
let decodedBasePath: string;
|
||||
try {
|
||||
decodedBasePath = decodeURIComponent(basePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const imagesPath = `${decodedBasePath}/images`;
|
||||
const imagesPrefix = `${imagesPath}/`;
|
||||
const normalizedPath = cleanPath.startsWith(imagesPrefix)
|
||||
? `${imagesPrefix}${cleanPath.slice(imagesPrefix.length).replace(/^\/+/, '')}`
|
||||
: cleanPath;
|
||||
const match = normalizedPath.match(
|
||||
new RegExp(`^${escapeRegExp(imagesPath)}/([a-f0-9]{24})/([^/]+)$`, 'i'),
|
||||
);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, ownerId, filename] = match;
|
||||
if (filename === '.' || filename === '..' || filename.includes('\\')) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
ownerId,
|
||||
filename,
|
||||
canonicalPath: `/images/${ownerId}/${filename}`,
|
||||
agentId: filename.match(AGENT_AVATAR_PATTERN)?.[1],
|
||||
};
|
||||
}
|
||||
|
||||
function getSignedUserId(token: string | undefined): string | null {
|
||||
const secret = process.env.JWT_REFRESH_SECRET;
|
||||
if (!token || !secret) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const payload = jwt.verify(token, secret) as JwtPayload;
|
||||
return typeof payload.id === 'string' && OBJECT_ID_PATTERN.test(payload.id) ? payload.id : null;
|
||||
} catch (error) {
|
||||
logger.warn('[imageAuthorization] Invalid signed user token', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSignedOpenIdUserId(
|
||||
token: string | undefined,
|
||||
refreshToken: string,
|
||||
): OpenIdCookieAuthResult {
|
||||
const secret = process.env.JWT_REFRESH_SECRET;
|
||||
if (!token || !secret) {
|
||||
return { status: 'invalid' };
|
||||
}
|
||||
try {
|
||||
const payload = jwt.verify(token, secret) as JwtPayload;
|
||||
if (typeof payload.id !== 'string' || !OBJECT_ID_PATTERN.test(payload.id)) {
|
||||
return { status: 'invalid' };
|
||||
}
|
||||
if (typeof payload.refreshTokenHash !== 'string') {
|
||||
return { status: 'legacy', userId: payload.id };
|
||||
}
|
||||
const refreshTokenHash = createHash('sha256').update(refreshToken).digest('base64url');
|
||||
return payload.refreshTokenHash === refreshTokenHash
|
||||
? { status: 'authenticated', userId: payload.id }
|
||||
: { status: 'invalid' };
|
||||
} catch (error) {
|
||||
logger.warn('[imageAuthorization] Invalid signed OpenID user token', error);
|
||||
return { status: 'invalid' };
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredPathCandidates(canonicalPath: string): string[] {
|
||||
return [canonicalPath, `${canonicalPath}?manual=false`, `${canonicalPath}?manual=true`];
|
||||
}
|
||||
|
||||
async function authenticateRequest(
|
||||
req: ImageRequest,
|
||||
deps: ImageAuthorizationDeps,
|
||||
): Promise<CookieAuthResult> {
|
||||
const cookieHeader = req.headers.cookie;
|
||||
if (!cookieHeader) {
|
||||
return { status: 'missing' };
|
||||
}
|
||||
|
||||
let parsed: Record<string, string | undefined>;
|
||||
try {
|
||||
parsed = deps.parseCookies(cookieHeader);
|
||||
} catch {
|
||||
return { status: 'invalid' };
|
||||
}
|
||||
|
||||
const refreshToken = parsed.refreshToken;
|
||||
if (!refreshToken) {
|
||||
return { status: 'missing' };
|
||||
}
|
||||
|
||||
if (parsed.token_provider === 'openid' && deps.isOpenIdReuseEnabled()) {
|
||||
const openIdAuth = getSignedOpenIdUserId(parsed.openid_user_id, refreshToken);
|
||||
if (openIdAuth.status === 'invalid') {
|
||||
return { status: 'invalid' };
|
||||
}
|
||||
if (openIdAuth.status === 'legacy') {
|
||||
return refreshToken === req.session?.openidTokens?.refreshToken
|
||||
? { status: 'authenticated', userId: openIdAuth.userId }
|
||||
: { status: 'invalid' };
|
||||
}
|
||||
const session = await runAsSystem(() =>
|
||||
deps.findSession({ userId: openIdAuth.userId, refreshToken }),
|
||||
);
|
||||
return session ? openIdAuth : { status: 'invalid' };
|
||||
}
|
||||
|
||||
const userId = getSignedUserId(refreshToken);
|
||||
if (!userId) {
|
||||
return { status: 'invalid' };
|
||||
}
|
||||
const session = await runAsSystem(() => deps.findSession({ userId, refreshToken }));
|
||||
return session ? { status: 'authenticated', userId } : { status: 'invalid' };
|
||||
}
|
||||
|
||||
function isSharedAssistant(assistant: AssistantImageRecord, configs: AssistantConfig[]): boolean {
|
||||
let matchingConfigs: AssistantConfig[] = [];
|
||||
if (assistant.endpoint) {
|
||||
matchingConfigs = configs.filter((config) => config.endpoint === assistant.endpoint);
|
||||
} else if (configs.length === 1) {
|
||||
matchingConfigs = configs;
|
||||
}
|
||||
|
||||
return matchingConfigs.some((config) => {
|
||||
if (config.privateAssistants) {
|
||||
return false;
|
||||
}
|
||||
if (config.supportedIds?.length) {
|
||||
return config.supportedIds.includes(assistant.assistant_id);
|
||||
}
|
||||
if (config.excludedIds?.length) {
|
||||
return !config.excludedIds.includes(assistant.assistant_id);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPrincipals(
|
||||
userId: string,
|
||||
user: ImageUser,
|
||||
deps: ImageAuthorizationDeps,
|
||||
): Promise<Principal[]> {
|
||||
return deps.getUserPrincipals({
|
||||
userId,
|
||||
role: user.role,
|
||||
idOnTheSource: user.idOnTheSource ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function canViewAgentAvatar(
|
||||
imagePath: ImagePath,
|
||||
viewerId: string | undefined,
|
||||
owner: ImageUser,
|
||||
deps: ImageAuthorizationDeps,
|
||||
): Promise<boolean> {
|
||||
const agent = await deps.getAgent(
|
||||
{
|
||||
id: imagePath.agentId,
|
||||
'avatar.filepath': { $in: getStoredPathCandidates(imagePath.canonicalPath) },
|
||||
},
|
||||
{ _id: 1 },
|
||||
);
|
||||
if (!agent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const publicPrincipal: Principal[] = [{ principalType: PrincipalType.PUBLIC }];
|
||||
if (!viewerId) {
|
||||
return deps.hasPermission(publicPrincipal, ResourceType.AGENT, agent._id, PermissionBits.VIEW);
|
||||
}
|
||||
|
||||
const viewer = await runAsSystem(() => deps.getUserById(viewerId, 'role tenantId idOnTheSource'));
|
||||
if (!viewer || viewer.tenantId !== owner.tenantId) {
|
||||
return deps.hasPermission(publicPrincipal, ResourceType.AGENT, agent._id, PermissionBits.VIEW);
|
||||
}
|
||||
|
||||
const principals = await loadPrincipals(viewerId, viewer, deps);
|
||||
let managesAgents = false;
|
||||
try {
|
||||
managesAgents = await deps.hasCapabilityForPrincipals({
|
||||
principals,
|
||||
capability: ResourceCapabilityMap[ResourceType.AGENT],
|
||||
tenantId: owner.tenantId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('[imageAuthorization] Agent capability check failed', error);
|
||||
}
|
||||
return (
|
||||
managesAgents ||
|
||||
(await deps.hasPermission(principals, ResourceType.AGENT, agent._id, PermissionBits.VIEW))
|
||||
);
|
||||
}
|
||||
|
||||
async function canViewAssistantAvatar(
|
||||
imagePath: ImagePath,
|
||||
viewerId: string | undefined,
|
||||
owner: ImageUser,
|
||||
configs: AssistantConfig[],
|
||||
deps: ImageAuthorizationDeps,
|
||||
): Promise<boolean> {
|
||||
const assistant = await deps.getAssistant(
|
||||
{ 'avatar.filepath': { $in: getStoredPathCandidates(imagePath.canonicalPath) } },
|
||||
{ _id: 1, assistant_id: 1, endpoint: 1 },
|
||||
);
|
||||
if (!assistant) {
|
||||
return false;
|
||||
}
|
||||
if (!viewerId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const viewer = await runAsSystem(() => deps.getUserById(viewerId, 'role tenantId idOnTheSource'));
|
||||
if (!viewer || viewer.tenantId !== owner.tenantId) {
|
||||
return false;
|
||||
}
|
||||
if (isSharedAssistant(assistant, configs)) {
|
||||
return true;
|
||||
}
|
||||
const principals = await loadPrincipals(viewerId, viewer, deps);
|
||||
try {
|
||||
return await deps.hasCapabilityForPrincipals({
|
||||
principals,
|
||||
capability: SystemCapabilities.MANAGE_ASSISTANTS,
|
||||
tenantId: owner.tenantId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('[imageAuthorization] Assistant capability check failed', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveImageConfig(
|
||||
ownerId: string,
|
||||
owner: ImageUser,
|
||||
options: ImageAuthorizationOptions,
|
||||
deps: ImageAuthorizationDeps,
|
||||
): Promise<ResolvedImageConfig> {
|
||||
if (!deps.getImageConfig) {
|
||||
return {
|
||||
secureImageLinks: options.secureImageLinks !== false,
|
||||
assistantEndpoints: options.assistantEndpoints ?? [],
|
||||
};
|
||||
}
|
||||
try {
|
||||
const config = await deps.getImageConfig({ userId: ownerId, user: owner });
|
||||
return {
|
||||
secureImageLinks: config.secureImageLinks !== false,
|
||||
assistantEndpoints: config.assistantEndpoints ?? [],
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn('[imageAuthorization] Image config resolution failed', error);
|
||||
return { secureImageLinks: true, assistantEndpoints: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function isStoredUserAvatar(
|
||||
imagePath: ImagePath,
|
||||
owner: ImageUser,
|
||||
deps: ImageAuthorizationDeps,
|
||||
): boolean {
|
||||
if (typeof owner.avatar !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const storedAvatar =
|
||||
parseImagePath(owner.avatar, deps.getBasePath()) ?? parseImagePath(owner.avatar, '');
|
||||
return storedAvatar?.canonicalPath === imagePath.canonicalPath;
|
||||
}
|
||||
|
||||
async function canViewUserAvatar(
|
||||
viewerId: string | undefined,
|
||||
owner: ImageUser,
|
||||
deps: ImageAuthorizationDeps,
|
||||
): Promise<boolean> {
|
||||
if (!viewerId) {
|
||||
return false;
|
||||
}
|
||||
const viewer = await runAsSystem(() => deps.getUserById(viewerId, 'tenantId'));
|
||||
return viewer != null && viewer.tenantId === owner.tenantId;
|
||||
}
|
||||
|
||||
function denyRequest(res: Response, auth: CookieAuthResult): void {
|
||||
if (auth.status === 'missing') {
|
||||
res.status(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
res.status(403).send('Access Denied');
|
||||
}
|
||||
|
||||
export function createImageAuthorizationMiddleware(
|
||||
options: ImageAuthorizationOptions,
|
||||
deps: ImageAuthorizationDeps,
|
||||
): RequestHandler {
|
||||
if (!deps.getImageConfig && options.secureImageLinks === false) {
|
||||
return (_req: Request, _res: Response, next: NextFunction): void => next();
|
||||
}
|
||||
|
||||
return async function authorizeImageRequest(
|
||||
req: ImageRequest,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const imagePath = parseImagePath(req.originalUrl, deps.getBasePath());
|
||||
if (!imagePath) {
|
||||
if (options.secureImageLinks === false) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const auth = await authenticateRequest(req, deps);
|
||||
denyRequest(res, auth);
|
||||
return;
|
||||
}
|
||||
|
||||
const owner = await runAsSystem(() =>
|
||||
deps.getUserById(imagePath.ownerId, 'role tenantId idOnTheSource avatar'),
|
||||
);
|
||||
if (!owner) {
|
||||
if (options.secureImageLinks === false) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const auth = await authenticateRequest(req, deps);
|
||||
denyRequest(res, auth);
|
||||
return;
|
||||
}
|
||||
|
||||
const authPromise = authenticateRequest(req, deps);
|
||||
void authPromise.catch(() => undefined);
|
||||
const loadImageConfig = (): Promise<ResolvedImageConfig> =>
|
||||
resolveImageConfig(imagePath.ownerId, owner, options, deps);
|
||||
const imageConfig = owner.tenantId
|
||||
? await tenantStorage.run(
|
||||
{ tenantId: owner.tenantId, userId: imagePath.ownerId },
|
||||
loadImageConfig,
|
||||
)
|
||||
: await runAsSystem(loadImageConfig);
|
||||
if (!imageConfig.secureImageLinks) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
res.locals.privateImageCache = true;
|
||||
const auth = await authPromise;
|
||||
const viewerId = auth.status === 'authenticated' ? auth.userId : undefined;
|
||||
if (viewerId === imagePath.ownerId) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const authorizeSpecialAvatar = async (): Promise<boolean> => {
|
||||
if (imagePath.agentId) {
|
||||
return canViewAgentAvatar(imagePath, viewerId, owner, deps);
|
||||
}
|
||||
if (isStoredUserAvatar(imagePath, owner, deps)) {
|
||||
return canViewUserAvatar(viewerId, owner, deps);
|
||||
}
|
||||
if (imageConfig.assistantEndpoints.length > 0) {
|
||||
const canViewAssistant = await canViewAssistantAvatar(
|
||||
imagePath,
|
||||
viewerId,
|
||||
owner,
|
||||
imageConfig.assistantEndpoints,
|
||||
deps,
|
||||
);
|
||||
if (canViewAssistant) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const allowed = owner.tenantId
|
||||
? await tenantStorage.run(
|
||||
{ tenantId: owner.tenantId, userId: viewerId ?? imagePath.ownerId },
|
||||
authorizeSpecialAvatar,
|
||||
)
|
||||
: await runAsSystem(authorizeSpecialAvatar);
|
||||
if (allowed) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
denyRequest(res, auth);
|
||||
} catch (error) {
|
||||
logger.error('[imageAuthorization] Error authorizing image request', error);
|
||||
res.status(500).send('Internal Server Error');
|
||||
}
|
||||
};
|
||||
}
|
||||
2
packages/api/src/images/index.ts
Normal file
2
packages/api/src/images/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './authorization';
|
||||
export * from './session';
|
||||
58
packages/api/src/images/session.spec.ts
Normal file
58
packages/api/src/images/session.spec.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { storeOpenIdSession } from './session';
|
||||
|
||||
describe('storeOpenIdSession', () => {
|
||||
const originalRefreshTokenExpiry = process.env.REFRESH_TOKEN_EXPIRY;
|
||||
const deps = {
|
||||
upsertSession: jest.fn(),
|
||||
deleteSession: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delete process.env.REFRESH_TOKEN_EXPIRY;
|
||||
deps.upsertSession.mockResolvedValue({ _id: 'session-id' });
|
||||
deps.deleteSession.mockResolvedValue({ deletedCount: 1 });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalRefreshTokenExpiry === undefined) {
|
||||
delete process.env.REFRESH_TOKEN_EXPIRY;
|
||||
return;
|
||||
}
|
||||
process.env.REFRESH_TOKEN_EXPIRY = originalRefreshTokenExpiry;
|
||||
});
|
||||
|
||||
it('stores the active OpenID refresh token with a durable expiration', async () => {
|
||||
const before = Date.now();
|
||||
|
||||
await storeOpenIdSession(
|
||||
{ userId: 'user-id', refreshToken: 'new-refresh', tenantId: 'tenant-a' },
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(deps.upsertSession).toHaveBeenCalledWith(
|
||||
'user-id',
|
||||
'new-refresh',
|
||||
expect.objectContaining({ tenantId: 'tenant-a', expiration: expect.any(Date) }),
|
||||
);
|
||||
expect(deps.upsertSession.mock.calls[0][2].expiration.getTime()).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
it('revokes the previous durable session after refresh-token rotation', async () => {
|
||||
await storeOpenIdSession(
|
||||
{
|
||||
userId: 'user-id',
|
||||
refreshToken: 'new-refresh',
|
||||
previousRefreshToken: 'old-refresh',
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(deps.deleteSession).toHaveBeenCalledWith({ refreshToken: 'old-refresh' });
|
||||
});
|
||||
|
||||
it('does not create a durable session without a refresh token', async () => {
|
||||
await expect(storeOpenIdSession({ userId: 'user-id' }, deps)).resolves.toBe(false);
|
||||
expect(deps.upsertSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
42
packages/api/src/images/session.ts
Normal file
42
packages/api/src/images/session.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { DEFAULT_REFRESH_TOKEN_EXPIRY, runAsSystem } from '@librechat/data-schemas';
|
||||
import type { ISession, DeleteSessionParams } from '@librechat/data-schemas';
|
||||
import { math } from '~/utils/math';
|
||||
|
||||
export interface OpenIdSessionParams {
|
||||
userId: string;
|
||||
refreshToken?: string;
|
||||
tenantId?: string;
|
||||
previousRefreshToken?: string;
|
||||
}
|
||||
|
||||
export interface OpenIdSessionDeps {
|
||||
upsertSession: (
|
||||
userId: string,
|
||||
refreshToken: string,
|
||||
options: { expiration: Date; tenantId?: string },
|
||||
) => Promise<ISession>;
|
||||
deleteSession: (params: DeleteSessionParams) => Promise<{ deletedCount?: number }>;
|
||||
}
|
||||
|
||||
export async function storeOpenIdSession(
|
||||
params: OpenIdSessionParams,
|
||||
deps: OpenIdSessionDeps,
|
||||
): Promise<boolean> {
|
||||
if (!params.userId || !params.refreshToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const refreshToken = params.refreshToken;
|
||||
const expiresIn = math(process.env.REFRESH_TOKEN_EXPIRY, DEFAULT_REFRESH_TOKEN_EXPIRY);
|
||||
await runAsSystem(() =>
|
||||
deps.upsertSession(params.userId, refreshToken, {
|
||||
expiration: new Date(Date.now() + expiresIn),
|
||||
tenantId: params.tenantId,
|
||||
}),
|
||||
);
|
||||
const previousRefreshToken = params.previousRefreshToken;
|
||||
if (previousRefreshToken && previousRefreshToken !== refreshToken) {
|
||||
await runAsSystem(() => deps.deleteSession({ refreshToken: previousRefreshToken }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -78,6 +78,8 @@ export * from './plugins';
|
|||
export * from './endpoints';
|
||||
/* Files */
|
||||
export * from './files';
|
||||
/* Images */
|
||||
export * from './images';
|
||||
/* Storage */
|
||||
export * from './storage';
|
||||
/* Tools */
|
||||
|
|
|
|||
|
|
@ -197,6 +197,7 @@ export const AppService = async (params?: {
|
|||
mcpConfig: mcpServersConfig,
|
||||
fileStrategies: config.fileStrategies,
|
||||
cloudfront: config.cloudfront as AppConfig['cloudfront'],
|
||||
secureImageLinks: config.secureImageLinks !== false,
|
||||
};
|
||||
|
||||
const agentsDefaults = agentsConfigSetup(config);
|
||||
|
|
@ -235,7 +236,6 @@ export const AppService = async (params?: {
|
|||
const appConfig: AppConfig = {
|
||||
...defaultConfig,
|
||||
fileConfig: config?.fileConfig as AppConfig['fileConfig'],
|
||||
secureImageLinks: config?.secureImageLinks,
|
||||
modelSpecs: processModelSpecs(config?.endpoints, config.modelSpecs, interfaceConfig),
|
||||
endpoints: loadedEndpoints,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
actionDelimiter,
|
||||
isActionTool,
|
||||
} from 'librechat-data-provider';
|
||||
import type { FilterQuery, Model, PipelineStage, Types } from 'mongoose';
|
||||
import type { FilterQuery, Model, PipelineStage, ProjectionType, Types } from 'mongoose';
|
||||
import type { AgentToolResources } from 'librechat-data-provider';
|
||||
import type { IAgent, IAclEntry } from '~/types';
|
||||
import { filterExistingSkillIds } from './skill';
|
||||
|
|
@ -440,7 +440,10 @@ export function createAgentMethods(
|
|||
mongoose: typeof import('mongoose'),
|
||||
deps: AgentDeps,
|
||||
): {
|
||||
getAgent: (searchParameter: FilterQuery<IAgent>) => Promise<IAgent | null>;
|
||||
getAgent: (
|
||||
searchParameter: FilterQuery<IAgent>,
|
||||
projection?: ProjectionType<IAgent>,
|
||||
) => Promise<IAgent | null>;
|
||||
getAgentVersions: (searchParameter: FilterQuery<IAgent>) => Promise<IAgent['versions'] | null>;
|
||||
getAgentWithVersionCount: (
|
||||
searchParameter: FilterQuery<IAgent>,
|
||||
|
|
@ -556,9 +559,12 @@ export function createAgentMethods(
|
|||
/**
|
||||
* Get an agent document based on the provided search parameter.
|
||||
*/
|
||||
async function getAgent(searchParameter: FilterQuery<IAgent>): Promise<IAgent | null> {
|
||||
async function getAgent(
|
||||
searchParameter: FilterQuery<IAgent>,
|
||||
projection?: ProjectionType<IAgent>,
|
||||
): Promise<IAgent | null> {
|
||||
const Agent = mongoose.models.Agent as Model<IAgent>;
|
||||
return await Agent.findOne(searchParameter).lean<IAgent>();
|
||||
return await Agent.findOne(searchParameter, projection).lean<IAgent>();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { FilterQuery, Model } from 'mongoose';
|
||||
import type { FilterQuery, Model, ProjectionType } from 'mongoose';
|
||||
import type { IAssistant } from '~/types';
|
||||
import { createIndexesWithRetry } from '~/utils/retry';
|
||||
|
||||
export function createAssistantMethods(mongoose: typeof import('mongoose')): {
|
||||
updateAssistantDoc: (
|
||||
|
|
@ -12,8 +13,25 @@ export function createAssistantMethods(mongoose: typeof import('mongoose')): {
|
|||
searchParams: FilterQuery<IAssistant>,
|
||||
select?: string | Record<string, number> | null,
|
||||
) => Promise<IAssistant[]>;
|
||||
getAssistant: (searchParams: FilterQuery<IAssistant>) => Promise<IAssistant | null>;
|
||||
getAssistant: (
|
||||
searchParams: FilterQuery<IAssistant>,
|
||||
projection?: ProjectionType<IAssistant>,
|
||||
) => Promise<IAssistant | null>;
|
||||
ensureAssistantIndexes: () => Promise<void>;
|
||||
} {
|
||||
const Assistant = () => mongoose.models.Assistant as Model<IAssistant>;
|
||||
let assistantIndexesPromise: Promise<void> | null = null;
|
||||
|
||||
function ensureAssistantIndexes(): Promise<void> {
|
||||
if (!assistantIndexesPromise) {
|
||||
assistantIndexesPromise = createIndexesWithRetry(Assistant()).catch((error) => {
|
||||
assistantIndexesPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return assistantIndexesPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an assistant with new data without overwriting existing properties,
|
||||
* or create a new assistant if it doesn't exist.
|
||||
|
|
@ -22,17 +40,19 @@ export function createAssistantMethods(mongoose: typeof import('mongoose')): {
|
|||
searchParams: FilterQuery<IAssistant>,
|
||||
updateData: Partial<IAssistant>,
|
||||
): Promise<IAssistant | null> {
|
||||
const Assistant = mongoose.models.Assistant as Model<IAssistant>;
|
||||
const options = { new: true, upsert: true };
|
||||
return await Assistant.findOneAndUpdate(searchParams, updateData, options).lean<IAssistant>();
|
||||
return await Assistant().findOneAndUpdate(searchParams, updateData, options).lean<IAssistant>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an assistant document based on the provided search params.
|
||||
*/
|
||||
async function getAssistant(searchParams: FilterQuery<IAssistant>): Promise<IAssistant | null> {
|
||||
const Assistant = mongoose.models.Assistant as Model<IAssistant>;
|
||||
return await Assistant.findOne(searchParams).lean<IAssistant>();
|
||||
async function getAssistant(
|
||||
searchParams: FilterQuery<IAssistant>,
|
||||
projection?: ProjectionType<IAssistant>,
|
||||
): Promise<IAssistant | null> {
|
||||
await ensureAssistantIndexes();
|
||||
return await Assistant().findOne(searchParams, projection).lean<IAssistant>();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -42,8 +62,7 @@ export function createAssistantMethods(mongoose: typeof import('mongoose')): {
|
|||
searchParams: FilterQuery<IAssistant>,
|
||||
select: string | Record<string, number> | null = null,
|
||||
): Promise<IAssistant[]> {
|
||||
const Assistant = mongoose.models.Assistant as Model<IAssistant>;
|
||||
const query = Assistant.find(searchParams);
|
||||
const query = Assistant().find(searchParams);
|
||||
|
||||
return await (select ? query.select(select) : query).lean<IAssistant[]>();
|
||||
}
|
||||
|
|
@ -54,16 +73,14 @@ export function createAssistantMethods(mongoose: typeof import('mongoose')): {
|
|||
async function deleteAssistant(
|
||||
searchParams: FilterQuery<IAssistant>,
|
||||
): Promise<IAssistant | null> {
|
||||
const Assistant = mongoose.models.Assistant as Model<IAssistant>;
|
||||
return await Assistant.findOneAndDelete(searchParams);
|
||||
return await Assistant().findOneAndDelete(searchParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all assistants matching the given search parameters.
|
||||
*/
|
||||
async function deleteAssistants(searchParams: FilterQuery<IAssistant>): Promise<number> {
|
||||
const Assistant = mongoose.models.Assistant as Model<IAssistant>;
|
||||
const result = await Assistant.deleteMany(searchParams);
|
||||
const result = await Assistant().deleteMany(searchParams);
|
||||
return result.deletedCount;
|
||||
}
|
||||
|
||||
|
|
@ -73,6 +90,7 @@ export function createAssistantMethods(mongoose: typeof import('mongoose')): {
|
|||
deleteAssistants,
|
||||
getAssistants,
|
||||
getAssistant,
|
||||
ensureAssistantIndexes,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type * as t from '~/types/session';
|
||||
import { createIndexesWithRetry } from '~/utils/retry';
|
||||
import { signPayload, hashToken } from '~/crypto';
|
||||
import logger from '~/config/winston';
|
||||
|
||||
|
|
@ -24,6 +25,12 @@ export function createSessionMethods(mongoose: typeof import('mongoose')): {
|
|||
SessionError: typeof SessionError;
|
||||
deleteSession: (params: t.DeleteSessionParams) => Promise<{ deletedCount?: number }>;
|
||||
createSession: (userId: string, options?: t.CreateSessionOptions) => Promise<t.SessionResult>;
|
||||
upsertSession: (
|
||||
userId: string,
|
||||
refreshToken: string,
|
||||
options: t.UpsertSessionOptions,
|
||||
) => Promise<t.ISession>;
|
||||
ensureSessionIndexes: () => Promise<void>;
|
||||
updateExpiration: (
|
||||
session: t.ISession | string,
|
||||
newExpiration?: Date,
|
||||
|
|
@ -36,6 +43,18 @@ export function createSessionMethods(mongoose: typeof import('mongoose')): {
|
|||
options?: t.DeleteAllSessionsOptions,
|
||||
) => Promise<{ deletedCount?: number }>;
|
||||
} {
|
||||
let sessionIndexesPromise: Promise<void> | null = null;
|
||||
|
||||
function ensureSessionIndexes(): Promise<void> {
|
||||
if (!sessionIndexesPromise) {
|
||||
sessionIndexesPromise = createIndexesWithRetry(mongoose.models.Session).catch((error) => {
|
||||
sessionIndexesPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return sessionIndexesPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new session for a user
|
||||
*/
|
||||
|
|
@ -50,6 +69,7 @@ export function createSessionMethods(mongoose: typeof import('mongoose')): {
|
|||
const expiresIn = options.expiresIn ?? DEFAULT_REFRESH_TOKEN_EXPIRY;
|
||||
|
||||
try {
|
||||
await ensureSessionIndexes();
|
||||
const Session = mongoose.models.Session;
|
||||
const currentSession = new Session({
|
||||
user: userId,
|
||||
|
|
@ -64,6 +84,43 @@ export function createSessionMethods(mongoose: typeof import('mongoose')): {
|
|||
}
|
||||
}
|
||||
|
||||
/** Stores an externally issued refresh token so logout and administrative revocation apply. */
|
||||
async function upsertSession(
|
||||
userId: string,
|
||||
refreshToken: string,
|
||||
options: t.UpsertSessionOptions,
|
||||
): Promise<t.ISession> {
|
||||
if (!userId || !refreshToken || !options.expiration) {
|
||||
throw new SessionError('User, refresh token, and expiration are required', 'INVALID_SESSION');
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureSessionIndexes();
|
||||
const Session = mongoose.models.Session;
|
||||
const refreshTokenHash = await hashToken(refreshToken);
|
||||
const update: Record<string, unknown> = {
|
||||
user: userId,
|
||||
refreshTokenHash,
|
||||
expiration: options.expiration,
|
||||
};
|
||||
if (options.tenantId) {
|
||||
update.tenantId = options.tenantId;
|
||||
}
|
||||
const session = await Session.findOneAndUpdate(
|
||||
{ user: userId, refreshTokenHash },
|
||||
{ $set: update },
|
||||
{ upsert: true, new: true, setDefaultsOnInsert: true },
|
||||
).exec();
|
||||
if (!session) {
|
||||
throw new SessionError('Stored session was not returned', 'SESSION_NOT_FOUND');
|
||||
}
|
||||
return session as t.ISession;
|
||||
} catch (error) {
|
||||
logger.error('[upsertSession] Error storing session:', error);
|
||||
throw new SessionError('Failed to store session', 'UPSERT_SESSION_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a session by various parameters
|
||||
*/
|
||||
|
|
@ -284,6 +341,8 @@ export function createSessionMethods(mongoose: typeof import('mongoose')): {
|
|||
SessionError,
|
||||
deleteSession,
|
||||
createSession,
|
||||
upsertSession,
|
||||
ensureSessionIndexes,
|
||||
updateExpiration,
|
||||
countActiveSessions,
|
||||
generateRefreshToken,
|
||||
|
|
|
|||
43
packages/data-schemas/src/methods/session.upsert.spec.ts
Normal file
43
packages/data-schemas/src/methods/session.upsert.spec.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { createSessionMethods } from './session';
|
||||
|
||||
jest.mock('~/config/winston', () => ({
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('upsertSession', () => {
|
||||
it('stores an externally issued refresh token as a revocable hash', async () => {
|
||||
const storedSession = { _id: 'session-id' };
|
||||
const createIndexes = jest.fn().mockResolvedValue(undefined);
|
||||
const exec = jest.fn().mockResolvedValue(storedSession);
|
||||
const findOneAndUpdate = jest.fn().mockReturnValue({ exec });
|
||||
const mongoose = {
|
||||
models: { Session: { modelName: 'Session', createIndexes, findOneAndUpdate } },
|
||||
} as unknown as typeof import('mongoose');
|
||||
const methods = createSessionMethods(mongoose);
|
||||
const expiration = new Date(Date.now() + 60_000);
|
||||
|
||||
const result = await methods.upsertSession('user-id', 'external-refresh', {
|
||||
expiration,
|
||||
tenantId: 'tenant-a',
|
||||
});
|
||||
|
||||
const refreshTokenHash = createHash('sha256').update('external-refresh').digest('hex');
|
||||
expect(createIndexes).toHaveBeenCalledTimes(1);
|
||||
expect(findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ user: 'user-id', refreshTokenHash },
|
||||
{
|
||||
$set: {
|
||||
user: 'user-id',
|
||||
refreshTokenHash,
|
||||
expiration,
|
||||
tenantId: 'tenant-a',
|
||||
},
|
||||
},
|
||||
{ upsert: true, new: true, setDefaultsOnInsert: true },
|
||||
);
|
||||
expect(result).toBe(storedSession);
|
||||
});
|
||||
});
|
||||
26
packages/data-schemas/src/schema/assistant.spec.ts
Normal file
26
packages/data-schemas/src/schema/assistant.spec.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { createAssistantMethods } from '~/methods/assistant';
|
||||
import assistantSchema from './assistant';
|
||||
|
||||
describe('assistantSchema', () => {
|
||||
it('indexes tenant-scoped avatar filepath lookups', () => {
|
||||
expect(assistantSchema.indexes()).toContainEqual([
|
||||
{ tenantId: 1, 'avatar.filepath': 1 },
|
||||
{ background: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates schema indexes before the first assistant lookup when auto-indexing is disabled', async () => {
|
||||
const createIndexes = jest.fn().mockResolvedValue(undefined);
|
||||
const lean = jest.fn().mockResolvedValue(null);
|
||||
const findOne = jest.fn().mockReturnValue({ lean });
|
||||
const mongoose = {
|
||||
models: { Assistant: { modelName: 'Assistant', createIndexes, findOne } },
|
||||
} as unknown as typeof import('mongoose');
|
||||
const methods = createAssistantMethods(mongoose);
|
||||
|
||||
await methods.getAssistant({ assistant_id: 'assistant-id' });
|
||||
await methods.getAssistant({ assistant_id: 'assistant-id' });
|
||||
|
||||
expect(createIndexes).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -13,6 +13,9 @@ const assistantSchema: Schema<IAssistant> = new Schema<IAssistant>(
|
|||
index: true,
|
||||
required: true,
|
||||
},
|
||||
endpoint: {
|
||||
type: String,
|
||||
},
|
||||
avatar: {
|
||||
type: Schema.Types.Mixed,
|
||||
default: undefined,
|
||||
|
|
@ -40,4 +43,6 @@ const assistantSchema: Schema<IAssistant> = new Schema<IAssistant>(
|
|||
},
|
||||
);
|
||||
|
||||
assistantSchema.index({ tenantId: 1, 'avatar.filepath': 1 });
|
||||
|
||||
export default assistantSchema;
|
||||
|
|
|
|||
10
packages/data-schemas/src/schema/session.spec.ts
Normal file
10
packages/data-schemas/src/schema/session.spec.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import sessionSchema from './session';
|
||||
|
||||
describe('sessionSchema', () => {
|
||||
it('prevents duplicate durable refresh-token sessions', () => {
|
||||
expect(sessionSchema.indexes()).toContainEqual([
|
||||
{ user: 1, refreshTokenHash: 1 },
|
||||
{ unique: true, background: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -22,4 +22,6 @@ const sessionSchema: Schema<ISession> = new Schema({
|
|||
},
|
||||
});
|
||||
|
||||
sessionSchema.index({ user: 1, refreshTokenHash: 1 }, { unique: true });
|
||||
|
||||
export default sessionSchema;
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export interface AppConfig {
|
|||
mcpSettings?: TCustomConfig['mcpSettings'] | null;
|
||||
/** File configuration */
|
||||
fileConfig?: TFileConfig;
|
||||
/** Secure image links configuration */
|
||||
/** Secure image links configuration, enabled unless explicitly disabled */
|
||||
secureImageLinks?: TCustomConfig['secureImageLinks'];
|
||||
/** Processed model specifications */
|
||||
modelSpecs?: TCustomConfig['modelSpecs'];
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Document, Types } from 'mongoose';
|
|||
export interface IAssistant extends Document {
|
||||
user: Types.ObjectId;
|
||||
assistant_id: string;
|
||||
endpoint?: string;
|
||||
avatar?: {
|
||||
filepath: string;
|
||||
source: string;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ export interface CreateSessionOptions {
|
|||
expiresIn?: number;
|
||||
}
|
||||
|
||||
export interface UpsertSessionOptions {
|
||||
expiration: Date;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateExpirationOptions {
|
||||
/** Duration in milliseconds for session expiry. Default: 7 days */
|
||||
expiresIn?: number;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue