@theia/core 1.73.0-next.43 → 1.73.0-next.52

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.
Files changed (81) hide show
  1. package/README.md +1 -1
  2. package/lib/browser/catalog.json +6 -0
  3. package/lib/browser/messaging/ws-connection-source.d.ts.map +1 -1
  4. package/lib/browser/messaging/ws-connection-source.js +1 -6
  5. package/lib/browser/messaging/ws-connection-source.js.map +1 -1
  6. package/lib/browser/tree/tree-view-welcome-widget.d.ts +1 -0
  7. package/lib/browser/tree/tree-view-welcome-widget.d.ts.map +1 -1
  8. package/lib/browser/tree/tree-view-welcome-widget.js +8 -6
  9. package/lib/browser/tree/tree-view-welcome-widget.js.map +1 -1
  10. package/lib/browser/tree/tree-view-welcome-widget.spec.d.ts +2 -0
  11. package/lib/browser/tree/tree-view-welcome-widget.spec.d.ts.map +1 -0
  12. package/lib/browser/tree/tree-view-welcome-widget.spec.js +68 -0
  13. package/lib/browser/tree/tree-view-welcome-widget.spec.js.map +1 -0
  14. package/lib/electron-node/hosting/electron-ws-origin-validator.d.ts.map +1 -1
  15. package/lib/electron-node/hosting/electron-ws-origin-validator.js +5 -2
  16. package/lib/electron-node/hosting/electron-ws-origin-validator.js.map +1 -1
  17. package/lib/electron-node/hosting/electron-ws-origin-validator.spec.d.ts +2 -0
  18. package/lib/electron-node/hosting/electron-ws-origin-validator.spec.d.ts.map +1 -0
  19. package/lib/electron-node/hosting/electron-ws-origin-validator.spec.js +59 -0
  20. package/lib/electron-node/hosting/electron-ws-origin-validator.spec.js.map +1 -0
  21. package/lib/node/backend-application-module.d.ts.map +1 -1
  22. package/lib/node/backend-application-module.js +2 -0
  23. package/lib/node/backend-application-module.js.map +1 -1
  24. package/lib/node/backend-application.d.ts +9 -0
  25. package/lib/node/backend-application.d.ts.map +1 -1
  26. package/lib/node/backend-application.js +23 -1
  27. package/lib/node/backend-application.js.map +1 -1
  28. package/lib/node/backend-application.spec.js +1 -0
  29. package/lib/node/backend-application.spec.js.map +1 -1
  30. package/lib/node/hosting/backend-hosting-module.d.ts.map +1 -1
  31. package/lib/node/hosting/backend-hosting-module.js +7 -0
  32. package/lib/node/hosting/backend-hosting-module.js.map +1 -1
  33. package/lib/node/hosting/browser-connection-token.d.ts +46 -0
  34. package/lib/node/hosting/browser-connection-token.d.ts.map +1 -0
  35. package/lib/node/hosting/browser-connection-token.js +120 -0
  36. package/lib/node/hosting/browser-connection-token.js.map +1 -0
  37. package/lib/node/hosting/browser-connection-token.spec.d.ts +2 -0
  38. package/lib/node/hosting/browser-connection-token.spec.d.ts.map +1 -0
  39. package/lib/node/hosting/browser-connection-token.spec.js +128 -0
  40. package/lib/node/hosting/browser-connection-token.spec.js.map +1 -0
  41. package/lib/node/hosting/ws-origin-validator.d.ts.map +1 -1
  42. package/lib/node/hosting/ws-origin-validator.js +21 -4
  43. package/lib/node/hosting/ws-origin-validator.js.map +1 -1
  44. package/lib/node/hosting/ws-origin-validator.spec.d.ts +2 -0
  45. package/lib/node/hosting/ws-origin-validator.spec.d.ts.map +1 -0
  46. package/lib/node/hosting/ws-origin-validator.spec.js +131 -0
  47. package/lib/node/hosting/ws-origin-validator.spec.js.map +1 -0
  48. package/lib/node/index.d.ts +1 -0
  49. package/lib/node/index.d.ts.map +1 -1
  50. package/lib/node/index.js +3 -1
  51. package/lib/node/index.js.map +1 -1
  52. package/lib/node/messaging/websocket-endpoint.d.ts +5 -0
  53. package/lib/node/messaging/websocket-endpoint.d.ts.map +1 -1
  54. package/lib/node/messaging/websocket-endpoint.js +14 -5
  55. package/lib/node/messaging/websocket-endpoint.js.map +1 -1
  56. package/lib/node/request/backend-request-facade.d.ts +19 -0
  57. package/lib/node/request/backend-request-facade.d.ts.map +1 -1
  58. package/lib/node/request/backend-request-facade.js +93 -3
  59. package/lib/node/request/backend-request-facade.js.map +1 -1
  60. package/lib/node/request/backend-request-facade.spec.d.ts +2 -0
  61. package/lib/node/request/backend-request-facade.spec.d.ts.map +1 -0
  62. package/lib/node/request/backend-request-facade.spec.js +171 -0
  63. package/lib/node/request/backend-request-facade.spec.js.map +1 -0
  64. package/package.json +4 -4
  65. package/src/browser/messaging/ws-connection-source.ts +1 -6
  66. package/src/browser/tree/tree-view-welcome-widget.spec.ts +80 -0
  67. package/src/browser/tree/tree-view-welcome-widget.tsx +10 -11
  68. package/src/electron-node/hosting/electron-ws-origin-validator.spec.ts +69 -0
  69. package/src/electron-node/hosting/electron-ws-origin-validator.ts +5 -2
  70. package/src/node/backend-application-module.ts +4 -2
  71. package/src/node/backend-application.spec.ts +2 -0
  72. package/src/node/backend-application.ts +18 -0
  73. package/src/node/hosting/backend-hosting-module.ts +10 -0
  74. package/src/node/hosting/browser-connection-token.spec.ts +167 -0
  75. package/src/node/hosting/browser-connection-token.ts +126 -0
  76. package/src/node/hosting/ws-origin-validator.spec.ts +193 -0
  77. package/src/node/hosting/ws-origin-validator.ts +23 -4
  78. package/src/node/index.ts +1 -0
  79. package/src/node/messaging/websocket-endpoint.ts +17 -5
  80. package/src/node/request/backend-request-facade.spec.ts +203 -0
  81. package/src/node/request/backend-request-facade.ts +108 -3
@@ -0,0 +1,193 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 STMicroelectronics and others.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { expect } from 'chai';
18
+ import * as http from 'http';
19
+ import { WsOriginValidator } from './ws-origin-validator';
20
+ import { BackendApplicationHosts } from './backend-application-hosts';
21
+
22
+ describe('WsOriginValidator', () => {
23
+
24
+ function createValidator(hosts?: string[]): WsOriginValidator {
25
+ const backendHosts = new BackendApplicationHosts();
26
+ if (hosts) {
27
+ for (const h of hosts) {
28
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
+ (backendHosts as any)['_hosts'].add(h);
30
+ }
31
+ }
32
+ const validator = new WsOriginValidator();
33
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
+ (validator as any)['backendApplicationHosts'] = backendHosts;
35
+ return validator;
36
+ }
37
+
38
+ function createRequest(origin?: string, host?: string): http.IncomingMessage {
39
+ const request = {
40
+ headers: {} as http.IncomingHttpHeaders
41
+ } as http.IncomingMessage;
42
+ if (origin !== undefined) {
43
+ request.headers.origin = origin;
44
+ }
45
+ if (host !== undefined) {
46
+ request.headers.host = host;
47
+ }
48
+ return request;
49
+ }
50
+
51
+ describe('when THEIA_HOSTS is not set', () => {
52
+
53
+ it('should allow same-origin requests', () => {
54
+ const validator = createValidator();
55
+ expect(validator.allowWsUpgrade(
56
+ createRequest('http://localhost:3000', 'localhost:3000')
57
+ )).to.be.true;
58
+ });
59
+
60
+ it('should allow same-origin requests on default ports', () => {
61
+ const validator = createValidator();
62
+ expect(validator.allowWsUpgrade(
63
+ createRequest('http://myhost.com', 'myhost.com')
64
+ )).to.be.true;
65
+ });
66
+
67
+ it('should reject cross-origin requests', () => {
68
+ const validator = createValidator();
69
+ expect(validator.allowWsUpgrade(
70
+ createRequest('http://evil.com', 'localhost:3000')
71
+ )).to.be.false;
72
+ });
73
+
74
+ it('should allow requests with no Origin header (same-origin polling)', () => {
75
+ const validator = createValidator();
76
+ expect(validator.allowWsUpgrade(
77
+ createRequest(undefined, 'localhost:3000')
78
+ )).to.be.true;
79
+ });
80
+
81
+ it('should reject requests with no Host header when Origin is present', () => {
82
+ const validator = createValidator();
83
+ expect(validator.allowWsUpgrade(
84
+ createRequest('http://localhost:3000', undefined)
85
+ )).to.be.false;
86
+ });
87
+
88
+ it('should reject requests with malformed Origin', () => {
89
+ const validator = createValidator();
90
+ expect(validator.allowWsUpgrade(
91
+ createRequest('not-a-valid-url', 'localhost:3000')
92
+ )).to.be.false;
93
+ });
94
+ });
95
+
96
+ describe('when THEIA_HOSTS is set', () => {
97
+
98
+ it('should allow requests from a listed host', () => {
99
+ const validator = createValidator(['myhost.com']);
100
+ expect(validator.allowWsUpgrade(
101
+ createRequest('http://myhost.com', 'myhost.com')
102
+ )).to.be.true;
103
+ });
104
+
105
+ it('should allow requests from a listed host with port', () => {
106
+ const validator = createValidator(['myhost.com:8080']);
107
+ expect(validator.allowWsUpgrade(
108
+ createRequest('http://myhost.com:8080', 'myhost.com:8080')
109
+ )).to.be.true;
110
+ });
111
+
112
+ it('should reject requests from an unlisted host', () => {
113
+ const validator = createValidator(['myhost.com']);
114
+ expect(validator.allowWsUpgrade(
115
+ createRequest('http://evil.com', 'myhost.com')
116
+ )).to.be.false;
117
+ });
118
+
119
+ it('should allow requests with no Origin header (same-origin polling)', () => {
120
+ const validator = createValidator(['myhost.com']);
121
+ expect(validator.allowWsUpgrade(
122
+ createRequest(undefined, 'myhost.com')
123
+ )).to.be.true;
124
+ });
125
+
126
+ it('should allow when origin host is in the allowlist regardless of Host header', () => {
127
+ const validator = createValidator(['myhost.com']);
128
+ expect(validator.allowWsUpgrade(
129
+ createRequest('http://myhost.com', 'internal-host:3000')
130
+ )).to.be.true;
131
+ });
132
+
133
+ it('should reject malformed Origin even with THEIA_HOSTS', () => {
134
+ const validator = createValidator(['myhost.com']);
135
+ expect(validator.allowWsUpgrade(
136
+ createRequest('not-a-valid-url', 'myhost.com')
137
+ )).to.be.false;
138
+ });
139
+ });
140
+
141
+ describe('edge cases', () => {
142
+
143
+ it('should reject cross-origin with port mismatch', () => {
144
+ const validator = createValidator();
145
+ expect(validator.allowWsUpgrade(
146
+ createRequest('http://localhost:4000', 'localhost:3000')
147
+ )).to.be.false;
148
+ });
149
+
150
+ it('should handle HTTPS origins', () => {
151
+ const validator = createValidator();
152
+ expect(validator.allowWsUpgrade(
153
+ createRequest('https://localhost:3000', 'localhost:3000')
154
+ )).to.be.true;
155
+ });
156
+
157
+ it('should reject Origin: null (opaque origin from sandboxed pages)', () => {
158
+ const validator = createValidator();
159
+ expect(validator.allowWsUpgrade(
160
+ createRequest('null', 'localhost:3000')
161
+ )).to.be.false;
162
+ });
163
+
164
+ it('should allow empty Origin string (treated as absent)', () => {
165
+ const validator = createValidator();
166
+ expect(validator.allowWsUpgrade(
167
+ createRequest('', 'localhost:3000')
168
+ )).to.be.true;
169
+ });
170
+
171
+ it('should handle IPv6 same-origin', () => {
172
+ const validator = createValidator();
173
+ expect(validator.allowWsUpgrade(
174
+ createRequest('http://[::1]:3000', '[::1]:3000')
175
+ )).to.be.true;
176
+ });
177
+
178
+ it('should strip credentials from Origin when comparing', () => {
179
+ const validator = createValidator();
180
+ // new URL('http://user:pass@localhost:3000').host === 'localhost:3000'
181
+ expect(validator.allowWsUpgrade(
182
+ createRequest('http://user:pass@localhost:3000', 'localhost:3000')
183
+ )).to.be.true;
184
+ });
185
+
186
+ it('should ignore path component in Origin', () => {
187
+ const validator = createValidator();
188
+ expect(validator.allowWsUpgrade(
189
+ createRequest('http://localhost:3000/some/path', 'localhost:3000')
190
+ )).to.be.true;
191
+ });
192
+ });
193
+ });
@@ -16,7 +16,6 @@
16
16
 
17
17
  import * as http from 'http';
18
18
  import { inject, injectable } from 'inversify';
19
- import * as url from 'url';
20
19
  import { WsRequestValidatorContribution } from '../ws-request-validators';
21
20
  import { BackendApplicationHosts } from './backend-application-hosts';
22
21
 
@@ -27,10 +26,30 @@ export class WsOriginValidator implements WsRequestValidatorContribution {
27
26
  protected readonly backendApplicationHosts: BackendApplicationHosts;
28
27
 
29
28
  allowWsUpgrade(request: http.IncomingMessage): boolean {
30
- if (!this.backendApplicationHosts.hasKnownHosts() || !request.headers.origin) {
29
+ if (!request.headers.origin) {
30
+ // Browsers omit the Origin header for same-origin requests (e.g. Socket.IO polling).
31
+ // Absent Origin is safe: cross-origin browser requests always include it.
31
32
  return true;
32
33
  }
33
- const origin = url.parse(request.headers.origin);
34
- return this.backendApplicationHosts.hosts.has(origin.host!);
34
+
35
+ let originHost: string;
36
+ try {
37
+ originHost = new URL(request.headers.origin).host;
38
+ } catch {
39
+ return false;
40
+ }
41
+
42
+ if (this.backendApplicationHosts.hasKnownHosts()) {
43
+ // When THEIA_HOSTS is configured, validate against the explicit allowlist.
44
+ return this.backendApplicationHosts.hosts.has(originHost);
45
+ }
46
+
47
+ // When THEIA_HOSTS is not configured (the common development/default case),
48
+ // enforce same-origin: the Origin's host must match the request's Host header.
49
+ const hostHeader = request.headers.host;
50
+ if (!hostHeader) {
51
+ return false;
52
+ }
53
+ return originHost === hostHeader;
35
54
  }
36
55
  }
package/src/node/index.ts CHANGED
@@ -22,3 +22,4 @@ export * from './messaging';
22
22
  export * from './cli';
23
23
  export * from './setting-service';
24
24
  export { FileSystemLocking } from './filesystem-locking';
25
+ export { BackendRequestAllowedContribution } from './request/backend-request-facade';
@@ -48,14 +48,21 @@ export class WebsocketEndpoint implements BackendApplicationContribution {
48
48
  const socketServer = new Server(server, {
49
49
  pingInterval: this.checkAliveTimeout,
50
50
  pingTimeout: this.checkAliveTimeout * 2,
51
- maxHttpBufferSize: this.maxHttpBufferSize
51
+ maxHttpBufferSize: this.maxHttpBufferSize,
52
+ allowRequest: (req, callback) => {
53
+ // eslint-disable-next-line no-null/no-null
54
+ const noError = null;
55
+ this.wsRequestValidator.allowWsUpgrade(req).then(
56
+ allowed => callback(noError, allowed),
57
+ error => {
58
+ console.error('Error during WebSocket allowRequest validation:', error);
59
+ callback(error?.message ?? 'Validation error', false);
60
+ }
61
+ );
62
+ }
52
63
  });
53
64
  // Accept every namespace by using /.*/
54
65
  socketServer.of(/.*/).on('connection', async socket => {
55
- const request = socket.request;
56
- // Socket.io strips the `origin` header of the incoming request
57
- // We provide a `fix-origin` header in the `WebSocketConnectionProvider`
58
- request.headers.origin = request.headers['fix-origin'] as string;
59
66
  if (await this.allowConnect(socket.request)) {
60
67
  await this.handleConnection(socket);
61
68
  this.messagingListener.onDidWebSocketUpgrade(socket.request, socket);
@@ -65,6 +72,11 @@ export class WebsocketEndpoint implements BackendApplicationContribution {
65
72
  });
66
73
  }
67
74
 
75
+ /**
76
+ * Secondary validation after connection. The primary check happens in the
77
+ * Socket.IO `allowRequest` callback at handshake time; this is kept as
78
+ * defense-in-depth in case a Socket.IO upgrade path bypasses `allowRequest`.
79
+ */
68
80
  protected async allowConnect(request: http.IncomingMessage): Promise<boolean> {
69
81
  try {
70
82
  return this.wsRequestValidator.allowWsUpgrade(request);
@@ -0,0 +1,203 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH and others.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { expect } from 'chai';
18
+ import * as sinon from 'sinon';
19
+ import { BackendRequestFacade, isUrlAllowed } from './backend-request-facade';
20
+ import { BackendApplicationConfigProvider } from '../backend-application-config-provider';
21
+
22
+ describe('isUrlAllowed', () => {
23
+
24
+ it('should allow a URL that matches an exact origin pattern', () => {
25
+ const patterns = ['https://open-vsx.org'];
26
+ expect(isUrlAllowed('https://open-vsx.org/api/extensions', patterns)).to.be.true;
27
+ });
28
+
29
+ it('should allow a URL with a path when pattern has no path', () => {
30
+ const patterns = ['https://example.com'];
31
+ expect(isUrlAllowed('https://example.com/some/deep/path?query=1', patterns)).to.be.true;
32
+ });
33
+
34
+ it('should allow a URL that exactly matches the origin', () => {
35
+ const patterns = ['https://example.com'];
36
+ expect(isUrlAllowed('https://example.com', patterns)).to.be.true;
37
+ expect(isUrlAllowed('https://example.com/', patterns)).to.be.true;
38
+ });
39
+
40
+ it('should reject a URL with a different scheme', () => {
41
+ const patterns = ['https://example.com'];
42
+ expect(isUrlAllowed('http://example.com/path', patterns)).to.be.false;
43
+ });
44
+
45
+ it('should reject a URL with a different host', () => {
46
+ const patterns = ['https://example.com'];
47
+ expect(isUrlAllowed('https://evil.com/path', patterns)).to.be.false;
48
+ });
49
+
50
+ it('should reject a URL with a different port', () => {
51
+ const patterns = ['https://example.com'];
52
+ expect(isUrlAllowed('https://example.com:8443/path', patterns)).to.be.false;
53
+ });
54
+
55
+ it('should allow a URL when port matches explicitly', () => {
56
+ const patterns = ['https://example.com:8443'];
57
+ expect(isUrlAllowed('https://example.com:8443/path', patterns)).to.be.true;
58
+ });
59
+
60
+ it('should treat default ports as equivalent', () => {
61
+ const patterns = ['https://example.com:443'];
62
+ expect(isUrlAllowed('https://example.com/path', patterns)).to.be.true;
63
+
64
+ const httpPatterns = ['http://example.com:80'];
65
+ expect(isUrlAllowed('http://example.com/path', httpPatterns)).to.be.true;
66
+
67
+ const implicitPatterns = ['http://example.com'];
68
+ expect(isUrlAllowed('http://example.com:80/path', implicitPatterns)).to.be.true;
69
+ });
70
+
71
+ it('should match wildcard subdomains', () => {
72
+ const patterns = ['https://*.example.com'];
73
+ expect(isUrlAllowed('https://foo.example.com/path', patterns)).to.be.true;
74
+ expect(isUrlAllowed('https://bar.baz.example.com/path', patterns)).to.be.true;
75
+ });
76
+
77
+ it('should not match the bare domain with a wildcard subdomain pattern', () => {
78
+ const patterns = ['https://*.example.com'];
79
+ expect(isUrlAllowed('https://example.com/path', patterns)).to.be.false;
80
+ });
81
+
82
+ it('should reject non-http(s) schemes', () => {
83
+ const patterns = ['file:///etc/passwd'];
84
+ expect(isUrlAllowed('file:///etc/passwd', patterns)).to.be.false;
85
+ });
86
+
87
+ it('should reject ftp scheme', () => {
88
+ const patterns = ['ftp://files.example.com'];
89
+ expect(isUrlAllowed('ftp://files.example.com/data', patterns)).to.be.false;
90
+ });
91
+
92
+ it('should reject a URL when target uses non-http(s) scheme even if patterns exist', () => {
93
+ const patterns = ['https://example.com'];
94
+ expect(isUrlAllowed('file:///etc/passwd', patterns)).to.be.false;
95
+ expect(isUrlAllowed('ftp://example.com/data', patterns)).to.be.false;
96
+ });
97
+
98
+ it('should reject all URLs when allowlist is empty', () => {
99
+ expect(isUrlAllowed('https://example.com', [])).to.be.false;
100
+ expect(isUrlAllowed('http://localhost:3000', [])).to.be.false;
101
+ });
102
+
103
+ it('should reject invalid URLs', () => {
104
+ const patterns = ['https://example.com'];
105
+ expect(isUrlAllowed('not a url', patterns)).to.be.false;
106
+ expect(isUrlAllowed('', patterns)).to.be.false;
107
+ });
108
+
109
+ it('should allow private IPs only when explicitly in the allowlist', () => {
110
+ expect(isUrlAllowed('http://127.0.0.1:8080/secret', [])).to.be.false;
111
+ expect(isUrlAllowed('http://127.0.0.1:8080/secret', ['https://example.com'])).to.be.false;
112
+
113
+ const patternsWithLoopback = ['http://127.0.0.1:8080'];
114
+ expect(isUrlAllowed('http://127.0.0.1:8080/secret', patternsWithLoopback)).to.be.true;
115
+ });
116
+
117
+ it('should reject cloud metadata endpoint by default', () => {
118
+ expect(isUrlAllowed('http://169.254.169.254/latest/meta-data/', [])).to.be.false;
119
+ expect(isUrlAllowed('http://169.254.169.254/latest/meta-data/', ['https://example.com'])).to.be.false;
120
+ });
121
+
122
+ it('should merge multiple patterns correctly', () => {
123
+ const patterns = [
124
+ 'https://open-vsx.org',
125
+ 'https://marketplace.example.com',
126
+ 'https://*.cdn.example.com'
127
+ ];
128
+ expect(isUrlAllowed('https://open-vsx.org/api/v2/extensions', patterns)).to.be.true;
129
+ expect(isUrlAllowed('https://marketplace.example.com/download', patterns)).to.be.true;
130
+ expect(isUrlAllowed('https://files.cdn.example.com/ext.vsix', patterns)).to.be.true;
131
+ expect(isUrlAllowed('https://evil.com/malware', patterns)).to.be.false;
132
+ });
133
+
134
+ it('should handle patterns with trailing slashes', () => {
135
+ const patterns = ['https://example.com/'];
136
+ expect(isUrlAllowed('https://example.com/path', patterns)).to.be.true;
137
+ });
138
+
139
+ it('should handle invalid patterns gracefully', () => {
140
+ const patterns = ['not a valid pattern', 'https://example.com'];
141
+ expect(isUrlAllowed('https://example.com/path', patterns)).to.be.true;
142
+ expect(isUrlAllowed('https://other.com/path', patterns)).to.be.false;
143
+ });
144
+
145
+ it('should prevent host spoofing via subdomain prefix', () => {
146
+ const patterns = ['https://example.com'];
147
+ expect(isUrlAllowed('https://example.com.evil.com/path', patterns)).to.be.false;
148
+ });
149
+ });
150
+
151
+ describe('BackendRequestFacade', () => {
152
+
153
+ describe('configure', () => {
154
+
155
+ let configProviderStub: sinon.SinonStub;
156
+
157
+ beforeEach(() => {
158
+ configProviderStub = sinon.stub(BackendApplicationConfigProvider, 'get');
159
+ });
160
+
161
+ afterEach(() => {
162
+ configProviderStub.restore();
163
+ });
164
+
165
+ it('should be a no-op when configureProxyFromPreferences is false', async () => {
166
+ configProviderStub.returns({
167
+ singleInstance: true,
168
+ frontendConnectionTimeout: 0,
169
+ configurationFolder: '.theia',
170
+ configureProxyFromPreferences: false
171
+ });
172
+
173
+ const facade = new BackendRequestFacade();
174
+ const configureStub = sinon.stub().resolves();
175
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
176
+ (facade as any).requestService = {
177
+ configure: configureStub
178
+ };
179
+ await facade.configure({ proxyUrl: 'http://evil.com', strictSSL: false });
180
+ expect(configureStub.called).to.be.false;
181
+ });
182
+
183
+ it('should pass through to the underlying request service when configureProxyFromPreferences is true', async () => {
184
+ configProviderStub.returns({
185
+ singleInstance: true,
186
+ frontendConnectionTimeout: 0,
187
+ configurationFolder: '.theia',
188
+ configureProxyFromPreferences: true
189
+ });
190
+
191
+ const facade = new BackendRequestFacade();
192
+ const configureStub = sinon.stub().resolves();
193
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
194
+ (facade as any).requestService = {
195
+ configure: configureStub
196
+ };
197
+ const config = { proxyUrl: 'http://my-proxy.com:8080', strictSSL: true, proxyAuthorization: 'Bearer token' };
198
+ await facade.configure(config);
199
+ expect(configureStub.calledOnce).to.be.true;
200
+ expect(configureStub.firstCall.args[0]).to.deep.equal(config);
201
+ });
202
+ });
203
+ });
@@ -14,8 +14,87 @@
14
14
  * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
15
  ********************************************************************************/
16
16
 
17
- import { inject, injectable } from 'inversify';
17
+ import { inject, injectable, named, optional } from 'inversify';
18
18
  import { RequestConfiguration, RequestContext, RequestOptions, RequestService } from '@theia/request';
19
+ import { ContributionProvider, MaybePromise } from '../../common';
20
+ import { BackendApplicationConfigProvider } from '../backend-application-config-provider';
21
+
22
+ export const BackendRequestAllowedContribution = Symbol('BackendRequestAllowedContribution');
23
+ export interface BackendRequestAllowedContribution {
24
+ /**
25
+ * Returns URL patterns that should be allowed through the backend request facade.
26
+ * Patterns are matched against request URLs.
27
+ *
28
+ * Supports:
29
+ * - Exact base URLs: `https://open-vsx.org` (allows any path under this origin)
30
+ * - Wildcard subdomains: `https://*.github.com` (allows any subdomain)
31
+ *
32
+ * Only `http:` and `https:` schemes are permitted.
33
+ */
34
+ getAllowedUrlPatterns(): MaybePromise<string[]>;
35
+ }
36
+
37
+ const ALLOWED_SCHEMES = new Set(['http:', 'https:']);
38
+
39
+ export function isUrlAllowed(url: string, allowedPatterns: string[]): boolean {
40
+ let parsed: URL;
41
+ try {
42
+ parsed = new URL(url);
43
+ } catch {
44
+ return false;
45
+ }
46
+ if (!ALLOWED_SCHEMES.has(parsed.protocol)) {
47
+ return false;
48
+ }
49
+ for (const pattern of allowedPatterns) {
50
+ if (matchesPattern(parsed, pattern)) {
51
+ return true;
52
+ }
53
+ }
54
+ return false;
55
+ }
56
+
57
+ function matchesPattern(target: URL, pattern: string): boolean {
58
+ let patternUrl: URL;
59
+ try {
60
+ patternUrl = new URL(pattern);
61
+ } catch {
62
+ return false;
63
+ }
64
+ if (!ALLOWED_SCHEMES.has(patternUrl.protocol)) {
65
+ return false;
66
+ }
67
+ if (target.protocol !== patternUrl.protocol) {
68
+ return false;
69
+ }
70
+ const targetPort = getEffectivePort(target);
71
+ const patternPort = getEffectivePort(patternUrl);
72
+ if (targetPort !== patternPort) {
73
+ return false;
74
+ }
75
+ return matchesHost(target.hostname, patternUrl.hostname);
76
+ }
77
+
78
+ function getEffectivePort(url: URL): string {
79
+ if (url.port) {
80
+ return url.port;
81
+ }
82
+ if (url.protocol === 'https:') {
83
+ return '443';
84
+ }
85
+ if (url.protocol === 'http:') {
86
+ return '80';
87
+ }
88
+ return '';
89
+ }
90
+
91
+ function matchesHost(targetHost: string, patternHost: string): boolean {
92
+ if (patternHost.startsWith('*.')) {
93
+ const suffix = patternHost.substring(1); // e.g. ".example.com"
94
+ return targetHost.endsWith(suffix) && targetHost.length > suffix.length;
95
+ }
96
+ return targetHost === patternHost;
97
+ }
19
98
 
20
99
  @injectable()
21
100
  export class BackendRequestFacade implements RequestService {
@@ -23,11 +102,23 @@ export class BackendRequestFacade implements RequestService {
23
102
  @inject(RequestService)
24
103
  protected readonly requestService: RequestService;
25
104
 
26
- configure(config: RequestConfiguration): Promise<void> {
27
- return this.requestService.configure(config);
105
+ @inject(ContributionProvider) @named(BackendRequestAllowedContribution) @optional()
106
+ protected readonly allowedContributions?: ContributionProvider<BackendRequestAllowedContribution>;
107
+
108
+ protected cachedAllowedPatterns: string[] | undefined;
109
+
110
+ async configure(config: RequestConfiguration): Promise<void> {
111
+ if (BackendApplicationConfigProvider.get().configureProxyFromPreferences) {
112
+ return this.requestService.configure(config);
113
+ }
28
114
  }
29
115
 
30
116
  async request(options: RequestOptions): Promise<RequestContext> {
117
+ const patterns = await this.getAllowedPatterns();
118
+ const url = options.url;
119
+ if (!url || !isUrlAllowed(url, patterns)) {
120
+ throw new Error(`Request to URL '${url}' is not allowed. The URL does not match any allowed pattern.`);
121
+ }
31
122
  const context = await this.requestService.request(options);
32
123
  return RequestContext.compress(context);
33
124
  }
@@ -36,4 +127,18 @@ export class BackendRequestFacade implements RequestService {
36
127
  return this.requestService.resolveProxy(url);
37
128
  }
38
129
 
130
+ protected async getAllowedPatterns(): Promise<string[]> {
131
+ if (this.cachedAllowedPatterns) {
132
+ return this.cachedAllowedPatterns;
133
+ }
134
+ const patterns: string[] = [];
135
+ if (this.allowedContributions) {
136
+ for (const contribution of this.allowedContributions.getContributions()) {
137
+ const contributed = await contribution.getAllowedUrlPatterns();
138
+ patterns.push(...contributed);
139
+ }
140
+ }
141
+ this.cachedAllowedPatterns = patterns;
142
+ return patterns;
143
+ }
39
144
  }