mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-21 15:45:22 +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
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue