@theia/core 1.73.0-next.40 → 1.73.0-next.51

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 (84) hide show
  1. package/README.md +1 -1
  2. package/lib/browser/catalog.json +23 -6
  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/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.js +2 -2
  7. package/lib/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.js.map +1 -1
  8. package/lib/browser/tree/tree-view-welcome-widget.d.ts +1 -0
  9. package/lib/browser/tree/tree-view-welcome-widget.d.ts.map +1 -1
  10. package/lib/browser/tree/tree-view-welcome-widget.js +8 -6
  11. package/lib/browser/tree/tree-view-welcome-widget.js.map +1 -1
  12. package/lib/browser/tree/tree-view-welcome-widget.spec.d.ts +2 -0
  13. package/lib/browser/tree/tree-view-welcome-widget.spec.d.ts.map +1 -0
  14. package/lib/browser/tree/tree-view-welcome-widget.spec.js +68 -0
  15. package/lib/browser/tree/tree-view-welcome-widget.spec.js.map +1 -0
  16. package/lib/electron-node/hosting/electron-ws-origin-validator.d.ts.map +1 -1
  17. package/lib/electron-node/hosting/electron-ws-origin-validator.js +5 -2
  18. package/lib/electron-node/hosting/electron-ws-origin-validator.js.map +1 -1
  19. package/lib/electron-node/hosting/electron-ws-origin-validator.spec.d.ts +2 -0
  20. package/lib/electron-node/hosting/electron-ws-origin-validator.spec.d.ts.map +1 -0
  21. package/lib/electron-node/hosting/electron-ws-origin-validator.spec.js +59 -0
  22. package/lib/electron-node/hosting/electron-ws-origin-validator.spec.js.map +1 -0
  23. package/lib/node/backend-application-module.d.ts.map +1 -1
  24. package/lib/node/backend-application-module.js +2 -0
  25. package/lib/node/backend-application-module.js.map +1 -1
  26. package/lib/node/backend-application.d.ts +9 -0
  27. package/lib/node/backend-application.d.ts.map +1 -1
  28. package/lib/node/backend-application.js +23 -1
  29. package/lib/node/backend-application.js.map +1 -1
  30. package/lib/node/backend-application.spec.js +1 -0
  31. package/lib/node/backend-application.spec.js.map +1 -1
  32. package/lib/node/hosting/backend-hosting-module.d.ts.map +1 -1
  33. package/lib/node/hosting/backend-hosting-module.js +7 -0
  34. package/lib/node/hosting/backend-hosting-module.js.map +1 -1
  35. package/lib/node/hosting/browser-connection-token.d.ts +46 -0
  36. package/lib/node/hosting/browser-connection-token.d.ts.map +1 -0
  37. package/lib/node/hosting/browser-connection-token.js +120 -0
  38. package/lib/node/hosting/browser-connection-token.js.map +1 -0
  39. package/lib/node/hosting/browser-connection-token.spec.d.ts +2 -0
  40. package/lib/node/hosting/browser-connection-token.spec.d.ts.map +1 -0
  41. package/lib/node/hosting/browser-connection-token.spec.js +128 -0
  42. package/lib/node/hosting/browser-connection-token.spec.js.map +1 -0
  43. package/lib/node/hosting/ws-origin-validator.d.ts.map +1 -1
  44. package/lib/node/hosting/ws-origin-validator.js +21 -4
  45. package/lib/node/hosting/ws-origin-validator.js.map +1 -1
  46. package/lib/node/hosting/ws-origin-validator.spec.d.ts +2 -0
  47. package/lib/node/hosting/ws-origin-validator.spec.d.ts.map +1 -0
  48. package/lib/node/hosting/ws-origin-validator.spec.js +131 -0
  49. package/lib/node/hosting/ws-origin-validator.spec.js.map +1 -0
  50. package/lib/node/index.d.ts +1 -0
  51. package/lib/node/index.d.ts.map +1 -1
  52. package/lib/node/index.js +3 -1
  53. package/lib/node/index.js.map +1 -1
  54. package/lib/node/messaging/websocket-endpoint.d.ts +5 -0
  55. package/lib/node/messaging/websocket-endpoint.d.ts.map +1 -1
  56. package/lib/node/messaging/websocket-endpoint.js +14 -5
  57. package/lib/node/messaging/websocket-endpoint.js.map +1 -1
  58. package/lib/node/request/backend-request-facade.d.ts +19 -0
  59. package/lib/node/request/backend-request-facade.d.ts.map +1 -1
  60. package/lib/node/request/backend-request-facade.js +93 -3
  61. package/lib/node/request/backend-request-facade.js.map +1 -1
  62. package/lib/node/request/backend-request-facade.spec.d.ts +2 -0
  63. package/lib/node/request/backend-request-facade.spec.d.ts.map +1 -0
  64. package/lib/node/request/backend-request-facade.spec.js +171 -0
  65. package/lib/node/request/backend-request-facade.spec.js.map +1 -0
  66. package/package.json +5 -6
  67. package/src/browser/messaging/ws-connection-source.ts +1 -6
  68. package/src/browser/shell/tab-bar-toolbar/tab-bar-toolbar-menu-adapters.tsx +2 -2
  69. package/src/browser/tree/tree-view-welcome-widget.spec.ts +80 -0
  70. package/src/browser/tree/tree-view-welcome-widget.tsx +10 -11
  71. package/src/electron-node/hosting/electron-ws-origin-validator.spec.ts +69 -0
  72. package/src/electron-node/hosting/electron-ws-origin-validator.ts +5 -2
  73. package/src/node/backend-application-module.ts +4 -2
  74. package/src/node/backend-application.spec.ts +2 -0
  75. package/src/node/backend-application.ts +18 -0
  76. package/src/node/hosting/backend-hosting-module.ts +10 -0
  77. package/src/node/hosting/browser-connection-token.spec.ts +167 -0
  78. package/src/node/hosting/browser-connection-token.ts +126 -0
  79. package/src/node/hosting/ws-origin-validator.spec.ts +193 -0
  80. package/src/node/hosting/ws-origin-validator.ts +23 -4
  81. package/src/node/index.ts +1 -0
  82. package/src/node/messaging/websocket-endpoint.ts +17 -5
  83. package/src/node/request/backend-request-facade.spec.ts +203 -0
  84. package/src/node/request/backend-request-facade.ts +108 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theia/core",
3
- "version": "1.73.0-next.40+201e8aea5",
3
+ "version": "1.73.0-next.51+154e74fa4",
4
4
  "description": "Theia is a cloud & desktop IDE framework implemented in TypeScript.",
5
5
  "main": "lib/common/index.js",
6
6
  "typings": "lib/common/index.d.ts",
@@ -17,8 +17,8 @@
17
17
  "@lumino/virtualdom": "^2.0.4",
18
18
  "@lumino/widgets": "2.7.5",
19
19
  "@parcel/watcher": "^2.5.6",
20
- "@theia/application-package": "1.73.0-next.40+201e8aea5",
21
- "@theia/request": "1.73.0-next.40+201e8aea5",
20
+ "@theia/application-package": "1.73.0-next.51+154e74fa4",
21
+ "@theia/request": "1.73.0-next.51+154e74fa4",
22
22
  "@types/body-parser": "^1.19.6",
23
23
  "@types/express": "^4.17.25",
24
24
  "@types/fs-extra": "^4.0.15",
@@ -28,7 +28,6 @@
28
28
  "@types/markdown-it-emoji": "^3.0.1",
29
29
  "@types/route-parser": "^0.1.7",
30
30
  "@types/safer-buffer": "^2.1.3",
31
- "@types/uuid": "^9.0.8",
32
31
  "@types/ws": "^8.18.1",
33
32
  "@types/yargs": "^17.0.35",
34
33
  "@vscode/codicons": "0.0.45",
@@ -66,7 +65,7 @@
66
65
  "socket.io": "^4.8.3",
67
66
  "socket.io-client": "^4.8.3",
68
67
  "tslib": "^2.8.1",
69
- "uuid": "^9.0.1",
68
+ "uuid": "^11.1.1",
70
69
  "vscode-languageserver-protocol": "3.17.5",
71
70
  "vscode-uri": "3.0.8",
72
71
  "ws": "^8.21.0",
@@ -231,5 +230,5 @@
231
230
  "nyc": {
232
231
  "extends": "../../configs/nyc.json"
233
232
  },
234
- "gitHead": "201e8aea5001e76ba316499bc634ae485b97b098"
233
+ "gitHead": "154e74fa48e7b7427a902630240df88e7865f4b1"
235
234
  }
@@ -208,12 +208,7 @@ export class WebSocketConnectionSource implements ConnectionSource {
208
208
  reconnection: true,
209
209
  reconnectionDelay: 1000,
210
210
  reconnectionDelayMax: 10000,
211
- reconnectionAttempts: Infinity,
212
- extraHeaders: {
213
- // Socket.io strips the `origin` header
214
- // We need to provide our own for validation
215
- 'fix-origin': window.location.origin
216
- }
211
+ reconnectionAttempts: Infinity
217
212
  });
218
213
  }
219
214
 
@@ -98,7 +98,7 @@ abstract class AbstractToolbarMenuWrapper {
98
98
  const className = `${icon} ${ACTION_ITEM}`;
99
99
  if (CompoundMenuNode.is(this.menuNode) && !this.menuNode.isEmpty(this.effectiveMenuPath, this.contextKeyService, widget.node, widget)) {
100
100
  return <div key={this.id} className={TabBarToolbar.Styles.TAB_BAR_TOOLBAR_ITEM + ' enabled menu'}>
101
- <div className={className}
101
+ <div id={this.id} className={className}
102
102
  title={this.tooltip || this.text}
103
103
  onClick={e => this.executeCommand(widget, e)}
104
104
  />
@@ -108,7 +108,7 @@ abstract class AbstractToolbarMenuWrapper {
108
108
  </div>;
109
109
  } else {
110
110
  return <div key={this.id} className={TabBarToolbar.Styles.TAB_BAR_TOOLBAR_ITEM + ' enabled menu'}>
111
- <div className={className}
111
+ <div id={this.id} className={className}
112
112
  title={this.tooltip || this.text}
113
113
  onClick={e => this.executeCommand(widget, e)}
114
114
  />
@@ -0,0 +1,80 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource 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 { enableJSDOM } from '../test/jsdom';
18
+
19
+ // The widget module transitively imports `@lumino/widgets`, which touches `document` at load time.
20
+ const disableJSDOM = enableJSDOM();
21
+
22
+ import { Container } from 'inversify';
23
+ import { expect } from 'chai';
24
+ import { ReactElement } from 'react';
25
+ import { CommandService } from '../../common';
26
+ import { LabelParser } from '../label-parser';
27
+ import { codicon } from '../widgets';
28
+ import { TreeViewWelcomeWidget } from './tree-view-welcome-widget';
29
+
30
+ disableJSDOM();
31
+
32
+ let labelParser: LabelParser;
33
+
34
+ before(() => {
35
+ const container = new Container();
36
+ container.bind(LabelParser).toSelf().inSingletonScope();
37
+ container.bind(CommandService).toDynamicValue(() => ({
38
+ executeCommand<T>(): Promise<T | undefined> {
39
+ return Promise.resolve(undefined);
40
+ }
41
+ })).inSingletonScope();
42
+ labelParser = container.get(LabelParser);
43
+ });
44
+
45
+ describe('TreeViewWelcomeWidget#renderLabelWithIcons', () => {
46
+
47
+ // Exercise the protected helper without standing up the full TreeWidget DI graph.
48
+ function render(label: string): ReactElement[] {
49
+ const widget = Object.create(TreeViewWelcomeWidget.prototype) as {
50
+ labelParser: LabelParser;
51
+ renderLabelWithIcons(label: string): ReactElement[];
52
+ };
53
+ widget.labelParser = labelParser;
54
+ return widget.renderLabelWithIcons(label);
55
+ }
56
+
57
+ it('renders a leading $(codicon) as a codicon span and keeps the trailing text', () => {
58
+ const nodes = render('$(robot) Launch AI Agent');
59
+ expect(nodes).to.have.lengthOf(2);
60
+ expect(nodes[0].props.className).to.equal(codicon('robot'));
61
+ expect(nodes[0].props.children).to.be.undefined;
62
+ expect(nodes[1].props.className).to.be.undefined;
63
+ expect(nodes[1].props.children).to.equal(' Launch AI Agent');
64
+ });
65
+
66
+ it('renders a plain label as a single text span with no codicon', () => {
67
+ const nodes = render('Open Documentation');
68
+ expect(nodes).to.have.lengthOf(1);
69
+ expect(nodes[0].props.className).to.be.undefined;
70
+ expect(nodes[0].props.children).to.equal('Open Documentation');
71
+ });
72
+
73
+ it('renders multiple codicons interleaved with text', () => {
74
+ const nodes = render('$(package) Import $(gear) Library');
75
+ const iconNodes = nodes.filter(n => typeof n.props.className === 'string' && n.props.className.includes('codicon-'));
76
+ expect(iconNodes).to.have.lengthOf(2);
77
+ expect(iconNodes[0].props.className).to.equal(codicon('package'));
78
+ expect(iconNodes[1].props.className).to.equal(codicon('gear'));
79
+ });
80
+ });
@@ -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
+ });