librechat-data-provider 0.8.301 → 0.8.400
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/jest.config.js +1 -0
- package/package.json +1 -1
- package/specs/api-endpoints-subdir.spec.ts +140 -0
- package/specs/api-endpoints.spec.ts +13 -25
- package/specs/mcp.spec.ts +147 -0
- package/specs/request-interceptor.spec.ts +7 -2
- package/specs/utils.spec.ts +71 -4
- package/src/accessPermissions.ts +4 -4
- package/src/api-endpoints.ts +11 -4
- package/src/config.spec.ts +315 -0
- package/src/config.ts +44 -3
- package/src/data-service.ts +8 -6
- package/src/file-config.spec.ts +39 -2
- package/src/file-config.ts +11 -5
- package/src/mcp.ts +32 -3
- package/src/request.ts +1 -1
- package/src/types.ts +18 -25
- package/src/utils.ts +30 -7
package/jest.config.js
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tests for buildLoginRedirectUrl and apiBaseUrl under subdirectory deployments.
|
|
7
|
+
*
|
|
8
|
+
* Uses jest.isolateModules to re-import api-endpoints with a <base href="/chat/">
|
|
9
|
+
* element present, simulating a subdirectory deployment where BASE_URL = '/chat'.
|
|
10
|
+
*
|
|
11
|
+
* Tests that need to override window.location use explicit function arguments
|
|
12
|
+
* instead of mocking the global, since jsdom 26+ does not allow redefining it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
function loadModuleWithBase(baseHref: string) {
|
|
16
|
+
const base = document.createElement('base');
|
|
17
|
+
base.setAttribute('href', baseHref);
|
|
18
|
+
document.head.appendChild(base);
|
|
19
|
+
|
|
20
|
+
const proc = process as typeof process & { browser?: boolean };
|
|
21
|
+
const originalBrowser = proc.browser;
|
|
22
|
+
|
|
23
|
+
let mod: typeof import('../src/api-endpoints');
|
|
24
|
+
try {
|
|
25
|
+
proc.browser = true;
|
|
26
|
+
jest.isolateModules(() => {
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- static import not usable inside isolateModules
|
|
28
|
+
mod = require('../src/api-endpoints');
|
|
29
|
+
});
|
|
30
|
+
return mod!;
|
|
31
|
+
} finally {
|
|
32
|
+
proc.browser = originalBrowser;
|
|
33
|
+
document.head.removeChild(base);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('buildLoginRedirectUrl — subdirectory deployment (BASE_URL = /chat)', () => {
|
|
38
|
+
let buildLoginRedirectUrl: typeof import('../src/api-endpoints').buildLoginRedirectUrl;
|
|
39
|
+
let apiBaseUrl: typeof import('../src/api-endpoints').apiBaseUrl;
|
|
40
|
+
|
|
41
|
+
beforeAll(() => {
|
|
42
|
+
const mod = loadModuleWithBase('/chat/');
|
|
43
|
+
buildLoginRedirectUrl = mod.buildLoginRedirectUrl;
|
|
44
|
+
apiBaseUrl = mod.apiBaseUrl;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('sets BASE_URL to "/chat" (trailing slash stripped)', () => {
|
|
48
|
+
expect(apiBaseUrl()).toBe('/chat');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns "/login" without base prefix (compatible with React Router navigate)', () => {
|
|
52
|
+
const result = buildLoginRedirectUrl('/chat/c/new', '', '');
|
|
53
|
+
expect(result).toMatch(/^\/login/);
|
|
54
|
+
expect(result).not.toMatch(/^\/chat/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('strips base prefix from redirect_to when pathname includes base', () => {
|
|
58
|
+
const result = buildLoginRedirectUrl('/chat/c/abc123', '?model=gpt-4', '');
|
|
59
|
+
const redirectTo = decodeURIComponent(result.split('redirect_to=')[1]);
|
|
60
|
+
expect(redirectTo).toBe('/c/abc123?model=gpt-4');
|
|
61
|
+
expect(redirectTo).not.toContain('/chat/');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('works with pathnames that do not include the base prefix', () => {
|
|
65
|
+
const result = buildLoginRedirectUrl('/c/new', '', '');
|
|
66
|
+
const redirectTo = decodeURIComponent(result.split('redirect_to=')[1]);
|
|
67
|
+
expect(redirectTo).toBe('/c/new');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('returns plain /login for base-prefixed login path', () => {
|
|
71
|
+
expect(buildLoginRedirectUrl('/chat/login', '', '')).toBe('/login');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns plain /login for base-prefixed login sub-path', () => {
|
|
75
|
+
expect(buildLoginRedirectUrl('/chat/login/2fa', '', '')).toBe('/login');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns plain /login when stripped path is root (no pointless redirect_to=/)', () => {
|
|
79
|
+
const result = buildLoginRedirectUrl('/chat', '', '');
|
|
80
|
+
expect(result).toBe('/login');
|
|
81
|
+
expect(result).not.toContain('redirect_to');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('composes correct full URL for window.location.href (apiBaseUrl + buildLoginRedirectUrl)', () => {
|
|
85
|
+
const fullUrl = apiBaseUrl() + buildLoginRedirectUrl('/chat/c/abc123', '', '');
|
|
86
|
+
expect(fullUrl).toBe('/chat/login?redirect_to=%2Fc%2Fabc123');
|
|
87
|
+
expect(fullUrl).not.toContain('/chat/chat/');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('encodes query params and hash correctly after stripping base', () => {
|
|
91
|
+
const result = buildLoginRedirectUrl('/chat/c/deep', '?q=hello&submit=true', '#section');
|
|
92
|
+
const redirectTo = decodeURIComponent(result.split('redirect_to=')[1]);
|
|
93
|
+
expect(redirectTo).toBe('/c/deep?q=hello&submit=true#section');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('does not strip base when path shares a prefix but is not a segment match', () => {
|
|
97
|
+
const result = buildLoginRedirectUrl('/chatroom/c/abc123', '', '');
|
|
98
|
+
const redirectTo = decodeURIComponent(result.split('redirect_to=')[1]);
|
|
99
|
+
expect(redirectTo).toBe('/chatroom/c/abc123');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('does not strip base from /chatbot path', () => {
|
|
103
|
+
const result = buildLoginRedirectUrl('/chatbot', '', '');
|
|
104
|
+
const redirectTo = decodeURIComponent(result.split('redirect_to=')[1]);
|
|
105
|
+
expect(redirectTo).toBe('/chatbot');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('buildLoginRedirectUrl — deep subdirectory (BASE_URL = /app/chat)', () => {
|
|
110
|
+
let buildLoginRedirectUrl: typeof import('../src/api-endpoints').buildLoginRedirectUrl;
|
|
111
|
+
let apiBaseUrl: typeof import('../src/api-endpoints').apiBaseUrl;
|
|
112
|
+
|
|
113
|
+
beforeAll(() => {
|
|
114
|
+
const mod = loadModuleWithBase('/app/chat/');
|
|
115
|
+
buildLoginRedirectUrl = mod.buildLoginRedirectUrl;
|
|
116
|
+
apiBaseUrl = mod.apiBaseUrl;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('sets BASE_URL to "/app/chat"', () => {
|
|
120
|
+
expect(apiBaseUrl()).toBe('/app/chat');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('strips deep base prefix from redirect_to', () => {
|
|
124
|
+
const result = buildLoginRedirectUrl('/app/chat/c/abc123', '', '');
|
|
125
|
+
const redirectTo = decodeURIComponent(result.split('redirect_to=')[1]);
|
|
126
|
+
expect(redirectTo).toBe('/c/abc123');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('full URL does not double the base prefix', () => {
|
|
130
|
+
const fullUrl = apiBaseUrl() + buildLoginRedirectUrl('/app/chat/c/abc123', '', '');
|
|
131
|
+
expect(fullUrl).toBe('/app/chat/login?redirect_to=%2Fc%2Fabc123');
|
|
132
|
+
expect(fullUrl).not.toContain('/app/chat/app/chat/');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('does not strip from /app/chatroom (segment boundary check)', () => {
|
|
136
|
+
const result = buildLoginRedirectUrl('/app/chatroom/page', '', '');
|
|
137
|
+
const redirectTo = decodeURIComponent(result.split('redirect_to=')[1]);
|
|
138
|
+
expect(redirectTo).toBe('/app/chatroom/page');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -4,18 +4,8 @@
|
|
|
4
4
|
import { buildLoginRedirectUrl } from '../src/api-endpoints';
|
|
5
5
|
|
|
6
6
|
describe('buildLoginRedirectUrl', () => {
|
|
7
|
-
let savedLocation: Location;
|
|
8
|
-
|
|
9
|
-
beforeEach(() => {
|
|
10
|
-
savedLocation = window.location;
|
|
11
|
-
Object.defineProperty(window, 'location', {
|
|
12
|
-
value: { pathname: '/c/abc123', search: '?model=gpt-4', hash: '#msg-5' },
|
|
13
|
-
writable: true,
|
|
14
|
-
});
|
|
15
|
-
});
|
|
16
|
-
|
|
17
7
|
afterEach(() => {
|
|
18
|
-
|
|
8
|
+
window.history.replaceState({}, '', '/');
|
|
19
9
|
});
|
|
20
10
|
|
|
21
11
|
it('builds a login URL from explicit args', () => {
|
|
@@ -31,18 +21,16 @@ describe('buildLoginRedirectUrl', () => {
|
|
|
31
21
|
});
|
|
32
22
|
|
|
33
23
|
it('falls back to window.location when no args provided', () => {
|
|
24
|
+
window.history.replaceState({}, '', '/c/abc123?model=gpt-4#msg-5');
|
|
34
25
|
const result = buildLoginRedirectUrl();
|
|
35
26
|
const encoded = result.split('redirect_to=')[1];
|
|
36
27
|
expect(decodeURIComponent(encoded)).toBe('/c/abc123?model=gpt-4#msg-5');
|
|
37
28
|
});
|
|
38
29
|
|
|
39
|
-
it('
|
|
40
|
-
|
|
41
|
-
value: { pathname: '', search: '', hash: '' },
|
|
42
|
-
writable: true,
|
|
43
|
-
});
|
|
30
|
+
it('returns plain /login when all location parts are empty (root)', () => {
|
|
31
|
+
window.history.replaceState({}, '', '/');
|
|
44
32
|
const result = buildLoginRedirectUrl();
|
|
45
|
-
expect(result).toBe('/login
|
|
33
|
+
expect(result).toBe('/login');
|
|
46
34
|
});
|
|
47
35
|
|
|
48
36
|
it('returns plain /login when pathname is /login (prevents recursive redirect)', () => {
|
|
@@ -51,10 +39,7 @@ describe('buildLoginRedirectUrl', () => {
|
|
|
51
39
|
});
|
|
52
40
|
|
|
53
41
|
it('returns plain /login when window.location is already /login', () => {
|
|
54
|
-
|
|
55
|
-
value: { pathname: '/login', search: '?redirect_to=%2Fc%2Fabc', hash: '' },
|
|
56
|
-
writable: true,
|
|
57
|
-
});
|
|
42
|
+
window.history.replaceState({}, '', '/login?redirect_to=%2Fc%2Fabc');
|
|
58
43
|
const result = buildLoginRedirectUrl();
|
|
59
44
|
expect(result).toBe('/login');
|
|
60
45
|
});
|
|
@@ -65,10 +50,7 @@ describe('buildLoginRedirectUrl', () => {
|
|
|
65
50
|
});
|
|
66
51
|
|
|
67
52
|
it('returns plain /login for basename-prefixed /login (e.g. /librechat/login)', () => {
|
|
68
|
-
|
|
69
|
-
value: { pathname: '/librechat/login', search: '?redirect_to=%2Fc%2Fabc', hash: '' },
|
|
70
|
-
writable: true,
|
|
71
|
-
});
|
|
53
|
+
window.history.replaceState({}, '', '/librechat/login?redirect_to=%2Fc%2Fabc');
|
|
72
54
|
const result = buildLoginRedirectUrl();
|
|
73
55
|
expect(result).toBe('/login');
|
|
74
56
|
});
|
|
@@ -78,6 +60,12 @@ describe('buildLoginRedirectUrl', () => {
|
|
|
78
60
|
expect(result).toBe('/login');
|
|
79
61
|
});
|
|
80
62
|
|
|
63
|
+
it('returns plain /login for root path (no pointless redirect_to=/)', () => {
|
|
64
|
+
const result = buildLoginRedirectUrl('/', '', '');
|
|
65
|
+
expect(result).toBe('/login');
|
|
66
|
+
expect(result).not.toContain('redirect_to');
|
|
67
|
+
});
|
|
68
|
+
|
|
81
69
|
it('does NOT match paths where "login" is a substring of a segment', () => {
|
|
82
70
|
const result = buildLoginRedirectUrl('/c/loginhistory', '', '');
|
|
83
71
|
expect(result).toContain('redirect_to=');
|
|
@@ -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
|
+
});
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @jest-environment
|
|
2
|
+
* @jest-environment @happy-dom/jest-environment
|
|
3
3
|
*/
|
|
4
4
|
import axios from 'axios';
|
|
5
5
|
import { setTokenHeader } from '../src/headers-helpers';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* The response interceptor in request.ts registers at import time when
|
|
9
|
-
* `typeof window !== 'undefined'` (
|
|
9
|
+
* `typeof window !== 'undefined'` (happy-dom provides window).
|
|
10
10
|
*
|
|
11
11
|
* We use axios's built-in request adapter mock to avoid real HTTP calls,
|
|
12
12
|
* and verify the interceptor's behavior by observing whether a 401 triggers
|
|
13
13
|
* a refresh POST or is immediately rejected.
|
|
14
|
+
*
|
|
15
|
+
* happy-dom is used instead of jsdom because it allows overriding
|
|
16
|
+
* window.location via Object.defineProperty, which jsdom 26+ blocks.
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
19
|
const mockAdapter = jest.fn();
|
|
@@ -38,6 +41,7 @@ afterEach(() => {
|
|
|
38
41
|
Object.defineProperty(window, 'location', {
|
|
39
42
|
value: savedLocation,
|
|
40
43
|
writable: true,
|
|
44
|
+
configurable: true,
|
|
41
45
|
});
|
|
42
46
|
});
|
|
43
47
|
|
|
@@ -45,6 +49,7 @@ function setWindowLocation(overrides: Partial<Location>) {
|
|
|
45
49
|
Object.defineProperty(window, 'location', {
|
|
46
50
|
value: { ...window.location, ...overrides },
|
|
47
51
|
writable: true,
|
|
52
|
+
configurable: true,
|
|
48
53
|
});
|
|
49
54
|
}
|
|
50
55
|
|
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/api-endpoints.ts
CHANGED
|
@@ -174,13 +174,20 @@ const LOGIN_PATH_RE = /(?:^|\/)login(?:\/|$)/;
|
|
|
174
174
|
export function buildLoginRedirectUrl(pathname?: string, search?: string, hash?: string): string {
|
|
175
175
|
const p = pathname ?? window.location.pathname;
|
|
176
176
|
if (LOGIN_PATH_RE.test(p)) {
|
|
177
|
-
return
|
|
177
|
+
return '/login';
|
|
178
178
|
}
|
|
179
179
|
const s = search ?? window.location.search;
|
|
180
180
|
const h = hash ?? window.location.hash;
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
|
|
181
|
+
|
|
182
|
+
const stripped =
|
|
183
|
+
BASE_URL && (p === BASE_URL || p.startsWith(BASE_URL + '/'))
|
|
184
|
+
? p.slice(BASE_URL.length) || '/'
|
|
185
|
+
: p;
|
|
186
|
+
const currentPath = `${stripped}${s}${h}`;
|
|
187
|
+
if (!currentPath || currentPath === '/') {
|
|
188
|
+
return '/login';
|
|
189
|
+
}
|
|
190
|
+
return `/login?${REDIRECT_PARAM}=${encodeURIComponent(currentPath)}`;
|
|
184
191
|
}
|
|
185
192
|
|
|
186
193
|
export const resendVerificationEmail = () => `${BASE_URL}/api/user/verify/resend`;
|