🎟️ 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:
Danny Avila 2026-08-27 09:55:27 -04:00 committed by GitHub
parent ff1784568b
commit de59da9636
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1834 additions and 189 deletions

View file

@ -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,

View file

@ -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 () => {

View file

@ -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,
},
),
);

View file

@ -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;
}

View file

@ -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,

View file

@ -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({

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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;

View file

@ -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,

View file

@ -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 = {

View file

@ -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', () => {

View file

@ -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;
}