@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
@@ -209,22 +209,14 @@ export class TreeViewWelcomeWidget extends TreeWidget {
209
209
  className='theia-button theia-WelcomeViewButton'
210
210
  disabled={!this.isEnabledClick(enablement)}
211
211
  onClick={e => this.openLinkOrCommand(e, node.href)}>
212
- {node.label}
212
+ {this.renderLabelWithIcons(node.label)}
213
213
  </button>
214
214
  </div>
215
215
  );
216
216
  }
217
217
 
218
218
  protected renderTextNode(node: string, textKey: string | number): React.ReactNode {
219
- return <span key={`text-${textKey}`}>
220
- {this.labelParser.parse(node)
221
- .map((segment, index) =>
222
- LabelIcon.is(segment)
223
- ? <span
224
- key={index}
225
- className={codicon(segment.name)}
226
- />
227
- : <span key={index}>{segment}</span>)}</span>;
219
+ return <span key={`text-${textKey}`}>{this.renderLabelWithIcons(node)}</span>;
228
220
  }
229
221
 
230
222
  protected renderLinkNode(node: ILink, linkKey: string | number, enablement: string | undefined): React.ReactNode {
@@ -233,11 +225,18 @@ export class TreeViewWelcomeWidget extends TreeWidget {
233
225
  className={this.getLinkClassName(node.href, enablement)}
234
226
  title={node.title || ''}
235
227
  onClick={e => this.openLinkOrCommand(e, node.href)}>
236
- {node.label}
228
+ {this.renderLabelWithIcons(node.label)}
237
229
  </a>
238
230
  );
239
231
  }
240
232
 
233
+ protected renderLabelWithIcons(label: string): React.ReactNode[] {
234
+ return this.labelParser.parse(label).map((segment, index) =>
235
+ LabelIcon.is(segment)
236
+ ? <span key={index} className={codicon(segment.name)} />
237
+ : <span key={index}>{segment}</span>);
238
+ }
239
+
241
240
  protected getLinkClassName(href: string, enablement: string | undefined): string {
242
241
  const classNames = ['theia-WelcomeViewCommandLink'];
243
242
  // Only command-backed links can be disabled. All other, https:, file: remain enabled
@@ -0,0 +1,69 @@
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 { ElectronWsOriginValidator } from './electron-ws-origin-validator';
20
+ import { BackendRemoteService } from '../../node/remote/backend-remote-service';
21
+
22
+ describe('ElectronWsOriginValidator', () => {
23
+
24
+ function createValidator(isRemote = false): ElectronWsOriginValidator {
25
+ const remoteService = { isRemoteServer: () => isRemote } as BackendRemoteService;
26
+ const validator = new ElectronWsOriginValidator();
27
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
+ (validator as any)['backendRemoteService'] = remoteService;
29
+ return validator;
30
+ }
31
+
32
+ function createRequest(origin?: string): http.IncomingMessage {
33
+ const request = {
34
+ headers: {} as http.IncomingHttpHeaders
35
+ } as http.IncomingMessage;
36
+ if (origin !== undefined) {
37
+ request.headers.origin = origin;
38
+ }
39
+ return request;
40
+ }
41
+
42
+ it('should allow file:// origin (standard Electron case)', () => {
43
+ expect(createValidator().allowWsUpgrade(createRequest('file://'))).to.be.true;
44
+ });
45
+
46
+ it('should allow null origin (opaque origin from file:// pages)', () => {
47
+ expect(createValidator().allowWsUpgrade(createRequest('null'))).to.be.true;
48
+ });
49
+
50
+ it('should allow absent origin (same-origin polling requests)', () => {
51
+ expect(createValidator().allowWsUpgrade(createRequest(undefined))).to.be.true;
52
+ });
53
+
54
+ it('should reject cross-origin requests', () => {
55
+ expect(createValidator().allowWsUpgrade(createRequest('http://evil.com'))).to.be.false;
56
+ });
57
+
58
+ it('should reject http://localhost origin (not expected in Electron)', () => {
59
+ expect(createValidator().allowWsUpgrade(createRequest('http://localhost:3000'))).to.be.false;
60
+ });
61
+
62
+ it('should allow empty string origin (treated as absent)', () => {
63
+ expect(createValidator().allowWsUpgrade(createRequest(''))).to.be.true;
64
+ });
65
+
66
+ it('should allow any origin when running as remote server', () => {
67
+ expect(createValidator(true).allowWsUpgrade(createRequest('http://remote-host.com'))).to.be.true;
68
+ });
69
+ });
@@ -30,8 +30,11 @@ export class ElectronWsOriginValidator implements WsRequestValidatorContribution
30
30
  if (this.backendRemoteService.isRemoteServer()) {
31
31
  return true;
32
32
  }
33
+ const origin = request.headers.origin;
33
34
  // On Electron the main page is served from the `file` protocol.
34
- // We don't expect the requests to come from anywhere else.
35
- return request.headers.origin === 'file://';
35
+ // Chromium may send "file://" or "null" (opaque origin) or omit the
36
+ // header entirely depending on the request type (WebSocket upgrade vs
37
+ // HTTP polling). All three are expected for same-origin Electron requests.
38
+ return !origin || origin === 'file://' || origin === 'null';
36
39
  }
37
40
  }
@@ -36,7 +36,7 @@ import {
36
36
  } from '../common';
37
37
  import {
38
38
  BackendApplication, BackendApplicationContribution, BackendApplicationCliContribution,
39
- BackendApplicationServer, BackendApplicationPath, RootContainer
39
+ BackendApplicationServer, BackendApplicationPath, EarlyExpressMiddleware, RootContainer
40
40
  } from './backend-application';
41
41
  import { CliManager, CliContribution } from './cli';
42
42
  import { IPCConnectionProvider } from './messaging';
@@ -55,7 +55,7 @@ import { ProcessUtils } from './process-utils';
55
55
  import { ProxyCliContribution } from './request/proxy-cli-contribution';
56
56
  import { bindNodeStopwatch, bindBackendStopwatchServer } from './performance';
57
57
  import { OSBackendProviderImpl } from './os-backend-provider';
58
- import { BackendRequestFacade } from './request/backend-request-facade';
58
+ import { BackendRequestFacade, BackendRequestAllowedContribution } from './request/backend-request-facade';
59
59
  import { FileSystemLocking, FileSystemLockingImpl } from './filesystem-locking';
60
60
  import { BackendRemoteService } from './remote/backend-remote-service';
61
61
  import { RemoteCliContribution } from './remote/remote-cli-contribution';
@@ -89,6 +89,7 @@ export const backendApplicationModule = new ContainerModule(bind => {
89
89
  bind(BackendApplicationCliContribution).toSelf().inSingletonScope();
90
90
  bind(CliContribution).toService(BackendApplicationCliContribution);
91
91
 
92
+ bind(EarlyExpressMiddleware).toSelf().inSingletonScope();
92
93
  bind(BackendApplication).toSelf().inSingletonScope();
93
94
  bind(RootContainer).toDynamicValue(({ container }) => {
94
95
  let root = container;
@@ -152,6 +153,7 @@ export const backendApplicationModule = new ContainerModule(bind => {
152
153
 
153
154
  bindRootContributionProvider(bind, RemoteCliContribution);
154
155
  bind(BackendRemoteService).toSelf().inSingletonScope();
156
+ bindRootContributionProvider(bind, BackendRequestAllowedContribution);
155
157
  bind(BackendRequestFacade).toSelf().inSingletonScope();
156
158
  bind(ConnectionHandler).toDynamicValue(
157
159
  ctx => new RpcConnectionHandler(REQUEST_SERVICE_PATH, () => ctx.container.get(BackendRequestFacade))
@@ -25,6 +25,7 @@ import {
25
25
  BackendApplication,
26
26
  BackendApplicationCliContribution,
27
27
  BackendApplicationContribution,
28
+ EarlyExpressMiddleware,
28
29
  RootContainer
29
30
  } from './backend-application';
30
31
  import { CliContribution } from './cli';
@@ -87,6 +88,7 @@ describe('BackendApplication', () => {
87
88
  container.bind(ProcessUtils).toSelf().inSingletonScope();
88
89
  container.bind(BackendApplicationCliContribution).toSelf().inSingletonScope();
89
90
  container.bind(CliContribution).toService(BackendApplicationCliContribution);
91
+ container.bind(EarlyExpressMiddleware).toSelf().inSingletonScope();
90
92
  bindContributionProvider(container, BackendApplicationContribution);
91
93
 
92
94
  container.bind(TestBackendApplication).toSelf().inSingletonScope();
@@ -54,6 +54,16 @@ const DEFAULT_HOST = 'localhost';
54
54
  const DEFAULT_SSL = false;
55
55
  const DEFAULT_DNS_DEFAULT_RESULT_ORDER: DnsResultOrder = 'ipv4first';
56
56
 
57
+ /**
58
+ * Shared registry for Express middleware that must run before static file handlers.
59
+ * Contributions push handlers during `initialize()`, and `BackendApplication`
60
+ * applies them at the start of `configure()`, before gzipped handlers and `express.static()`.
61
+ */
62
+ @injectable()
63
+ export class EarlyExpressMiddleware {
64
+ readonly handlers: express.Handler[] = [];
65
+ }
66
+
57
67
  export const BackendApplicationServer = Symbol('BackendApplicationServer');
58
68
  /**
59
69
  * This service is responsible for serving the frontend files.
@@ -175,6 +185,9 @@ export class BackendApplication {
175
185
 
176
186
  protected readonly app: express.Application = express();
177
187
 
188
+ @inject(EarlyExpressMiddleware)
189
+ protected readonly earlyMiddleware: EarlyExpressMiddleware;
190
+
178
191
  @inject(ProcessUtils)
179
192
  protected readonly processUtils: ProcessUtils;
180
193
 
@@ -244,6 +257,11 @@ export class BackendApplication {
244
257
  protected async configure(): Promise<void> {
245
258
  await this.initialize();
246
259
 
260
+ // Apply early middleware before static file handlers.
261
+ for (const handler of this.earlyMiddleware.handlers) {
262
+ this.app.use(handler);
263
+ }
264
+
247
265
  this.app.get('*.js', this.serveGzipped.bind(this, 'text/javascript'));
248
266
  this.app.get('*.js.map', this.serveGzipped.bind(this, 'application/json'));
249
267
  this.app.get('*.css', this.serveGzipped.bind(this, 'text/css'));
@@ -15,12 +15,22 @@
15
15
  // *****************************************************************************
16
16
 
17
17
  import { ContainerModule } from 'inversify';
18
+ import { BackendApplicationContribution } from '../backend-application';
18
19
  import { WsRequestValidatorContribution } from '../ws-request-validators';
19
20
  import { BackendApplicationHosts } from './backend-application-hosts';
21
+ import {
22
+ BrowserConnectionToken, BrowserConnectionTokenBackendContribution, createBrowserConnectionToken
23
+ } from './browser-connection-token';
20
24
  import { WsOriginValidator } from './ws-origin-validator';
21
25
 
22
26
  export default new ContainerModule(bind => {
23
27
  bind(BackendApplicationHosts).toSelf().inSingletonScope();
24
28
  bind(WsOriginValidator).toSelf().inSingletonScope();
25
29
  bind(WsRequestValidatorContribution).toService(WsOriginValidator);
30
+
31
+ // Cookie-based connection token protects both HTTP and WebSocket endpoints.
32
+ bind(BrowserConnectionToken).toConstantValue(createBrowserConnectionToken());
33
+ bind(BrowserConnectionTokenBackendContribution).toSelf().inSingletonScope();
34
+ bind(BackendApplicationContribution).toService(BrowserConnectionTokenBackendContribution);
35
+ bind(WsRequestValidatorContribution).toService(BrowserConnectionTokenBackendContribution);
26
36
  });
@@ -0,0 +1,167 @@
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 {
20
+ BrowserConnectionToken,
21
+ BrowserConnectionTokenBackendContribution,
22
+ BROWSER_TOKEN_COOKIE_NAME,
23
+ createBrowserConnectionToken
24
+ } from './browser-connection-token';
25
+
26
+ describe('BrowserConnectionToken', () => {
27
+
28
+ it('should generate unique tokens', () => {
29
+ const token1 = createBrowserConnectionToken();
30
+ const token2 = createBrowserConnectionToken();
31
+ expect(token1.value).to.not.equal(token2.value);
32
+ });
33
+
34
+ it('should generate tokens of sufficient length', () => {
35
+ const token = createBrowserConnectionToken();
36
+ expect(token.value.length).to.be.greaterThan(16);
37
+ });
38
+ });
39
+
40
+ describe('BrowserConnectionTokenBackendContribution', () => {
41
+
42
+ const token: BrowserConnectionToken = createBrowserConnectionToken();
43
+
44
+ function createContribution(): BrowserConnectionTokenBackendContribution {
45
+ const contribution = new BrowserConnectionTokenBackendContribution();
46
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
47
+ (contribution as any)['browserConnectionToken'] = token;
48
+ return contribution;
49
+ }
50
+
51
+ function createRequest(cookieValue?: string): http.IncomingMessage {
52
+ const request = {
53
+ headers: {} as http.IncomingHttpHeaders
54
+ } as http.IncomingMessage;
55
+ if (cookieValue !== undefined) {
56
+ request.headers.cookie = `${BROWSER_TOKEN_COOKIE_NAME}=${cookieValue}`;
57
+ }
58
+ return request;
59
+ }
60
+
61
+ describe('allowWsUpgrade (WebSocket validation)', () => {
62
+
63
+ it('should allow WebSocket with valid token cookie', () => {
64
+ const contribution = createContribution();
65
+ expect(contribution.allowWsUpgrade(createRequest(token.value))).to.be.true;
66
+ });
67
+
68
+ it('should reject WebSocket with no cookie', () => {
69
+ const contribution = createContribution();
70
+ expect(contribution.allowWsUpgrade(createRequest())).to.be.false;
71
+ });
72
+
73
+ it('should reject WebSocket with invalid token', () => {
74
+ const contribution = createContribution();
75
+ expect(contribution.allowWsUpgrade(createRequest('wrong-token'))).to.be.false;
76
+ });
77
+
78
+ it('should reject WebSocket with stale token from previous server', () => {
79
+ const contribution = createContribution();
80
+ const staleToken = createBrowserConnectionToken().value;
81
+ expect(contribution.allowWsUpgrade(createRequest(staleToken))).to.be.false;
82
+ });
83
+
84
+ it('should reject WebSocket with empty token', () => {
85
+ const contribution = createContribution();
86
+ expect(contribution.allowWsUpgrade(createRequest(''))).to.be.false;
87
+ });
88
+
89
+ it('should handle multiple cookies correctly', () => {
90
+ const contribution = createContribution();
91
+ const request = {
92
+ headers: {
93
+ cookie: `other-cookie=foo; ${BROWSER_TOKEN_COOKIE_NAME}=${token.value}; another=bar`
94
+ } as http.IncomingHttpHeaders
95
+ } as http.IncomingMessage;
96
+ expect(contribution.allowWsUpgrade(request)).to.be.true;
97
+ });
98
+ });
99
+
100
+ describe('expressMiddleware (HTTP cookie flow)', () => {
101
+
102
+ interface CookieCall {
103
+ name: string;
104
+ value: string;
105
+ options: Record<string, unknown>;
106
+ }
107
+
108
+ interface MockResponse {
109
+ cookieCalls: CookieCall[];
110
+ nextCalled: boolean;
111
+ }
112
+
113
+ function callMiddleware(cookieValue?: string): MockResponse {
114
+ const contribution = createContribution();
115
+ const result: MockResponse = { cookieCalls: [], nextCalled: false };
116
+
117
+ const req = {
118
+ headers: {} as Record<string, string | undefined>,
119
+ socket: { remoteAddress: '127.0.0.1' }
120
+ };
121
+ if (cookieValue !== undefined) {
122
+ req.headers.cookie = `${BROWSER_TOKEN_COOKIE_NAME}=${cookieValue}`;
123
+ }
124
+
125
+ const res = {
126
+ cookie: (name: string, value: string, options: Record<string, unknown>) => {
127
+ result.cookieCalls.push({ name, value, options });
128
+ }
129
+ };
130
+
131
+ const next = (): void => { result.nextCalled = true; };
132
+
133
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
134
+ (contribution as any).expressMiddleware(req, res, next);
135
+ return result;
136
+ }
137
+
138
+ it('should set cookie and allow request when no cookie present', () => {
139
+ const result = callMiddleware();
140
+ expect(result.nextCalled).to.be.true;
141
+ expect(result.cookieCalls).to.have.length(1);
142
+ expect(result.cookieCalls[0].name).to.equal(BROWSER_TOKEN_COOKIE_NAME);
143
+ expect(result.cookieCalls[0].value).to.equal(token.value);
144
+ });
145
+
146
+ it('should set cookie with HttpOnly, SameSite=Strict, and path=/', () => {
147
+ const result = callMiddleware();
148
+ const opts = result.cookieCalls[0].options;
149
+ expect(opts.httpOnly).to.be.true;
150
+ expect(opts.sameSite).to.equal('strict');
151
+ expect(opts.path).to.equal('/');
152
+ });
153
+
154
+ it('should allow request with valid cookie without re-setting it', () => {
155
+ const result = callMiddleware(token.value);
156
+ expect(result.nextCalled).to.be.true;
157
+ expect(result.cookieCalls).to.have.length(0);
158
+ });
159
+
160
+ it('should refresh stale cookie and allow request', () => {
161
+ const result = callMiddleware('stale-token-from-old-server');
162
+ expect(result.nextCalled).to.be.true;
163
+ expect(result.cookieCalls).to.have.length(1);
164
+ expect(result.cookieCalls[0].value).to.equal(token.value);
165
+ });
166
+ });
167
+ });
@@ -0,0 +1,126 @@
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 * as cookie from 'cookie';
18
+ import * as crypto from 'crypto';
19
+ import * as http from 'http';
20
+ import express = require('express');
21
+ import { inject, injectable } from 'inversify';
22
+ import { environment } from '../../common/index';
23
+ import { MaybePromise } from '../../common';
24
+ import { BackendApplicationContribution, EarlyExpressMiddleware } from '../backend-application';
25
+ import { WsRequestValidatorContribution } from '../ws-request-validators';
26
+ import { generateUuid } from '../../common/uuid';
27
+
28
+ export const BrowserConnectionToken = Symbol('BrowserConnectionToken');
29
+
30
+ export const BROWSER_TOKEN_COOKIE_NAME = 'theia-connection-token';
31
+
32
+ export interface BrowserConnectionToken {
33
+ value: string;
34
+ }
35
+
36
+ /**
37
+ * Validates WebSocket and HTTP requests using a cookie-based connection token.
38
+ *
39
+ * In browser deployments, the server generates a random token at startup and sets it
40
+ * as a `SameSite=Strict; HttpOnly` cookie on the first page load. Cross-origin pages
41
+ * cannot obtain or send this cookie, so their requests are rejected.
42
+ *
43
+ * This complements the origin validator: non-browser callers that omit the Origin
44
+ * header (e.g. Node.js scripts) still cannot reach the backend without the cookie.
45
+ *
46
+ * Skipped in Electron deployments (which use their own `ElectronSecurityToken`).
47
+ */
48
+ @injectable()
49
+ export class BrowserConnectionTokenBackendContribution implements BackendApplicationContribution, WsRequestValidatorContribution {
50
+
51
+ @inject(BrowserConnectionToken)
52
+ protected readonly browserConnectionToken: BrowserConnectionToken;
53
+
54
+ @inject(EarlyExpressMiddleware)
55
+ protected readonly earlyMiddleware: EarlyExpressMiddleware;
56
+
57
+ /**
58
+ * Register the cookie middleware during `initialize()` via `EarlyExpressMiddleware`
59
+ * so it runs before `express.static()` (which is registered later during `configure()`).
60
+ * This ensures the browser receives the token cookie on the initial page load.
61
+ */
62
+ initialize(): void {
63
+ if (environment.electron.is()) {
64
+ return;
65
+ }
66
+ this.earlyMiddleware.handlers.push((req, res, next) => this.expressMiddleware(req, res, next));
67
+ }
68
+
69
+ /**
70
+ * Validate the connection token cookie on WebSocket upgrade requests.
71
+ * Non-browser callers that omit the Origin header (e.g. Node.js scripts)
72
+ * cannot provide the `SameSite=Strict` cookie either, so they are rejected.
73
+ */
74
+ allowWsUpgrade(request: http.IncomingMessage): MaybePromise<boolean> {
75
+ if (environment.electron.is()) {
76
+ return true;
77
+ }
78
+ const token = this.getTokenFromCookie(request);
79
+ if (token) {
80
+ return this.isTokenValid(token);
81
+ }
82
+ // No cookie: reject. Legitimate browsers always have the cookie
83
+ // because it is set on the initial page load.
84
+ return false;
85
+ }
86
+
87
+ protected expressMiddleware(req: express.Request, res: express.Response, next: express.NextFunction): void {
88
+ const existing = this.getTokenFromCookie(req);
89
+ if (!existing || !this.isTokenValid(existing)) {
90
+ // No cookie or stale cookie (e.g. after server restart) so (re-)issue it.
91
+ // The browser will use the fresh token on subsequent requests.
92
+ res.cookie(BROWSER_TOKEN_COOKIE_NAME, this.browserConnectionToken.value, {
93
+ httpOnly: true,
94
+ sameSite: 'strict',
95
+ path: '/'
96
+ });
97
+ }
98
+ next();
99
+ }
100
+
101
+ protected getTokenFromCookie(req: http.IncomingMessage): string | undefined {
102
+ const cookieHeader = req.headers.cookie;
103
+ if (cookieHeader) {
104
+ return cookie.parse(cookieHeader)[BROWSER_TOKEN_COOKIE_NAME];
105
+ }
106
+ return undefined;
107
+ }
108
+
109
+ protected isTokenValid(token: string): boolean {
110
+ try {
111
+ const received = Buffer.from(token, 'utf8');
112
+ const expected = Buffer.from(this.browserConnectionToken.value, 'utf8');
113
+ return received.byteLength === expected.byteLength && crypto.timingSafeEqual(received, expected);
114
+ } catch (error) {
115
+ console.error(error);
116
+ }
117
+ return false;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Creates a new browser connection token.
123
+ */
124
+ export function createBrowserConnectionToken(): BrowserConnectionToken {
125
+ return { value: generateUuid() };
126
+ }